Don't break the Web:以 SmooshGate 以及 `<keygen>` 为例

Don't break the Web:以 SmooshGate 以及 `<keygen>` 为例

最近 YDKJS(You Don't Know JS 的缩写,中文译为:你不知道的 JS)有了第二版。名叫 YDKJSY,Y 是 Yet 的意思(中文版可能译作:你还是不知道的 JS)。第二版还没有全部完成,但在 GitHub 上已经公开了最前面的一些章节。

强先读了一下第一章,在读与 JS 相关的历史,其中提到一段让我很感兴趣的议题:

As such, sometimes the JS engines will refuse to conform to a specification-dictated change because it would break that web content.

In these cases, often TC39 will backtrack and simply choose to conform the specification to the reality of the web. For example, TC39 planned to add a contains(..) method for Arrays, but it was found that this name conflicted with old JS frameworks still in use on some sites, so they changed the name to a non-conflicting includes(..). The same happened with a comedic/tragic JS community crisis dubbed “smooshgate”, where the planned flatten(..) method was eventually renamed flat(..).

大意是在说有时候 JS 的标准必须与现实妥协。例如说原本 Array 要加上一个叫做 contains 的方法,但是因为有问题所以改为 includesflatten 也改名为 flat

还有一个上面特别标记的词 [smooshgate],用这个但关键词去找才发现是去年三月左右发生的事件,至于发生了什么事件,底下会详述,跟上面提到的 flatten 有关。看到这件事的时候我第一个反应是:“咦,我怎么什么都不知道?”,查了一下繁体中文资料,大概也只有这篇提到:SmooshGate,以及[筆記] 3 種 JavaScript 物件屬性的特性这篇有擦到边而已。

再仔细研究一下事情的来龙去脉之后,觉得是个蛮有趣的议题,因此写了这篇文章跟大家分享。

SmooshGate 事件

有关这个事件以及这篇文章的灵感,大多数来自:#SmooshGate FAQ 这篇文章,里面其实解释得很好,建议大家可以去看这篇。

但懒得看也没关系,底下我简单讲一下事情的来龙去脉。

有一个组织叫做 TC39,全名为 Technical Committee 39,第 39 号技术委员会,负责与 ECMAScript 规范相关的事项,例如说决定哪些提案可以过关之类的,而最后那些提案就会被纳入新的 ECMAScript 标准之中。

提案一共分成五个 stage,从 stage0 到 stage4,详情我就不多说明了,可以参考 Championing a proposal at TC39 或是 The TC39 Process

TC39 之前有一个提案是 Array.prototype.{flatten,flatMap}(flatten 现在已经改为 flat)。

这边先帮不清楚什么是 flatten 的读者简单介绍一下它的功用,简单来说就是把巢状的东西摊平。

例如说下面的示例:

let arr = [1, 2, [3], [4], [5, 6, 7]];
console.log(arr.flatten()); // [1, 2, 3, 4, 5, 6, 7]

原本巢状的阵列会被摊平,这就是 flatten 的意思,跟 lodash 里面的 flatten 是差不多的。

详细的使用方法可以参考 MDN,就只是多了一个参数 depth 可以让你指定展开的深度。

flatMap 就是先 map 之后再 flat,熟悉 RxJS 的朋友们应该会感到满亲切的(在 RxJS 里面又称作 mergeMap,而且 mergeMap 比较常用,有兴趣的朋友也可以参考这篇:concatAll and concatMap rather than flatten and flatMap)。

好,这个提案看似很不错,但到底会有什么问题呢?

问题就出在一个前端新鲜人可能没听过的工具:MooTools,而我也只有听过而已,完全没用过。想要快速知道它可以干嘛的,请看这篇十年前的比较文:jQuery vs MooTools

在 MooTools 里面,他们定义了自己的 flatten 方法,在代码里面做了类似下面的事:

Array.prototype.flatten = /* ... */;

这听起来没什么问题,因为就算 flatten 正式列入标准并并且变为原生的方法,也只是把它覆盖掉而已,没有什么事儿。

但麻烦的事情是,MooTools 还有一段代码把 Array 的方法都复制到 Elements(MooTools 的自定义 API)上面去:

for (var key in Array.prototype) {
  Elements.prototype[key] = Array.prototype[key];
}

for...in 这个语法会遍历所有可枚举的(enumerable)属性,而原生的方法并不包含在内。

例如说在 Chrome devtool 的 console 中执行下面这段代码:

for (var key in Array.prototype) {
  console.log(key);
}

会发现什么都没有打印出来。但如果你加上几个自定义属性之后:

Array.prototype.foo = 123;
Array.prototype.sort = 456;
Array.prototype.you_can_see_me = 789;
for (var key in Array.prototype) {
  console.log(key); // foo, you_can_see_me
}

会发现只有自定义的属性会是可枚举的,而原生的方法你就算覆写,也还是不会变成可枚举的。

那问题是什么呢?问题就出在当 flatten 还没正式变成 Array 方法时,它就只是一个 MooTools 自定义的属性,是可枚举的,所以会被复制到 Elements 去。但是当 flatten 纳入标准并且被浏览器正式支援以后,flatten 就不是可枚举的了。

意思就是,Elements.prototype.flatten 就会变成 undefined,所有使用到这个方法的代码都会挂掉。

此时天真的你可能会想说:「那就把 flatten 变成可枚举的吧!」,但这样搞不好会产生更多问题,因为一堆旧的 for…in 就会突然多出一个 flatten 的属性,很有可能会造成其他的 bug。

当初发现这个 bug 的讨论情况可以看这里:Implementing array.prototype.flatten broke MooTools’ version of it.

确认有了这个问题以后,大家就开始讨论要把 flatten 换成什么词,有人在 Issues 里面提议说:rename flatten to smoosh,引起了广大讨论,也就是 #SmooshGate 事件的起源。除了讨论改名以外,也有人认为干脆就让那些网站坏掉好了。

smoosh 这个字其实跟 flatten 或是其他人提议的 squash 差不多,都有把东西弄平的意思在,不过这个字实在是非常少见,听到这事件以前我也完全没听过这个单字。不过这个提议其实从来没有正式被 TC39 讨论过就是了。

TC39 在 2018 年 5 月的会议上,正式把 flatten 改成 flat,结束了这个事件。

这个提案的时间轴大概是这样:

  1. 2017 年 7 月:stage 0
  2. 2017 年 7 月:stage 1
  3. 2017 年 9 月:stage 2
  4. 2017 年 11 月:stage 3
  5. 2018 年 3 月:发现 flatten 会让使用 MooTools 的网站坏掉
  6. 2018 年 3 月:有人提议改名为 smoosh
  7. 2018 年 5 月:flatten 改名为 flat
  8. 2019 年 1 月:stage 4

我因为好奇去找了 V8 的 commit 来看,V8 是在 2018 年 3 月的时候实现这个功能的:[esnext] Implement Array.prototype.{flatten,flatMap},其中我觉得最值得大家学习的其实是测试部分:

const elements = new Set([
  -Infinity,
  -1,
  -0,
  +0,
  +1,
  Infinity,
  null,
  undefined,
  true,
  false,
  '',
  'foo',
  /./,
  [],
  {},
  Object.create(null),
  new Proxy({}, {}),
  Symbol(),
  (x) => x ** 2,
  String,
]);

for (const value of elements) {
  assertEquals(
    [value].flatMap((element) => [element, element]),
    [value, value],
  );
}

直接丢了各种奇形怪状的东西进去测。

flatten 改名为 flat 的隔天,V8 也立刻做出修正:[esnext] Rename Array#flatten to flat

简单总结一下,总之 #SmooshGate 事件就是:

  1. 有人提议新的方法:Array.prototype.flatten
  2. 发现会让 MooTools 坏掉,因此要改名
  3. 有人提议改名 smoosh,也有人觉得不该改名,引起一番讨论
  4. TC39 决议改成 flat,事情落幕

其中的第二点可能有些人会很疑惑,想说 MooTools 都是这么古早的东西了,为什么不直接让它坏掉就好,反正都是一些老旧的网站了。

这就要谈论到制定 Web 相关标准时的原则了:Don’t break the web。

Don’t break the Web

这个网站:Space Jam 过了 22 年,依旧可以顺利执行,就是因为在制定网页相关新标准时都会注意到「Don’t break the Web」这个大原则。

仔细想想,好像会发现 Web 的领域没有什么 breaking change,你以前可以用的 JS 语法现在还是可以用,只是多了一些新的东西,而不是把旧的东西改掉或者是拿掉。

因为一旦出现 breaking change,就可能会有网站遭殃,像是出现 bug 甚至是整个坏掉。其实有很多网站好几年都没有在维护了,但我们也不应该让它就这样坏掉。如果今天制定新标准时有了 breaking change,最后吃亏的还是使用者,使用者只会知道网站坏了,却不知道是为什么坏掉。

所以在 SmooshGate 事件的选择上,比起「flatten 就是最符合语义,让那些使用 MooTools 的老旧网站坏掉有什么关系!」,TC39 最终选择了「把 flatten 改一下名字就好,虽然不是最理想的命名,但我们不能让那些网页坏掉」。

不过话虽如此,这不代表糟糕的设计一旦出现以后,就完全没有办法被移除。

事实上,有些东西就悄悄地被移除掉了,但因为这些东西太过冷门所以你我可能都没注意到。

WHATWG 的 FAQ 有写到:

That said, we do sometimes remove things from the platform! This is usually a very tricky effort, involving the coordination among multiple implementations and extensive telemetry to quantify how many web pages would have their behavior changed. But when the feature is sufficiently insecure, harmful to users, or is used very rarely, this can be done. And once implementers have agreed to remove the feature from their browsers, we can work together to remove it from the standard.

下面有提到两个示例:<applet><keygen>。也是因为好奇,所以去找了一些相关资料来看。

被淘汰的 HTML 标签

有听过 <keygen> 这个标签的请举手一下?举手的人麻烦大家帮他们鼓鼓掌,你很厉害,封你为冷门 HTML 标签之王。

我就算看了 MDN 上面的范例,也没有很清楚这个标签在干嘛。只知道这是一个可以用在表单里的标签,人如其名,是用来产生与凭证相关的 key 用的。

从 MDN 给的资料 Non-conforming features 里面,我们可以进一步找到其它也被淘汰的标签,例如说:

  1. applet
  2. acronym
  3. bgsound
  4. dir
  5. isindex
  6. keygen
  7. nextid

不过被标示为 obsolete(已废弃)不代表就没有作用,应该只是说明你不该再使用这些标签,因为我猜根据 don't break the web 的原则,里面有些标签还是可以正常运作,例如说小时候很爱用的跑马灯 marquee 也在 Non-conforming features 里面。

在另外一份 DOM 相关的标准当中,有说明了该如何处理 HTML 的标签,我猜这些才是真的被淘汰而且没作用的标签:

If name is applet, bgsound, blink, isindex, keygen, multicol, nextid, or spacer, then return HTMLUnknownElement.

如果你拿这些标签到 Chrome 上面去试,例如说这样:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
  </head>
  <body>
    <bgsound>123</bgsound>
    <isindex>123</isindex>
    <multicol>123</multicol>
    <foo>123</foo>
  </body>
</html>

就会发现表现起来跟 <span> 差不多,猜测 Chrome 应该会把这些不认识的标签当作 span 来看待。

再来因为好奇,所以也去找了一下 chromium 里相关的代码,我以前都是直接在 GitHub 上面去搜寻代码的内容,但因为这次要搜的关键字重复性太高,因此改成搜 commit message。这个时候就完全突显 commit message 的重要性了,发现 chromium 的 commit message 写得蛮好的。

例如说这个 commit:Remove support for the obsolete tag

This patch removes all special-casing for the <isindex> tag; it
now behaves exactly like <foo> in all respects. This additionally
means that we can remove the special-casing for forms containing
<input name="isindex"> as their first element.

The various tests for <isindex> have been deleted, with the
exception of the imported HTML5Lib tests. It's not clear that
we should send them patches to remove the <isindex> tests, at
least not while the element is (an obsolete) part of HTML5, and
supported by other vendors.

I've just landed failing test results here. That seems like
the right thing to do.

"Intent to Remove" discussion:
https://groups.google.com/a/chromium.org/d/msg/blink-dev/14q_I06gwg8/0a3JI0kjbC0J

有附上当初的讨论,资讯给的很详细。而代码的改动除了测试的部分以外,就是把有关这个标签的地方都删掉,当作是一个不认识的标签,所以 message 才会说:「it now behaves exactly like <foo> in all respects.」

再来我们看另外一个 commit:Remove support for the keygen tag

This removes support for <keygen> by updating it
to be an HTMLUnknownElement. As a result, it's
no longer a form-associated element and no
longer has IDL-assigned properties.

The <keygen> tag is still left in the parser,
similar to <applet>, so that it maintains the
document parse behaviours (such as self-closing),
but is otherwise a neutered element.

Tests that were relying on <keygen> having its
own browser-created shadow root (for its custom
select element) have been updated to use
progress bars, while other tests (such as
<keygen>-related crash tests) have been
fully removed.

As Blink no longer treats this tag as special,
all the related IPC infrastructure is removed,
including preferences and enterprise flags,
and all localized strings, as they're all now
unreachable.

This concludes the "Intent to Remove" thread
for <keygen> at
https://groups.google.com/a/chromium.org/d/msg/blink-dev/z_qEpmzzKh8/BH-lkwdgBAAJ

因为 <keygen> 这个标签原本的处理就比较复杂,比起刚刚的 <isindex>,改动的档案多了很多,看起来是把相关的东西全部都拿掉了。

最后来看这一个:bgsound must use the HTMLUnknownElement interface

As specified here:
https://html.spec.whatwg.org/#bgsound

This causes one less fail on:
http://w3c-test.org/html/semantics/interfaces.html

里面给的测试链接:Test of interfaces 满有趣的,会去测试一大堆元素的 interface(接口)是不是正确的,在 interfaces.js 里面可以看到它测试的列表:

var elements = [
  ['a', 'Anchor'],
  ['abbr', ''],
  ['acronym', ''],
  ['address', ''],
  ['applet', 'Unknown'],
  ['area', 'Area'],
  ['article', ''],
  ['aside', ''],
  ['audio', 'Audio'],
  ['b', ''],
  ['base', 'Base'],
  ['basefont', ''],
  ['bdi', ''],
  ['bdo', ''],
  ['bgsound', 'Unknown'],
  ['big', ''],
  ['blink', 'Unknown'],
  ['blockquote', 'Quote'],
  ['body', 'Body'],
  ['br', 'BR'],
  ['button', 'Button'],
  ['canvas', 'Canvas'],
  ['caption', 'TableCaption'],
  ['center', ''],
  ['cite', ''],
  ['code', ''],
  ['col', 'TableCol'],
  ['colgroup', 'TableCol'],
  ['command', 'Unknown'],
  ['data', 'Data'],
  ['datalist', 'DataList'],
  ['dd', ''],
  ['del', 'Mod'],
  ['details', 'Details'],
  ['dfn', ''],
  ['dialog', 'Dialog'],
  ['dir', 'Directory'],
  ['directory', 'Unknown'],
  ['div', 'Div'],
  ['dl', 'DList'],
  ['dt', ''],
  ['em', ''],
  ['embed', 'Embed'],
  ['fieldset', 'FieldSet'],
  ['figcaption', ''],
  ['figure', ''],
  ['font', 'Font'],
  ['foo-BAR', 'Unknown'], // not a valid custom element name
  ['foo-bar', ''], // valid custom element name
  ['foo', 'Unknown'],
  ['footer', ''],
  ['form', 'Form'],
  ['frame', 'Frame'],
  ['frameset', 'FrameSet'],
  ['h1', 'Heading'],
  ['h2', 'Heading'],
  ['h3', 'Heading'],
  ['h4', 'Heading'],
  ['h5', 'Heading'],
  ['h6', 'Heading'],
  ['head', 'Head'],
  ['header', ''],
  ['hgroup', ''],
  ['hr', 'HR'],
  ['html', 'Html'],
  ['i', ''],
  ['iframe', 'IFrame'],
  ['image', 'Unknown'],
  ['img', 'Image'],
  ['input', 'Input'],
  ['ins', 'Mod'],
  ['isindex', 'Unknown'],
  ['kbd', ''],
  ['keygen', 'Unknown'],
  ['label', 'Label'],
  ['legend', 'Legend'],
  ['li', 'LI'],
  ['link', 'Link'],
  ['listing', 'Pre'],
  ['main', ''],
  ['map', 'Map'],
  ['mark', ''],
  ['marquee', 'Marquee'],
  ['menu', 'Menu'],
  ['meta', 'Meta'],
  ['meter', 'Meter'],
  ['mod', 'Unknown'],
  ['multicol', 'Unknown'],
  ['nav', ''],
  ['nextid', 'Unknown'],
  ['nobr', ''],
  ['noembed', ''],
  ['noframes', ''],
  ['noscript', ''],
  ['object', 'Object'],
  ['ol', 'OList'],
  ['optgroup', 'OptGroup'],
  ['option', 'Option'],
  ['output', 'Output'],
  ['p', 'Paragraph'],
  ['param', 'Param'],
  ['picture', 'Picture'],
  ['plaintext', ''],
  ['pre', 'Pre'],
  ['progress', 'Progress'],
  ['q', 'Quote'],
  ['quasit', 'Unknown'],
  ['rb', ''],
  ['rp', ''],
  ['rt', ''],
  ['rtc', ''],
  ['ruby', ''],
  ['s', ''],
  ['samp', ''],
  ['script', 'Script'],
  ['section', ''],
  ['select', 'Select'],
  ['slot', 'Slot'],
  ['small', ''],
  ['source', 'Source'],
  ['spacer', 'Unknown'],
  ['span', 'Span'],
  ['strike', ''],
  ['strong', ''],
  ['style', 'Style'],
  ['sub', ''],
  ['summary', ''],
  ['sup', ''],
  ['table', 'Table'],
  ['tbody', 'TableSection'],
  ['td', 'TableCell'],
  ['textarea', 'TextArea'],
  ['tfoot', 'TableSection'],
  ['th', 'TableCell'],
  ['thead', 'TableSection'],
  ['time', 'Time'],
  ['title', 'Title'],
  ['tr', 'TableRow'],
  ['track', 'Track'],
  ['tt', ''],
  ['u', ''],
  ['ul', 'UList'],
  ['var', ''],
  ['video', 'Video'],
  ['wbr', ''],
  ['xmp', 'Pre'],
  ['\u00E5-bar', 'Unknown'], // not a valid custom element name
];

像是 applet, bgsound, blink 等等這些元素,就应该回传 HTMLUnknownElement

总结

这一趟旅程一样收获满满,从一个议题持续向外延伸,就能挖到更多有趣的东西。

例如说我们从 SmooshGate 事件,学到了 TC39 的运作流程、flatten 坏掉的原因以及 V8 当初实作 flatten 的 commit 还有学到怎么写测试。也学习到了 don’t break the web 的原则,再从这个原则去看了 HTML 的规范,看到了那些被淘汰的标签,最后去看了在 chromium 里面怎么做处理。

制定规范的人要注重的层面以及要考虑的问题真的很多,因为一旦做下去,就很难再回头了;规范文档也要写得清楚又明白,而且不能有错误。

真心佩服那些制定标准的人。

参考资料

本文作者 huli,转载请注明来源链接:

原文链接:https://blog.huli.tw/2019/11/26/dont-break-web-smooshgate-and-keygen/

本文链接:https://tie.pub/2019/12/dont-break-the-web/