Skip to content

读懂规范与旧代码:抽象操作、eval 和兼容边界 ​

当两篇资料对同一段代码给出不同解释,先找到它们讨论的运行环境和年代,再回到规范。规范是精确说明语言行为的工具,阅读方式与入门教程不同;本篇用几个小问题建立入口。

先找到正在执行的语言操作 ​

js
const label = {
  [Symbol.toPrimitive](hint) {
    console.log(hint)
    return '7'
  }
}
console.log(label + 2) // 先打印 default,再打印 72
console.log(label - 2) // 先打印 number,再打印 5

问题不是“对象和数字相加有什么口诀”,而是两个运算符分别要求怎样处理操作数。加法会先进行原始值转换,再决定字符串连接还是数值运算;减法按数值路径处理。对象提供了转换钩子,所以转换过程也可能执行用户代码。

阅读规范时,可以先找到加法或减法语义,再沿调用进入 ToPrimitive、ToNumeric 等抽象操作。名字看起来像函数,却不表示全局存在可调用的 ToPrimitive()。

对象没有转换钩子时,还会按对应规则尝试 valueOf、toString;如果始终得不到原始值,转换会抛出异常。掌握这条因果链,比给每一对类型各背一个结果更能应付新情况。

先求操作数,再执行转换 ​

“从左到右”必须说明针对哪一层操作。下面把操作数求值与原始值转换分别记录:

js
const log = []
function operand(name, result) {
  log.push(`求值${name}`)
  return {
    [Symbol.toPrimitive](hint) {
      log.push(`转换${name}:${hint}`)
      return result
    }
  }
}
const sum = operand('左', '8') + operand('右', 1)
console.log(sum) // 81
console.log(log.join(' → '))
// 求值左 → 求值右 → 转换左:default → 转换右:default

两侧表达式先产生值,然后加法处理原始值转换。左侧得到字符串后,并不意味着可以完全跳过右侧转换;两边需要先取得相应原始值,才能沿字符串连接的分支继续。若左侧转换抛错,右侧操作数表达式已经执行,但右侧转换尚未执行,这能解释一些“明明报错却已经发生副作用”的程序。

在规范中逐层读 Evaluation、GetValue、ToPrimitive,才有办法把这些时刻分开。还应注意,ToPrimitive 的结果可以是任意原始类型,不保证是数字;之后的 ToNumeric 等操作才继续施加要求。普通转换尝试得到对象时可以继续尝试另一个方法,而自定义 Symbol.toPrimitive 返回对象会直接失败,不能把两条路径混为一谈。

问号、感叹号和双中括号不是新语法 ​

规范记号含义容易混淆之处
[[Prototype]] 等内部槽或内部方法的记法不是通过同名字符串属性就能直接读写
? Operation(...)发生异常完成时继续传播,否则取正常结果与 JavaScript 可选链不是同一语法
! Operation(...)该处断言操作正常完成,再取结果不是 JavaScript 逻辑非
Completion Record描述正常返回、抛错、跳转等控制流不是你收到的普通结果对象
Host 开头的操作由宿主参与定义的行为不能仅凭 ECMAScript 推出完整浏览器行为

例如 getter 抛出错误时,外部读取会随之失败,而不是得到一个名为 throw 的对象:

js
const config = {
  get timeout() { throw new Error('配置不可读取') }
}
try {
  console.log(config.timeout)
} catch (error) {
  console.log(error.message) // 配置不可读取
}

规范用完成记录精确描述传播;日常 JavaScript 用 try/catch 处理这个结果。二者是描述层和代码层的关系。

Reference Record 怎样解释方法的接收者 ​

第二章介绍过提取方法可能改变 this。到这里可以追踪表达式的中间结果:属性访问先产生一个描述“从哪里、以什么名字取值”的 Reference Record;取值操作才得到函数对象。调用语义还可能利用保留下来的来源决定接收者。

js
const panel = {
  title: '目录',
  read() { return this === undefined ? '无接收者' : this.title }
}
console.log(panel.read())              // 目录
console.log((panel.read)())            // 目录
console.log((0, panel.read)())         // 无接收者
console.log((true ? panel.read : null)()) // 无接收者
const detached = panel.read
console.log(detached())                // 无接收者

本例在模块的严格模式中运行。单纯的分组括号保留相关求值结果;逗号表达式和条件表达式的相应路径会取出值,后续调用便失去原对象的属性来源。把方法赋给普通变量也只保存函数值。因此不能把规则简化成“出现括号就丢失 this”,或“除点调用以外全部一样”。

