Restrict subclassing support in built-in methods S1
中文标题:限制内置方法中的子类化支持
- 阶段: Stage 1
- 状态: 进行中
- ECMAScript 版本: —
- 同步时间: 2026年8月26日
- English original · 官方仓库
该提案旨在移除 Array、RegExp、Promise 和 TypedArray 的内置方法中通过 @@species 及相关属性查找实现的子类化支持。它识别了四种类型的子类化,主张仅保留最小支持(I 型),并提议对方法和构造函数进行具体更改。
以下 README 来自上游仓库,其中的阶段或状态标注可能滞后;当前信息以提案概览为准。
限制内置方法中的子类化支持
Champions: Shu-yu Guo (Google), Yulia Startsev (Mozilla)
阶段: 1
在 Array、RegExp、Promise 和 TypedArray 的内置方法中通过 @@species 创建子类实例,以及在 RegExp 的内置方法中对接收者进行如 "exec" 等属性查找,被许多实现者和部分委员会成员认为是 TC39 在语言上最大的错误之一。它给语言的实现和心理模型带来了巨大的复杂性,这种代价导致了大量安全漏洞。
此提案旨在移除通过 @@species 及相关机制(如某些 RegExp 内置方法中的 "flags" 和 "exec" 属性查找)进行的子类化支持。
这是一个重大的、要么全部要么全不的向后不兼容更改,旨在移除所有内置的子类化机制。对某些方法或构造函数进行更细粒度的移除将导致更多混乱,并不能解决复杂性和维护负担的成本。
动机
通过 @@species 及相关机制支持内置类的子类化带来了巨大负担:
- 增加的实现复杂性和可维护性
- 性能悬崖
- 安全漏洞
来自 Project Zero 的 Natalie Silvanoich 在2018年给 TC39 做过一个演讲,指出了 @@species 对安全漏洞的影响。以下是由 @@species 导致的安全漏洞列表:
Chrome:
- https://bugs.chromium.org/p/chromium/issues/detail?id=920491
- https://bugs.chromium.org/p/chromium/issues/detail?id=840106
- https://bugs.chromium.org/p/chromium/issues/detail?id=804971
- https://bugs.chromium.org/p/chromium/issues/detail?id=800356
- https://bugs.chromium.org/p/chromium/issues/detail?id=726622
- https://bugs.chromium.org/p/chromium/issues/detail?id=726636
- https://bugs.chromium.org/p/chromium/issues/detail?id=799952 (在发布版中无安全影响)
Firefox
支持内置类的子类化也对新内置类的编写产生了负面影响。当前和未来的规范作者,通常是出于一致性考虑,传播了 @@species 机制。
@@species 也负面影响了其他与 JavaScript 交互的规范,例如 WebAssembly,这些规范并不了解规范的这一角落。
子类化的分类
@domenic 对 JS 中内置类的子类化有一个极好的分类,我在此采纳并稍作修改。JS 目前支持以下所有类型的内置类子类化。
I 型:最小支持
如果能创建内置类的子类,则支持 I 型。例如,如果派生类可以调用 super()。通过 new.target 提供对此的支持。
示例
如果缺少 I 型支持,并且不支持 new.target,则以下情况将为真
成本效益
㊟ 对 I 型子类化存在关键依赖,值得付出实现和语言成本。
I 型被用户库以及 WebIDL 和 DOM 中的 Web 平台所使用。
II 型:在内置方法中创建子类实例
如果内置方法创建子类的新实例,则支持 II 型。例如,如果 Array.prototype.map 或 Array.from 返回 Array 子类的实例。对此的支持被下面的 III 型支持通过 this.constructor[@@species] 包含。
示例
成本效益
㊟ 有益,但有代价。
II 型是许多开发者享有的直觉。它使用户库无需维护所有创建实例的方法的覆盖,就能子类化像 Array 这样的内置类。
然而,这导致了实现的复杂性,因为内置方法具有可覆盖的行为,导致通过 this.constructor 执行任意代码。在实现中,这导致慢路径和不变量激增,使 JIT 代码去优化。未能做到这一点可能并已经在浏览器中导致了严重的安全漏洞。在语言中,this.constructor 在某些内置方法中可能导致任意代码执行,增加了推理的难度。
III 型:在内置方法中可自定义子类实例创建
如果内置方法创建子类选择的实例,则支持 III 型。例如,如果 Array.prototype.map 或 Array.from 通过 SubclassConstructor[@@species] 返回 Array 子类的实例。通过在内置方法中委派给 this.constructor[@@species] 并为 @@species 属性提供自定义值来支持此功能。
II 型和 III 型的主要区别在于用户体验,而非实现。II 型是用户体验的期望,即内置方法在子类实例上调用时,有某种方式查询这些实例的类并创建子类的实例。III 型是子类本身可以编程方式覆盖 II 型行为的附加功能。
示例
成本效益
㊟ 无益,且代价高昂。
III 型为子类提供了表达性,实际上可以 选择退出 II 型支持。如果 NodeList.prototype.map(继承自 Array.prototype.map)实际上希望返回 Array 而不是 NodeList,它可以通过将 NodeList[@@species] 设置为 Array 来退出。在更复杂的情况下,每个创建实例的方法可能有其自身的考虑,而构造函数上的单一 @@species 值是不够的。
支持 @@species 会使路径更加复杂,不变量更加脆弱,从而加剧了 II 型已经产生的成本。在语言中,开发者必须考虑 @@species 这一点(通常没有用处)是有害的。
目前没有已知的引人注目的用例值得付出这种代价。
IV 型:在内置方法中委派给属性查找
如果内置方法查询实例上的属性而不是内部槽,则支持 IV 型。例如,如果 RegExp.prototype[@@match] 调用 this.exec 而不是内置的 RegExp exec。对此的支持是,嗯,通过委派给属性查找提供的。
请注意,RegExp 的 @@match、@@matchAll、@@replace、@@search 和 @@split 符号本身并非严格仅用于子类化支持,因为它们不在 RegExp 方法本身中使用。相反,它们用作 String 的协议,以便可以消费完全自定义的 RegExp 实例。
示例
我希望这个示例能说明,无法零碎地子类化 RegExp 并获得良好的体验。
成本效益
㊟ 有害,且代价高昂。
IV 型是有害的表达性。对于 RegExp,实现很难提供稳健的快速路径,而用户对其有高性能期望。此代价(取决于可覆盖属性的数量)是 II 型和 III 型成本的总和,甚至更多。
这也使得支持它的内置类的语言模型显著更难推理。用户不应零碎地子类化 RegExp,并通过 exec 或像 global 这样的标志属性覆盖一部分行为,并期望有良好的体验。Promise 也是如此。这也使得规范非常难以理解(参见 PromiseCapabilities)。
(由于 RegExp 的符号并非用于子类化,因此在此上下文中它们不视为有害。)
提议的新旧语义
我们提议移除对 II 型、III 型和 IV 型子类化的支持,仅保留 I 型。
Array
原型方法
Array.prototype 上的以下方法将在当前 Realm 中创建并返回一个 Array 奇异对象。它们将不再查询 this.constructor[@@species]。
Array.prototype.concatArray.prototype.filterArray.prototype.flatArray.prototype.flatMapArray.prototype.mapArray.prototype.sliceArray.prototype.splice
这意味着子类在子类实例上调用这些方法时,将始终获得 Array 实例。
此更改前:
此更改后:
构造函数方法
Array 上的以下方法将在当前 Realm 中创建并返回一个 Array 奇异对象。如果 IsConstructor(this) 为真,它们将不再有条件地将 this 值用作构造函数。
Array.fromArray.fromAsyncArray.of
这意味着任何子类在子类构造函数上调用这些方法时,将始终获得 Array 实例。
此更改前:
此更改后:
移除 @@species
Array[@@species] 将被移除。这意味着以下使用 @@species 符号的方式将不再可能:
RegExp
RegExp 子类化机制涉及 @@species 和各个原型方法中对 this 值的动态属性查找。属性查找将移除,转而使用内部槽。@@species 将移除,转而在当前 Realm 中创建 RegExp 对象。
值得注意的是,针对类 RegExp 对象(例如 @@match)的协议不会作为此提案的一部分被移除,因为它们不被 RegExp 实例或构造函数方法本身使用。
原型方法
RegExp.prototype 上的方法在适用情况下将有以下更改。
- 如果
this没有 [[RegExpMatcher]],则抛出 TypeError。 - 创建新的
RegExp实例时,将在当前 Realm 中创建 %RegExp% 实例,而不是查询this.constructor[@@species]。 - 将查询
this.[[OriginalFlags]] 而不是this.flags。 - 将查询
this.[[OriginalFlags]] 而不是this.dotAll。 - 将查询
this.[[OriginalFlags]] 而不是this.global。 - 将查询
this.[[OriginalFlags]] 而不是this.ignoreCase。 - 将查询
this.[[OriginalFlags]] 而不是this.sticky。 - 将使用
this.[[OriginalFlags]] 而不是this.unicode。 - 将使用
this.[[OriginalSource]] 而不是this.source。 - 将使用 RegExpBuiltinExec 而不是
this.exec。
这些更改适用于以下方法。
RegExp.prototype[@@match]RegExp.prototype[@@matchAll]RegExp.prototype[@@replace]RegExp.prototype[@@search]RegExp.prototype[@@split]RegExp.prototype.test
String 原型方法
值得注意的是,String 原型方法不提议修改。
移除 @@species
RegExp[@@species] 将被移除。
示例
此更改前:
此更改后:
Promise
原型方法
Promise.prototype 上的以下方法将在当前 Realm 中创建并返回一个 Promise 对象。它们将不再查询 this.constructor[@@species]。
Promise.prototype.finallyPromise.prototype.then
这意味着子类在子类实例上调用这些方法时,将始终获得 Promise 实例。
此更改前:
此更改后:
构造函数方法
Promise 上的以下方法将在当前 Realm 中创建并返回一个 Promise 对象。它们将忽略 this 值。
Promise.allPromise.allSettledPromise.anyPromise.racePromise.rejectPromise.resolve
这意味着任何子类在子类构造函数上调用这些方法时,将始终获得 Promise 实例。
此更改前:
此更改后:
移除 @@species
Promise[@@species] 将被移除。
TypedArray
构造函数
- TypedArray
(typedArray)构造函数将不再在第16步使用 SpeciesConstructor,并将使用 %ArrayBuffer%。 - 第17步变得不必要,将被移除。
原型方法
TypedArray.prototype 上的以下方法将在当前 Realm 中创建并返回一个 TypedArray 对象。它们将不再查询 this.constructor[@@species]。
- TypedArray
.prototype.filter - TypedArray
.prototype.map - TypedArray
.prototype.slice - TypedArray
.prototype.subarray
这意味着子类在子类实例上调用这些方法时,将始终获得 TypedArray 实例。
此更改前:
此更改后:
构造函数方法
TypedArray 上的以下方法将在当前 Realm 中创建并返回一个 TypedArray 奇异对象。它们将忽略 this 值。
- TypedArray
.from - TypedArray
.of
这意味着任何子类在子类构造函数上调用这些方法时,将始终获得 TypedArray 实例。
移除 @@species
TypedArray[@@species] 将被移除。
ArrayBuffer
原型方法
ArrayBuffer.prototype.slice 将在当前 Realm 中创建并返回一个 %ArrayBuffer% 对象。它将不再查询 this.constructor[@@species]。
移除 @@species
ArrayBuffer[@@species] 将被移除。
SharedArrayBuffer
原型方法
SharedArrayBuffer.prototype.slice 将在当前 Realm 中创建并返回一个 %SharedArrayBuffer% 对象。它将不再查询 this.constructor[@@species]。
移除 @@species
SharedArrayBuffer[@@species] 将被移除。
Map
移除 @@species
Map[@@species] 将被移除。它目前未被使用。
Set
移除 @@species
Set[@@species] 将被移除。它目前未被使用。
元
Symbol.species 将保留为残留符号,如果任何用户代码希望在自己的子类化协议中使用它。
网络兼容性
内置类的子类化是作为 ES6 的一部分添加的。所有主流浏览器多年来都已支持:Chrome 自 51 起,Firefox 自 41 起,Safari 自 10 起。移除 @@species 的兼容性风险非常真实。
存在一项跨厂商的协同努力来评估兼容性风险。当前的努力包括但不限于:
- 使用 Chrome UseCounter 数据以获得子类化机制(即 @@species 和
.constructor)使用情况的保守视图。 - 使用 HTTP Archive 上的查询。
- 使用爬虫从 HTTP Archive 上的查询中更深入地检查 URL。
- 使用带有插桩的浏览器构建来手动检查破坏情况。
非常初步的数字表明,在 Array、RegExp 和 Promise 上修改 .constructor 或 @@species 的情况很多(在 Chrome 中达到所有页面访问的 2%),而在 TypedArray 构造函数中则少得多(在 Chrome 中达到所有页面访问的 0.04%)。对执行此类修改的网站进行手动检查后发现,它们并非真正使用内置类子类化,而是使用了一个过时的 core-js shim,该 shim 无条件地安装了一个 function() { return this; } 作为 @@species getter。
因此,工作假设是大多数真实用途是由于过时的 shim 导致的误报,并且此更改基本上与网络兼容。
按子类化类型的直觉
- 移除 II 型具有最大的兼容性风险
- 移除 III 型可能是兼容的
- 移除 IV 型可能是兼容的
兼容性将被破坏的知名库
Node.js Buffer 和 Buffer polyfill
Buffer 是 Uint8Array 的子类。使用 Uint8Array.prototype.map、Uint8Array.prototype.filter、Uint8Array.prototype.subarray 和 Uint8Array.prototype.slice 将根据提议的语义产生 Uint8Array 而不是 Buffer。请注意,Buffer 覆盖了 slice,但继承了其他三个方法。
退出标准
如果移除 III 型和 IV 型与网络不兼容,此提案将被撤回。
如果移除 II 型与网络不兼容(或者 TC39 重新达成共识以维护开发者直觉),但移除 III 型和 IV 型子类化与网络兼容,则此提案将探索仅支持 II 型的替代方法,以减少实现和安全负担。如果没有好的替代方案,并且实现者认为仅移除 III 型和 IV 型的好处不足以证明更改行为的合理性,此提案将被撤回。