For AI agents: the complete documentation index is available at /tc39-atlas/llms.txt, the full documentation bundle is available at /tc39-atlas/llms-full.txt, and this page is available as Markdown at /tc39-atlas/proposals/stage/1/proposal-rm-builtin-subclassing.md.
  • 简体中文
  • Restrict subclassing support in built-in methods S1

    中文标题:限制内置方法中的子类化支持

    提案概览
    提案速览

    该提案旨在移除 Array、RegExp、Promise 和 TypedArray 的内置方法中通过 @@species 及相关属性查找实现的子类化支持。它识别了四种类型的子类化,主张仅保留最小支持(I 型),并提议对方法和构造函数进行具体更改。

    Note

    以下 README 来自上游仓库,其中的阶段或状态标注可能滞后;当前信息以提案概览为准。

    限制内置方法中的子类化支持

    Champions: Shu-yu Guo (Google), Yulia Startsev (Mozilla)

    阶段: 1

    上次演讲: 笔记, 幻灯片

    ArrayRegExpPromiseTypedArray 的内置方法中通过 @@species 创建子类实例,以及在 RegExp 的内置方法中对接收者进行如 "exec" 等属性查找,被许多实现者和部分委员会成员认为是 TC39 在语言上最大的错误之一。它给语言的实现和心理模型带来了巨大的复杂性,这种代价导致了大量安全漏洞。

    此提案旨在移除通过 @@species 及相关机制(如某些 RegExp 内置方法中的 "flags""exec" 属性查找)进行的子类化支持。

    这是一个重大的、要么全部要么全不的向后不兼容更改,旨在移除所有内置的子类化机制。对某些方法或构造函数进行更细粒度的移除将导致更多混乱,并不能解决复杂性和维护负担的成本。

    动机

    通过 @@species 及相关机制支持内置类的子类化带来了巨大负担:

    • 增加的实现复杂性和可维护性
    • 性能悬崖
    • 安全漏洞

    来自 Project Zero 的 Natalie Silvanoich 在2018年给 TC39 做过一个演讲,指出了 @@species 对安全漏洞的影响。以下是由 @@species 导致的安全漏洞列表:

    Chrome:

    Firefox

    支持内置类的子类化也对新内置类的编写产生了负面影响。当前和未来的规范作者,通常是出于一致性考虑,传播了 @@species 机制。

    @@species 也负面影响了其他与 JavaScript 交互的规范,例如 WebAssembly,这些规范并不了解规范的这一角落。

    子类化的分类

    @domenic 对 JS 中内置类的子类化有一个极好的分类,我在此采纳并稍作修改。JS 目前支持以下所有类型的内置类子类化。

    I 型:最小支持

    如果能创建内置类的子类,则支持 I 型。例如,如果派生类可以调用 super()。通过 new.target 提供对此的支持。

    示例

    class A extends Array {
      constructor(a,b,c) {
        super(a,b,c);
      }
    }
    new A(1,2,3);      // 返回类型: A

    如果缺少 I 型支持,并且不支持 new.target,则以下情况将为真

    class A extends Array {
      constructor(a,b,c) {
        super(a,b,c);
      }
    }
    new A(1,2,3);      // 返回类型: Array

    成本效益

    对 I 型子类化存在关键依赖,值得付出实现和语言成本。

    I 型被用户库以及 WebIDL 和 DOM 中的 Web 平台所使用。

    II 型:在内置方法中创建子类实例

    如果内置方法创建子类的新实例,则支持 II 型。例如,如果 Array.prototype.mapArray.from 返回 Array 子类的实例。对此的支持被下面的 III 型支持通过 this.constructor[@@species] 包含

    示例

    class A extends Array { }
    
    A.from([1,2,3])    // 返回类型: A
     .map(x => x + 1); // 返回类型: A

    成本效益

    有益,但有代价。

    II 型是许多开发者享有的直觉。它使用户库无需维护所有创建实例的方法的覆盖,就能子类化像 Array 这样的内置类。

    然而,这导致了实现的复杂性,因为内置方法具有可覆盖的行为,导致通过 this.constructor 执行任意代码。在实现中,这导致慢路径和不变量激增,使 JIT 代码去优化。未能做到这一点可能并已经在浏览器中导致了严重的安全漏洞。在语言中,this.constructor 在某些内置方法中可能导致任意代码执行,增加了推理的难度。

    III 型:在内置方法中可自定义子类实例创建

    如果内置方法创建子类选择的实例,则支持 III 型。例如,如果 Array.prototype.mapArray.from 通过 SubclassConstructor[@@species] 返回 Array 子类的实例。通过在内置方法中委派给 this.constructor[@@species] 并为 @@species 属性提供自定义值来支持此功能。

    II 型和 III 型的主要区别在于用户体验,而非实现。II 型是用户体验的期望,即内置方法在子类实例上调用时,有某种方式查询这些实例的类并创建子类的实例。III 型是子类本身可以编程方式覆盖 II 型行为的附加功能。

    示例

    class A extends Array {
      static [Symbol.species] = Array;
    }
    
    A.from([1,2,3])    // 返回类型: A
     .map(x => x + 1); // 返回类型: Array

    成本效益

    无益,且代价高昂。

    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 并获得良好的体验。

    function R() { }
    Object.setPrototypeOf(R, RegExp);
    Object.setPrototypeOf(R.prototype, RegExp.prototype);
    R.prototype.exec = function() {
      console.log("overridden");
      return null;
    };
    // 定义一个新的 .global,因为 RegExp#global 在非 RegExp 品牌的 `this` 上会抛出异常
    Object.defineProperty(R.prototype, "global", { value: false });
    console.log("some string".match(new R("foo")))     // 记录 "overridden"

    成本效益

    有害,且代价高昂。

    IV 型是有害的表达性。对于 RegExp,实现很难提供稳健的快速路径,而用户对其有高性能期望。此代价(取决于可覆盖属性的数量)是 II 型和 III 型成本的总和,甚至更多。

    这也使得支持它的内置类的语言模型显著更难推理。用户不应零碎地子类化 RegExp,并通过 exec 或像 global 这样的标志属性覆盖一部分行为,并期望有良好的体验。Promise 也是如此。这也使得规范非常难以理解(参见 PromiseCapabilities)。

    (由于 RegExp 的符号并非用于子类化,因此在此上下文中它们不视为有害。)

    提议的新旧语义

    我们提议移除对 II 型、III 型和 IV 型子类化的支持,仅保留 I 型。

    Array

    原型方法

    Array.prototype 上的以下方法将在当前 Realm 中创建并返回一个 Array 奇异对象。它们将不再查询 this.constructor[@@species]

    • Array.prototype.concat
    • Array.prototype.filter
    • Array.prototype.flat
    • Array.prototype.flatMap
    • Array.prototype.map
    • Array.prototype.slice
    • Array.prototype.splice

    这意味着子类在子类实例上调用这些方法时,将始终获得 Array 实例。

    此更改前:

    class MyArray extends Array { /* ... */ }
    let ma = (new MyArray(42)).map((x) => x);
    console.log(ma instanceof MyArray) // true

    此更改后:

    class MyArray extends Array { /* ... */ }
    let ma = (new MyArray(42)).map((x) => x);
    console.log(ma instanceof MyArray) // false
    // 结果是 Array,而不是 MyArray。

    构造函数方法

    Array 上的以下方法将在当前 Realm 中创建并返回一个 Array 奇异对象。如果 IsConstructor(this) 为真,它们将不再有条件地将 this 值用作构造函数。

    • Array.from
    • Array.fromAsync
    • Array.of

    这意味着任何子类在子类构造函数上调用这些方法时,将始终获得 Array 实例。

    此更改前:

    class MyArray extends Array { /* ... */ }
    let ma = MyArray.from([1,2,3]);
    console.log(ma instanceof MyArray) // true

    此更改后:

    class MyArray extends Array { /* ... */ }
    let ma = MyArray.from([1,2,3]);
    console.log(ma instanceof MyArray) // false
    // 结果是 Array,而不是 MyArray。

    移除 @@species

    Array[@@species] 将被移除。这意味着以下使用 @@species 符号的方式将不再可能:

    class A extends Array {
      static [Symbol.species] = OtherArray;
    }
    
    A.from([1,2,3])     // 返回类型: A
      .map(x => x + 1); // 返回类型: OtherArray

    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] 将被移除。

    示例

    此更改前:

    class R extends RegExp {
      exec() {
        return "overridden";
      }
    }
    console.log("some string".match(new R("foo")))     // "overridden"

    此更改后:

    class R extends RegExp {
      exec() {
        return "overridden";
      }
    }
    console.log("some string".match(new R("foo")))     // null

    Promise

    原型方法

    Promise.prototype 上的以下方法将在当前 Realm 中创建并返回一个 Promise 对象。它们将不再查询 this.constructor[@@species]

    • Promise.prototype.finally
    • Promise.prototype.then

    这意味着子类在子类实例上调用这些方法时,将始终获得 Promise 实例。

    此更改前:

    class MyPromise extends Promise { /* ... */ }
    let mp = (new MyPromise(executor)).then(() => {});
    console.log(mp instanceof MyPromise) // true

    此更改后:

    class MyPromise extends Promise { /* ... */ }
    let mp = (new MyPromise(executor)).then(() => {});
    console.log(mp instanceof MyPromise) // false
    // 结果是 Promise,而不是 MyPromise。

    构造函数方法

    Promise 上的以下方法将在当前 Realm 中创建并返回一个 Promise 对象。它们将忽略 this 值。

    • Promise.all
    • Promise.allSettled
    • Promise.any
    • Promise.race
    • Promise.reject
    • Promise.resolve

    这意味着任何子类在子类构造函数上调用这些方法时,将始终获得 Promise 实例。

    此更改前:

    class MyPromise extends Promise { /* ... */ }
    let mp = MyPromise.resolve(() => {});
    console.log(mp instanceof MyPromise) // true

    此更改后:

    class MyPromise extends Promise { /* ... */ }
    let mp = MyPromise.resolve(() => {});
    console.log(mp instanceof MyPromise) // false
    // 结果是 Promise,而不是 MyPromise。

    移除 @@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 实例。

    此更改前:

    class MyBuffer extends Uint8Array { /* ... */ }
    let mb = (new MyBuffer(42)).filter((x) => true);
    console.log(mb instanceof MyBuffer) // true

    此更改后:

    class MyBuffer extends Uint8Array { /* ... */ }
    let mb = (new MyBuffer(42)).filter((x) => true);
    console.log(mb instanceof MyBuffer) // false
    // 结果是 Uint8Array,而不是 MyBuffer。

    构造函数方法

    TypedArray 上的以下方法将在当前 Realm 中创建并返回一个 TypedArray 奇异对象。它们将忽略 this 值。

    • TypedArray.from
    • TypedArray.of

    这意味着任何子类在子类构造函数上调用这些方法时,将始终获得 TypedArray 实例。

    class MyBuffer extends Uint8Array { /* ... * / }
    let mb = MyBuffer.from([1,2,3]);
    // 结果是 Uint8Array,而不是 MyBuffer。

    移除 @@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 的兼容性风险非常真实。

    存在一项跨厂商的协同努力来评估兼容性风险。当前的努力包括但不限于:

    1. 使用 Chrome UseCounter 数据以获得子类化机制(即 @@species 和 .constructor)使用情况的保守视图。
    2. 使用 HTTP Archive 上的查询。
    3. 使用爬虫从 HTTP Archive 上的查询中更深入地检查 URL。
    4. 使用带有插桩的浏览器构建来手动检查破坏情况。

    非常初步的数字表明,在 ArrayRegExpPromise 上修改 .constructor 或 @@species 的情况很多(在 Chrome 中达到所有页面访问的 2%),而在 TypedArray 构造函数中则少得多(在 Chrome 中达到所有页面访问的 0.04%)。对执行此类修改的网站进行手动检查后发现,它们并非真正使用内置类子类化,而是使用了一个过时的 core-js shim,该 shim 无条件地安装了一个 function() { return this; } 作为 @@species getter。

    因此,工作假设是大多数真实用途是由于过时的 shim 导致的误报,并且此更改基本上与网络兼容。

    按子类化类型的直觉

    • 移除 II 型具有最大的兼容性风险
    • 移除 III 型可能是兼容的
    • 移除 IV 型可能是兼容的

    兼容性将被破坏的知名库

    Node.js BufferBuffer polyfill

    BufferUint8Array 的子类。使用 Uint8Array.prototype.mapUint8Array.prototype.filterUint8Array.prototype.subarrayUint8Array.prototype.slice 将根据提议的语义产生 Uint8Array 而不是 Buffer。请注意,Buffer 覆盖了 slice,但继承了其他三个方法。

    退出标准

    如果移除 III 型和 IV 型与网络不兼容,此提案将被撤回。

    如果移除 II 型与网络不兼容(或者 TC39 重新达成共识以维护开发者直觉),但移除 III 型和 IV 型子类化与网络兼容,则此提案将探索仅支持 II 型的替代方法,以减少实现和安全负担。如果没有好的替代方案,并且实现者认为仅移除 III 型和 IV 型的好处不足以证明更改行为的合理性,此提案将被撤回。