Reference Record 不是“对象引用值”的另一个名称。变量里保存的对象值可以传给其他函数;规范中的 Reference Record 则用于描述名称、属性、赋值与调用的求值过程,不能直接作为普通 JS 值取出来检查。现代规范还要处理环境绑定、super 的接收者、私有名称等情况,所以旧教程用三元组画出的示意只覆盖一部分用途。

finally 的完成方式可以覆盖原来的结果 ​

js
function inspect(mode) {
  try {
    if (mode === 'throw') throw new Error('读取失败')
    return '正常返回'
  } finally {
    if (mode === 'replace') return '清理块返回'
    if (mode === 'throw') throw new Error('关闭失败')
  }
}
console.log(inspect('normal'))  // 正常返回
console.log(inspect('replace')) // 清理块返回
try { inspect('throw') } catch (error) {
  console.log(error.message)    // 关闭失败
}

try 产生的结果暂时等候 finally。如果清理块正常完成,就继续先前的返回或异常;如果清理块自己 return 或 throw,则新的完成方式取代先前结果。因而“finally 一定执行,所以原异常一定能到 catch”缺少前提。

规范的 Completion Record 把正常、返回、抛错等完成方式放进统一模型。? 表示需要传播异常完成;它不会回滚异常发生前的副作用。读清这些分支,比笼统地说“错误会冒泡”更能解释哪一个错误最终到达处理位置。

走完一次算法,而不是只查到一个名字 ​

以 Number(input) 为问题入口,目标先缩小为“普通对象怎样取得可用于数值转换的原始值”。阅读时找到 ToPrimitive,再跟进其普通路径 OrdinaryToPrimitive。先记输入和选择的分支,不必一次读完全部关联章节。

在数字提示下,普通路径先取得 valueOf;如果它可调用,就调用并检查返回值。返回对象时,继续尝试 toString;返回原始值时,这一步已完成。取得方法或调用方法时若抛错,则立即传播。这里“没有得到原始值”和“抛出异常”是两种不同分支。

js
const log = []
const input = {
  valueOf() {
    log.push('valueOf 返回对象')
    return {}
  },
  toString() {
    log.push('toString 返回文本')
    return '15'
  }
}
console.log(Number(input)) // 15
console.log(log) // ['valueOf 返回对象', 'toString 返回文本']

可以按这个表对照自己的阅读过程。它是对本例路径的讲解,不是可替代完整规范的转换函数:

到达的位置当前事实下一步
对象转原始值没有自定义 Symbol.toPrimitive 方法进入普通转换,使用数字提示
调用 valueOf返回对象尚未完成,继续尝试 toString
调用 toString返回文本 '15'原始值转换完成
后续数值转换得到的是字符串转成数字 15

再把 valueOf 改成抛错:toString 不会执行。规范中的 ? Call(...) 在此意味着异常向外传播,而不是“失败就试下一种转换”。若改为返回 undefined,它已经是原始值,因此也不会执行 toString;后续数值转换才得到 NaN。结果都没有变成常规数字,但停止的位置不同。

最后才比较另一条路径:对象存在可调用的 Symbol.toPrimitive 时,先调用这个自定义方法;它若返回对象,会直接抛 TypeError,不能退回上述 valueOf/toString 顺序。由输入前提选择分支,再沿每一步的结果追踪,才不会把两条规则拼成一条不存在的兜底链。

eval 为什么让作用域解释变复杂 ​

下面实验仅执行写死的字符串,保存为 .mjs:

js
globalThis.__bookLabel = '全局'
function compare() {
  const __bookLabel = '局部'
  console.log(eval('__bookLabel'))      // 局部:直接 eval
  console.log((0, eval)('__bookLabel')) // 全局:间接 eval
  console.log(new Function('return __bookLabel')()) // 全局
}
try { compare() } finally { delete globalThis.__bookLabel }

直接 eval 在相应条件下能访问调用处的词法环境;间接调用按全局环境处理。new Function 创建的函数也不会像普通闭包那样捕获构造位置的局部变量。它不是把普通函数字面量换了一个较长的写法。

严格模式下的 eval 有额外限制,例如其中声明的绑定不会作为普通局部声明泄漏到外部。非严格旧脚本的行为又不同,因此要先说明模式再讨论结果。

执行来自用户的字符串会把数据提升为代码权限。若需求只是解析 JSON,用 JSON 解析;若需求是选择一种计算规则,用显式函数表。理解 eval 是为了读懂语义和旧代码,不需要因此把它安排进日常数据处理。

直接 eval 既取决于调用形式,也取决于调用的值 ​

