读懂规范与旧代码:抽象操作、eval 和兼容边界
当两篇资料对同一段代码给出不同解释,先找到它们讨论的运行环境和年代,再回到规范。规范是精确说明语言行为的工具,阅读方式与入门教程不同;本篇用几个小问题建立入口。
先找到正在执行的语言操作
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;如果始终得不到原始值,转换会抛出异常。掌握这条因果链,比给每一对类型各背一个结果更能应付新情况。
先求操作数,再执行转换
“从左到右”必须说明针对哪一层操作。下面把操作数求值与原始值转换分别记录:
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 的对象:
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;取值操作才得到函数对象。调用语义还可能利用保留下来的来源决定接收者。
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 的完成方式可以覆盖原来的结果
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;返回原始值时,这一步已完成。取得方法或调用方法时若抛错,则立即传播。这里“没有得到原始值”和“抛出异常”是两种不同分支。
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:
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 加分组括号,并不会自动变成间接调用。
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 环境:
var label = '外层'
var record = { label: '对象' }
with (record) {
console.log(label) // 对象
}读到 label 时,只看代码文本就更难判断绑定来源;对象属性变化也会改变名称含义。改写为 record.label 后,依赖变得明确。
非严格模式里给未声明名称赋值,也可能创建全局对象属性;严格模式会抛 ReferenceError。维护旧脚本时应先补声明并验证行为,而非简单加上严格模式声明后期待所有代码自动正常。
两个不常见的 Symbol 协议
数组子类的旧式复制方法可以通过 Symbol.species 选择结果容器的构造器。它改变的是某些方法创建结果的方式,而不是自动改变每个元素的类型:
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)) // truemap、filter、slice 等相应方法会考虑 species;toReversed、toSorted、toSpliced、with 这组新复制方法直接创建普通数组,不沿用同样的子类构造行为。这正是“返回新数组”仍不足以说明全部边界的例子。自定义 species 还可能执行用户代码,使用继承扩展集合时要额外考虑这种可观察行为。
Symbol.unscopables 则与上一节的历史 with 有关:对象可以指定哪些属性不参与 with 的标识符查找。下面仍是非严格普通脚本实验:
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.exports | CommonJS 与模块求值 | 第七章 |
| 全局脚本中的 this/window | 全局环境与宿主 | 本章第一篇 |
认识旧写法不意味着把所有兼容旧平台的方法都混入新手主线。现代写法也不会自动消除共享引用、错误传播和生命周期问题。迁移时先写出必须保留的行为,再改变表达方式。
练习
- 为什么对象的
Symbol.toPrimitive抛错后,不能继续尝试把对象当普通字符串拼接? - 将
new Function放在函数里创建,能否捕获该函数的局部变量?如何用普通闭包完成同样需求? - 一份旧笔记说“执行上下文只有全局、函数和 eval”,为什么需要补充模块与现代规范语境?
- 为什么 (panel.read)() 与 (0, panel.read)() 的接收者不同?若 finally 自己抛错,原 try 中的异常是否仍是最终捕获的错误?
- 写三个互不共享状态的输入对象,分别让 valueOf 返回对象、返回 undefined、抛出指定 Error;toString 都记录一次调用并返回 '15'。逐个验证 Number 的结果、toString 是否调用和异常身份。再给第一个输入添加返回对象的 Symbol.toPrimitive,解释为什么结果改变。不要在被测转换函数外额外加入“失败就字符串化”的兜底。