First-class protocols S1
中文标题:一等协议
- 阶段: Stage 1
- 状态: 进行中
- ECMAScript 版本: —
- 同步时间: 2026年8月26日
- English original · 官方仓库
该提案在 ECMAScript 中引入一等协议,为基于协议的设计提供语法设施,使用符号以避免命名冲突。它包括 protocol 声明、implements 运算符、Protocol.implement() 和其他命令式 API、子协议、继承以及 Protocol.withStrings() 等便利特性。
以下 README 来自上游仓库,其中的阶段或状态标注可能滞后;当前信息以提案概览为准。
ECMAScript 一等协议提案
从 ES2015 开始,新的 ECMAScript 标准库 API 采用了基于协议的设计,这得益于 Symbol 的引入。Symbol 是具有标识性的 ECMAScript 值,可以用作对象属性键。本提案的目标是为基于协议的设计提供一种便捷的语法设施。
阶段:1
champions:
- Michael Ficarra (@michaelficarra)
- Lea Verou (@leaverou)
- Jordan Harband (@ljharb)
目录
它看起来像什么?
声明协议的语法如下所示:
requires 关键字的替代方案是 abstract。参见 issue #50。
必需成员由 requires 关键字定义。
任何其他成员都是 提供的。
协议可以只有必需成员,或只有提供的成员,或两者都有。
尽管语法上与类元素相似,协议成员的名称实际上是 符号,这确保了唯一性并防止名称冲突。
例如,在此示例中,必需成员不是 "foldr" 属性,而是 Foldable.foldr 符号,
并且提供的两个方法不会作为 "toArray" 或 "length" 属性添加到类中,而是作为 Foldable.toArray 和 Foldable.length 符号。
在对象上实现协议
一旦声明了协议,就可以通过 Protocol.implement() 方法在任何满足协议要求的对象上 实现 它。
目前 requires 隐含的唯一约束是属性存在。有关其他约束类型的讨论,参见 issue #4。
在对象上实现协议等同于将协议的成员复制到该对象。
如果对象已经具有协议将提供的属性,则不会替换该属性。
动机
ECMAScript 中最著名的协议是迭代协议。诸如 Array.from、Map 和 Set 构造函数、解构语法以及 for-of 语法等 API 都围绕该协议构建。但还有许多其他协议。例如,由 Symbol.toStringTag 定义的协议本可以使用协议来表达,如下所示:
Promise.prototype.then 的自动展平行为是一个非常争议的决定。
自动展平和单子版本都有合理的论据支持作为默认行为。
协议通过两种方式消除了这个问题:
- 符号是唯一且无歧义的。没有命名冲突的担忧, 并且明确知道你正在使用哪个函数。
- 协议可以应用于现有的类,因此没有任何东西 阻止具有不同目标的消费者使用他们自己的方法。
最后,协议的最大好处之一是它们消除了 修改内置原型时的恐惧。ECMAScript 的一个美妙方面是 它能够扩展其内置原型。但受限于字符串命名空间, 这在大规模代码库中难以维持,并且在集成第三方时变得不可能。 因为协议基于符号,这不再是一种反模式。
深入语法
查询协议成员
可以使用 implements 运算符来查询协议成员,通过检查对象是否满足协议的要求并包含其提供的成员。
提供显式成员名
默认情况下,提供的和要求的成员名称实际上在协议对象上定义符号,这是协议避免冲突的关键部分。 可以使用 ComputedPropertyName 语法提供将按原样使用的显式成员名:
这使得描述语言中已存在的协议成为可能,这是委员会反馈所必需的。 这包括必需成员为字符串的协议,例如 thenables, 以及必需成员为现有符号的协议,例如 迭代协议:
提供非方法数据属性
如本解释器的其余部分所见,协议可以使用类声明中使用的相同表示法提供方法和访问器。除此之外,协议还可以使用以下表示法提供具有任意值的数据属性:
在上面的示例中,协议 P 提供了一个名为 x 且值为 0 的成员。这与类声明中类似构造的含义不同,在类中它将定义带有字段初始化器的类字段。类字段初始化器是在每次实例化类时计算的表达式,而上面协议中 = 后面的表达式仅在协议评估期间计算一次。
与私有名称的交互
提供仅能由其他协议成员引用的私有名称属性的协议似乎明显有用。但即使不支持私有名称,本提案仍然有价值。目前,协议中的私有名称是一个早期错误。协议中的私有名称可以并且应该作为后续提案来追求。此主题在 #66 中跟踪。
协议组合
子协议
一个必需的成员还可以要求实现一个或多个子协议,这些子协议可以内联指定或通过引用指定。
这可用于指定意在类上使用的协议的静态成员:
实际上,Foldable.from 将 不会 可用。这是一个开放的设计难题,参见 #81 进行讨论。
constructor 和 prototype 是否应 始终 隐式为字符串而不在协议对象上创建符号?参见 issue #84
继承
协议一旦创建即被冻结,无法修改。 相反,可以使用继承从现有协议创建新协议。 语法和语义与类类似:
关于协议组合的确切实现和语义的讨论,参见 issue #23。
命令式 API
除了前面描述的 Protocol.implement() 之外,还有其他几个命令式 API 方法可用。
Protocol() 构造函数
协议也可以通过 Protocol() 构造函数以命令方式构造。
所有选项都是可选的。
协议自省:Protocol.describe()
Protocol.describe(p) 接受一个现有的协议对象,并返回一个可以传递给构造函数以创建新协议的对象字面量。
协议展平:Protocol.union()
Protocol.implement() 是原子的,如果对象不满足协议的要求,将抛出错误。
然而,当在单个对象上实现多个协议时,它们的一些要求可能由其他协议满足。
考虑这个示例:
以下所有调用都将抛出错误:
Protocol.implement(obj, A);Protocol.implement(obj, B);Protocol.implement(obj, C);
无论按什么顺序在 obj 上调用 Protocol.implement() 都无法避免错误,即使 所有 它们的要求都满足于组中的另一个协议。
为了解决这个问题,我们可以通过对所有必需和提供的成员进行深层联合,将多个协议组合成一个协议。
生成的协议满足组中所有协议的 implements 检查,但要求是针对整个集合检查的,而不是针对每个协议单独检查。
协议可以要求和提供相同的成员名。
这不会抛出错误,要求只是被平凡满足了。
这在展平协议时很容易发生,例如在上面的示例中,
Protocol.union(A, B, C) 将产生一个要求其所有提供的成员的协议。
便利特性
在构造函数上指定和实现协议
除了适用于任何对象的 Protocol.implement() 之外,构造函数还支持通过 implements 关键字在其原型上声明式地实现协议:
当在构造函数上实现协议(通过 class C implements P 语法)时,它们被安装到类的 .prototype 对象上,即它们等同于 Protocol.implement(C.prototype, P)。
通过实现 Foldable,类 C 现在获得了 C.prototype[Foldable.toArray] 方法和 C.prototype[Foldable.length] 访问器,它可以像这样选择向外部世界暴露它们:
可以通过向 implements 关键字传递协议列表来实现多个协议。
在这种情况下,协议在传递给 Protocol.implement() 之前被包装在 Protocol.union() 中,以确保只有真正的要求传播到构造函数本身。
自动为提供的成员创建字符串别名:Protocol.withStrings()
虽然基于符号的名称是默认的(并且是理想的,以避免冲突),但许多用例需要与现有的临时协议集成,这些协议以常规字符串命名的属性定义。 默认情况下,实现对象需要定义协议定义的基于符号的名称与其希望暴露的基于字符串的名称之间的映射,这可能很繁琐,尤其是当所需的字符串映射与协议名称相同时。
例如,web 平台支持使自定义元素与表单关联,目前这是通过对 ElementInternals 对象的委托模式实现的。
如果协议存在,人们可以想象浏览器提供这样的协议:
然后像这样使用:
然而,通常实现会希望暴露所有提供的成员,以便与内置元素保持一致。 这涉及 大量 样板代码:
虽然协议 可以 将其所有提供的成员定义为字符串命名的属性,但这将控制权从消费者(对象)转移到生产者(协议),这并不总是可取的。 同一个协议可能以不同的方式使用,要么在内部,主要为了它提供的功能,要么暴露给对象消费者作为 API。
为了允许对象作者逐个案例决定,Protocol 提供了一个便利工厂(暂时命名为 Protocol.withStrings(),直到命名被确定)函数,它接受一个输入协议并产生一个输出协议,该输出协议为所有提供的符号成员包含字符串别名作为访问器。
输出是记忆化的:相同的输入协议 → 相同的输出协议。
使用此函数,上面的示例可以写成:
或等价地:
模式
冲突解决与消歧
虽然协议旨在通过使用符号来避免冲突,但在某些情况下可能会出现冲突,例如:
- 显式的基于字符串的成员名
- 外部符号
- 使用自动字符串别名时
默认情况下,具有冲突成员的协议将抛出错误。
然而,如前所述,实现对象中的属性优先于协议成员。 作为推论,这也为实现对象提供了一种消歧的方法:
我如何试用它?
一个使用 sweet.js 的过时原型可在 https://github.com/disnet/sweet-interfaces 获得。它需要更新以使用最新的语法。运行时组件的 polyfill 可在 https://github.com/michaelficarra/proposal-first-class-protocols-polyfill 获得。
与类似特性的关系
Haskell 类型类
本提案强烈受到 Haskell 类型类的启发。概念模型是相同的,除了在 Haskell 中,类型类实例(本质上是一个隐式记录)由类型检查器自动解析。 对于更类似 Haskell 的调用模式,可以定义如下函数:
类似于 Haskell 中每种类型对每个类型类只能有一个实现(newtypes 用作变通方法),JavaScript 中每个类对每个协议也只能有一个实现。Haskell 程序员通过使用 newtypes 来绕过这一限制。本提案的用户将用每个可能的替代方案扩展他们希望实现的协议,并允许消费者通过他们使用的符号选择实现。
Haskell 类型类仅存在于类型级别而不是项级别,因此它们不能作为一等值传递,对它们的任何抽象都必须通过类型级编程机制完成。本提案中的协议本身是可以作为一等公民传递的值。
Rust 特征
Rust 特征与 Haskell 类型类非常相似。Rust 特征对内置数据结构的实现有限制;本提案没有这样的限制。本提案中的 implements 运算符在手动保护函数方面与 Rust 的特征边界类似。Rust 特征中的默认方法等同于本提案中我们称之为方法的东西。
Java 8+ 接口
自 Java 8 起,Java 接口具有与本提案的许多相同特性。最大的区别是 Java 接口不是即席的,这意味着现有的类不能在定义之后被声明为实现接口。此外,Java 接口与类和其他接口共享成员名命名空间,因此它们可能重叠、遮蔽,或者不兼容,且用户无法消歧。
Ruby 混入
Ruby 混入与本提案类似,因为它们允许向现有类添加功能,但在许多方面有所不同。最大的区别是由于所有内容都存在于一个共享的命名空间中,导致方法名重叠/冲突。另一个 Ruby 混入特有的区别是,它们不检查它们所依赖的方法是否由实现类实现。
ECMAScript mixin(...) 模式
这种混合模式通常最终会创建一个或多个中间原型对象,它们位于类和其超类之间的原型链上。相比之下,本提案通过将提供的协议方法复制到类或其原型中来工作。本提案也完全基于 Symbol 命名的属性构建,但使用现有机制这样做会很繁琐且难以正确完成。对于正确操作的复杂性的示例,请参阅 sweet.js 实现的输出。
以往相关讨论/草案的链接
- [ES Wiki] strawman:syntax_for_efficient_traits
- [ES Wiki] strawman:classes_with_trait_composition
- [es-discuss] Traits - current state of discussion
历史
变更日志
2026年2月24日
- 移除了
static成员 - 添加了子协议
- 编辑了构造函数签名以反映当前思路
- 添加了
Protocol.describe()