eval('...') 要满足直接调用的语义条件;在非严格脚本里,如果名称 eval 被局部绑定遮蔽成普通函数,就不能仅凭拼写认定它能访问调用处环境。严格模式禁止声明名为 eval 的绑定,这种遮蔽实验不能直接放进本书默认的 .mjs 文件。另一方面,单纯给内建 eval 加分组括号,并不会自动变成间接调用。

js
globalThis.__routeScope = '全局'
function inspectEval() {
  const __routeScope = '局部'
  console.log((eval)('__routeScope')) // 局部
  console.log(eval?.('__routeScope')) // 全局:可选调用是间接 eval
}
try { inspectEval() } finally { delete globalThis.__routeScope }

new Function 的全局环境规则也不是压缩器造成的。即使完全没有构建工具,它也不捕获构造位置的局部环境。需要把局部值交给动态函数时可以显式传参;通常更清楚的方案仍是普通函数或函数表,让依赖关系能被直接阅读与检查。

with 与隐式全局变量为什么难维护 ​

with 可以把对象参与名称解析,严格模式中禁止这种语法。下面是非严格普通脚本的历史写法,不要放进本书默认 .mjs 环境:

js
var label = '外层'
var record = { label: '对象' }
with (record) {
  console.log(label) // 对象
}

读到 label 时,只看代码文本就更难判断绑定来源;对象属性变化也会改变名称含义。改写为 record.label 后,依赖变得明确。

非严格模式里给未声明名称赋值,也可能创建全局对象属性;严格模式会抛 ReferenceError。维护旧脚本时应先补声明并验证行为,而非简单加上严格模式声明后期待所有代码自动正常。

两个不常见的 Symbol 协议 ​

数组子类的旧式复制方法可以通过 Symbol.species 选择结果容器的构造器。它改变的是某些方法创建结果的方式,而不是自动改变每个元素的类型:

js
class ReadingList extends Array {
  static get [Symbol.species]() { return Array }
}
const source = new ReadingList(1, 2, 3)
const doubled = source.map(value => value * 2)
console.log(doubled instanceof ReadingList) // false
console.log(Array.isArray(doubled))         // true

map、filter、slice 等相应方法会考虑 species;toReversed、toSorted、toSpliced、with 这组新复制方法直接创建普通数组,不沿用同样的子类构造行为。这正是“返回新数组”仍不足以说明全部边界的例子。自定义 species 还可能执行用户代码,使用继承扩展集合时要额外考虑这种可观察行为。

Symbol.unscopables 则与上一节的历史 with 有关:对象可以指定哪些属性不参与 with 的标识符查找。下面仍是非严格普通脚本实验:

js
var chapterName = '外层章节'
var book = {
  chapterName: '内部字段',
  [Symbol.unscopables]: { chapterName: true }
}
with (book) {
  console.log(chapterName) // 外层章节
}
console.log(book.chapterName) // 内部字段

它并没有删除属性,也不影响 book.chapterName 的普通读取。这个协议帮助平台在给原型新增方法时维持一些历史脚本行为;新代码不需要为使用它而重新引入 with。

旧写法如何放回今天的知识体系 ​

旧代码形式要理解的机制阅读本书的位置
var 与立即调用函数函数作用域、闭包、隔离变量第二章
构造函数、手动挂 prototype对象创建、原型委托、new第四章
callback 串联回调时机、错误通道、控制反转第五章
require / module.exportsCommonJS 与模块求值第七章
全局脚本中的 this/window全局环境与宿主本章第一篇

认识旧写法不意味着把所有兼容旧平台的方法都混入新手主线。现代写法也不会自动消除共享引用、错误传播和生命周期问题。迁移时先写出必须保留的行为,再改变表达方式。

练习 ​

  1. 为什么对象的 Symbol.toPrimitive 抛错后,不能继续尝试把对象当普通字符串拼接?
  2. 将 new Function 放在函数里创建,能否捕获该函数的局部变量?如何用普通闭包完成同样需求?
  3. 一份旧笔记说“执行上下文只有全局、函数和 eval”,为什么需要补充模块与现代规范语境?
  4. 为什么 (panel.read)() 与 (0, panel.read)() 的接收者不同?若 finally 自己抛错,原 try 中的异常是否仍是最终捕获的错误?
  5. 写三个互不共享状态的输入对象,分别让 valueOf 返回对象、返回 undefined、抛出指定 Error;toString 都记录一次调用并返回 '15'。逐个验证 Number 的结果、toString 是否调用和异常身份。再给第一个输入添加返回对象的 Symbol.toPrimitive,解释为什么结果改变。不要在被测转换函数外额外加入“失败就字符串化”的兜底。

查看本篇答案

用心记录每一步成长