你不知道的 Web Components - 过去和未来

这篇文章会重点讲 Web Components 技术规范的发展情况,关注提案设计、变更上的考虑,也会涉及一些 Web Components 之外的技术,为你了解 Web Components 历史、理解规范提供一些参考。关于具体 API 介绍示例代码,官方文档很全,本文不会提太多。

Web Components 发展史

2011 之前

HTML Components  1998年 微软开创的新技术,Web Components 的雏形,从 IE5.5 开始提出并实现到 IE10 中被废弃。它使用声明性模型将事件和 API 附加到宿主元素,把组件解析为一种类似现在 shadowDOM 的 viewlink。 XBL  2001 年 Mozilla 提出的一种基于XML 的语言,类似于 Microsoft 的 HTML Components,W3C 2007 年还通过了 XBL2,不过一直只有 FireFox 支持,最终在 2012 年被废弃。

2011

Alex Russel 首次提出了 Web Components 的概念 Web Components and Model Driven Views by Alex Russell · Fronteers  并首次演示了 demo,这时候整套技术包括三个方面:Scoped CSS、Shadow DOM 和 Web Components。W3C 也在此时开始推进  Web Components 规范。

2012

HTML Template 很快被实现

HTML Template https://html.spec.whatwg.org/multipage/scripting.html#the-template-element  ,作为 wrapper 包裹内容,在页面加载时不使用,在之后运行时实例化。

Shadow DOM V0 标准发布并被实现

作为解决 HTML 和 CSS 的全局性影响的一种方式。讲到这里多说几句,在这之前为了避免 id、class、样式冲突,出现了很多CSS 模块化方案,比如 OOCSS、SMACSS、BEM、以及 CSS-Modules,到 CSS-in-JS 和 CSS-out-of-JS 的争议, styled-components,Preprocessor variables,CSS variables ,包括现在的 Tailwind CSS/Windi CSS,企业都是在解决这类问题。 而 Shadow DOM 则是另一条方向,在原生平台层面实现了不需要其他工具和命名规范、互不影响的 scoped styles

不过这时候的规范有不少看起来就不太好的地方,为后续规范的推广埋下了隐。 比如一个 shdowhost 可以支持多个 shadow roots  https://www.w3.org/TR/2012/WD-shadow-dom-20120522/#shadow-dom-subtrees :

Javascript
let e = document.createElement("div");
let olderShadowRoot = e.createShadowRoot();
let youngerShadowRoot = e.createShadowRoot();

他的重要意义是作为可选择组合的内容,从而形成所谓的分布式节点 (distributed nodes),然而这又需要用 select 属性搭配 CSS 选择器来选择特定子元素内容,又是一种看起来很难受的方式:

HTML, XML
<div id="nameTag">
  <div class="first">Bob</div>
  <div>B. Love</div>
  <div class="email">bob@</div>
</div>


  <div style="background: purple; padding: 1em;">
    <div style="color: red;">
      <content select=".first"></content>
    </div>
    <div style="color: yellow;">
      <content select="div"></content>
    </div>
    <div style="color: blue;">
      <content select=".email"></content>
    </div>
  </div>

这一年,Ember 和 Angular 都计划去支持 Web Components,甚至是基于它去做改造,但最终没有结果。

2013

Google 开始开发 Polymer 作为开发 Web Components 的库。

2014

Mozila 开始对 Web Components 做了基本实现

可以参考 811542 - (webcomponents) meta Implement Web Components,当时Mozila 重点提到以下几点:

HTML Imports 被逐渐放弃

HTML Imports 本意是提供在 html 中直接引用其他 html 的能力,但在这段时间被逐渐放弃。

  • 原因一是实践证明 HTML Imports 并没有什么必要且实现当时的规范很不容易,Mozila  在 Firefox OS 中使用 Web Components 中发现使用现有的模块语法(AMD 或 Common JS)来解析依赖关系树、注册元素、使用普通的 script 标签加载足够好用,HTML imports 只适合简单/声明性的方式,比如这个时候的 Polymer。
  • 原因二是ES6 modules 的发展,大家更愿意朝模块依赖管理这个方向去优化。

2015

WebKit 支持了 shadowDOM

提供了样式独立 style 标签、slots 语法、:host 伪类让 shadow root 内部元素实现样式继承等特性。 当时尽管 CSS Scoping Module Level 1 已经出现,可以做到局部样式隔离,但 WebKit 还是考虑支持ShdowDOM ,它除了样式隔离,还可以避免这套被 querySelectorAll 之类的 节点遍历 API 访问到,从而更好的封装组件内容。

Webkit 开始准备支持 CustmElements

但对 CustmElements 实现有更多的问题需要解决。主要是围绕如何以及何时创建 CustmElements,upgrades 的问题 webcomponents/Why-Upgrades.md at gh-pages · WICG/webcomponents · GitHub

  • 代码中存在异步加载的模块
  • 越来越多渐进增强的前端应用、比如三大框架 渲染的 app,ssr 中静态页面注水的过程都会导致 CustmElements从首次加载到最终完整加载可能会有变化。

比如 html 片段:

HTML, XML
<time class=“updated” datetime=“2015-02-18T18:32:38Z” is=“relative-time”>Feb 18, 2015</time>  

最终渲染后可能是长这样:

HTML, XML
<time title=“Feb 18, 2015, 1:32 PM ESTclass=“updated” datetime=“2015-02-18T18:32:38Z” is=“relative-time”>on Feb 18</time>
  • 大量使用的现代前端框架,存在着虚拟 DOM,意味着框架自身存在类似 upgrade/update 的操作,所以需要提供内置的 upgrade 机制让框架们可以不用额外处理 CustmElements,让他们更好兼容支持。

这里先有一种提案 webcomponents/Optional-Upgrades-Optional-Constructors.md at gh-pages · WICG/webcomponents · GitHub  给出了两种可选方式:

  • Custom constructors; no upgrades:用 Custom constructors 创建,无法实现 upgrades
  • Upgrades; no custom constructors:使用 upgrades,则必须用 [Element.created]() 方法创建

创建和更新拆开之后,Document.prototype.registerElement  不会做 upgrades,而 Document.prototype.upgradeElementsFor 会单独用来处理 upgrades 逻辑。

还有一类提案提出新的构造方法,给 DOM 增加 ElementsRegistry 接口以及相应的生命周期: webcomponents/Parser-Created-Constructors.md at gh-pages · WICG/webcomponents · GitHub

再有一类方式是把 Custom Elements 当做原生 Elements 的拓展 :webcomponents/Type-Extensions.md at gh-pages · WICG/webcomponents · GitHub。这样做之后在可访问性方面会有很多争议:

  • Non-ARIA semantics 没有无障碍访问的语义
  • Focusability:焦点也会继承原生 Elements
  • Keyboard events: 在不使用 shadow root 的情况下通用会继承原生 Elements 等等。

而标识拓展类型的方式是 is 属性,好处是:

  • 渐进增强
  • 浏览器容易实现
  • 解决  之类原生行为无法触发的问题

缺点也比较多:

  • 语义迷惑
  • 无法再自定义名称

总的来说这段时间关于CustomElements 的类似提案还有很多,各种声音也导致 Web Components 规范在这段时间推荐很缓慢。不过积极的一面是,ES6 开始全面普及,Webkit 跟 Mozilla 都开始面向 ES6 支持 CustomElements。同时 CSS Houdini - CSS: Cascading Style Sheets | MDN 研发组也表示加入帮助推进 Web Components 样式和布局的自定义实现。

这时期,已经发布几年的 Shadow DOM V0 规范也陆续收到很多反馈,W3C 针对开发者的实际使用方式(比如 Polymer 中的组件)做了一些调研,发现了不少规范制订上的问题:

  • 依照规范 元素是一个 shadow 插入点,但实践中人们更习惯把他当做外层包裹节点。
  • 通过 select 搭配 CSS 选择器控制子元素的方式不符合用户使用习惯,几乎每人习惯这么用。
  • 同时 select 还只能选择 host 节点的直接子元素,不能选择后代子元素

于是有了这样的改进提案:Proposal for changes to manage Shadow DOM content distribution from Ryosuke Niwa on 2015-04-22 ) 这个提案提出,需要一个新的命名插入点的语法、以及给插入点提供命令方式。这也就推动了后续 slot 插槽的诞生。

另一方面,各大新兴前端框架越来越火,他们也对 Web Components 有各自的看法,比如 React Core Team 的 Sebastian Markbage 就表示不打算在其上构建 React,因为 Web Components 中的命令式与 React 中的声明式。但也表示 Web Components 可以作为React 组件树中的叶子,就像原生 DOM 元素一样,而在 Web Components 中使用 React 意义不大,它违背了 Web Components 尽可能少的问题。

即便从现在的视角看,他们说的也都没有问题。

2016

Shadow DOM v1 spec

2016 年 ShadowDOM 经过之前一批改进提案,也发布了新的版本  Shadow DOM v1 spec** ** 首先提供了新的创建 shdowroot 的方法 Element.attachShadow() ,并支持了 mode 参数: open/closed  让我们可以控制  shdowroot是否可以被外部感知。 其次改掉了之前选择组合节点的蹩脚用法,采用了之前提到的  插槽用来填充原始 DOM 中和子类中的内容,用 slot 属性标明插入点:

HTML, XML
<!-- Top level HTML -->
<my-host>
  <my-child id="c1" slot="slot1"></my-child>
  <my-child id="c2" slot="slot2"></my-child>
  <my-child id="c3"></my-child>
</my-host>
<!-- <my-host>'s shadow tree: -->
<div>
  <slot id="s1" name="slot1"></slot>
  <slot id="s2" name="slot2"></slot>
  <slot id="s3"></slot>
</div>

有了插槽机制,在分发和嵌入 DOM 内容的时候也可以这样使用,所以多个 shadowroot 就不再需要了,这种看起来就很奇怪的创建多个 shadowroot 的调用也不再支持:

HTML, XML
let e = document.createElement("div");
let shadowRoot = e.attachShadow({ mode: "open" });
// let another = e.attachShadow({ mode: 'open' });  // Error.

到此,ShadowDOM 的规范终于也变得比较合理,并被更多的厂商接受。 ShadowDOM  v1 版本在 Chrome 和 Safari 上相继实现,Edge 也开始跟进支持。

Custom Elements V1版本

另一方面,在 2016 年底,WebKit 终于宣布在 Safari Technology Preview 18 上支持 Custom Elements API,让我们可以不依赖任何框架定义自己的 HTML Elements。 除了给出了新的 customElements.define API 配合 Class 的方式构造  CustomElement 并操作 ShadowDOM 以外,还提供了 customElements.whenDefined 方法,由于引擎层会实现提到的 upgrades 机制,这个方式一直沿用至今。

引擎先返回一段 HTMLElement,随后在把他 upgrades 到一个真正的 customElements 实例,这个过程完成之后才会触发 whenDefined。而在这里,我们就可以有选择性的执行组件内部逻辑,做首次加载时的性能优化。 但whenDefined也有使用限制,因为立即返回的 plain HTMLElement,并没有属性,子节点也还没有完成链接,因此在这段时间不能操作,因此又提供了两个 api分别用与处理属性变更、相应子节点创建完成:

  • attributeChangedCallback 原文写的很清晰:Don’t add, remove, mutate, or access any attribute inside a constructor
  • connectedCallback 原文写的很清晰 +1:Don’t insert, remove, mutate, or access a child

有一点比较有意思,从实现角度看 CustomElements 是 CustomElementRegistry 接口返回的,这看起来跟之前提到的给 Document 增加 ElementsRegistry 接口的方式很像,但其实是有很大不同的。那个 ElementsRegistry 是 Document 的 interface:

partial interface Document {
  [SameObject] readonly attribute ElementsRegistry elementsRegistry;
};

而现在的 CustomElementRegistry 是 Window 而不是 Document 的 interface,要知道 HTMLElement 是 Window 的 interface,正因为如此,我们才可以通过 Class extends HTMLElement 的方式构造  CustomElement。 可以看到几经争论,浏览器引擎还是努力实现了至少我觉得用起来比较舒服的 API(相比那些争议提案)。而这也是  Custom Elements V1 版本的规范初步实现。

2017

Custom Elements v1 版本在 Chrome 和 Safari 上相继实现。

2018

FireFox 对 Custom Elements 和 Shadow DOM 的支持进入开发阶段。

2019

曾经实现了 HTML Imports 的 Chrome 也废弃了它,HTML Imports 彻底退出历史舞台。

但对 Web Components 来说,直接引用和分发的方案还是有意义的,新的提案  webcomponents/html-modules-proposal.md at gh-pages · WICG/webcomponents · GitHub 出现。

这里叉开说一下,与 HTML Module 类似的提案还有  CSS Modules 、  JSON Modules,甚至包括更多类型文件比如 WebAssembly module 等 Layering: Enable cyclic dependencies with non-STMR module types by linclark · Pull Request #1311 · tc39/ecma262 · GitHub

HTML Module 结合了ES Module ,能够直接把 HTML 作为 ES Module 引入,而模块内部则是 HTML 自身被解析成的  DocumentFragment,自身内部的 Inline Script 则可通过 import.meta.document 访问当前的 DocumentFragment。

HTML Module 提案让我们不再需要字符串字面量来内嵌模板内容,会节省大量编译、预处理、构建等工作,提高开发效率。不过直到今天,还是在进行中 HTML Modules · Issue #645 · WICG/webcomponents · GitHub

Web Components 的未来

了解了 Web Components 的未来,我们再来看看今后 Web Components还有哪些持续的可能性。

Declarative Shadow Root

这是一个持续中的 提案 declarative-shadow-dom/README.md at master · mfreed7/declarative-shadow-dom · GitHub 前面我们提到,在目前 ShadwDOM V1 的提案中,我们需要通过 host.attachShadow 去添加 shadow root 到 DOM 上:

Javascript
const host = document.getElementById('host');
const shadowRoot = host.attachShadow({mode: 'open'});
shadowRoot.innerHTML = '<h1>Hello Shadow DOM</h1>';

这会存在问题一:到服务端渲染的时候,因为没有 dom,就无法创建 shadowroot 问题二:如果把 shadowroot 附加到已经渲染好的 DOM 元素上,会触发重布局,可能有加载白屏

那么Declarative Shadow Root 目的就是让我们可以声明式的创建 shadowroot,在 HTML 解析阶段让 HTML parser 取检测并创建 shadowroot,而不是通过 api 命令式调用操作 DOM。这样就可以不依赖 JS 去创建完整 包括 shdowroot 的 dom 树。

写法就是一个有着 shadowroot  属性的元素:

这个表现其实也非常像普通的  template 标签,template 标签内的内容会被解析成 document-fragment ,并且他们也不是 template 的子节点。

同时提案中还支持未定义伪类,在未创建好之前不展示,避免白屏

CSS
x-foo:not(:defined) > * {
  display: none;
}

Declarative Shadow DOM 在 Chrome 90 和 Edge 91已经支持,Chorme 83+ 可以用 —enable-blink-features=DeclarativeShadowDOM  开启.

至于 polyfill 的思路,可以利用 MutationObserver 在 dom 变化时触发遍历所有 elements,然后调用他们的父元素的 attachShadow

性能测试显示 Declarative Shadow DOM 会比使用 script 引入快 33%,比  MutationObserver polyfill  快 65%。 不过这个提案也存在一定的 xss 风险,由于一些 SanitizerLibrary 可能会不认识 declarative Shadow Root.

Template instantiation

原生的模板支持,这个 issue 最近几年一直在讨论 https://github.com/whatwg/html/issues/2254 所谓 template instantiation,旨在提供标准的  HTML Template parser,支持赋值、表达式、添加事件绑定,考虑 HTML 语法和 JS API,而不仅仅是现在的 template 标签。

因为目前的规范 HTML Standard - the-template-element 虽然定义了什么是  template 标签,但是对于模板内容替换、按需引用、模板复用等方面并没有提供原生实现。 这导致了两个问题:

  • 一、社区里有非常多的实现方式,比如各类模板引擎、各类框架比如 vue/svelte 也有各自 所谓 html-sytax-based 的 tempalte 语法实现,由此产生各种 template 方言;
  • 二、由于不是浏览器原生实现,多少会会影响渲染性能;如果混用各种模板引擎或带模板语法的框架,也会让应用体积越来越大。

Apple 给出过一种提案:webcomponents/Template-Instantiation.md at gh-pages · WICG/webcomponents · GitHub 这个提案还是基于使用最普遍的 Mustache 语法,没有提供更多复杂的语法。

核心思想是在创建 shadowtree 的时候不再需要手动创建 constructor,并且支持内容替换和更新。通过给 HTMLTemplateElement 增加一个 createInstance 方法, createInstance  执行后会将 HTML Template 的 content tree 返回给一个  TemplateInstance类型的实例。 TemplateInstance 属于 DocumentFragment 的子类,还提供一个 update 方法用于更新。

然后需要对于 capitalize ,mailto 等比如在 Vue template 中需要额外用 filter 实现的功能、表单输入绑定、值绑定、包括变量双向绑定这个提案里也计划都通过原生实现。而这就依赖于 template parts 的实现

template parts

形如下面这样的 template 代码,其中的 f(y)x就是 template parts:

HTML, XML
<template id=“foo”><div class=“foo {{ f(y) }}”>{{ x }} world</div></template>

如果要让开发者能构实现对内容的读写、实现双向绑定的效果,可能就需要提供类似 x.replace/x.replaceHtml  之类的方法。最直接的方式你可能会想到可以基于前面提到的  TemplateInstance 实现。

但规范和提案者们还要考虑的是 template parts 相关的 API 是更加面向框架和库的开发者,而不是应用开发者的。所以 TemplateInstance 这种创建模板实例的 API 不应该引入 template parts 的处理。应该额外有一套明确的机制来描述 tempalte parts 需要如何处理,同时模板语法需要从语义上依然表示模板实例没有变化。

一种思路是定义一套 template type 声明机制。 但考虑到同时用于 shdaow tree 和 全局 dom,如果把 template type  做成 JS API,由于 JS 在他们两之间是隔离的,就需要引入额外的全局 scope 的概念。 所以直接把 template type 注册到 document 上是比较好的选择。通过 document.defineTemplateType 来注册 template type并提供相应的 processCallback``declareCallback`createCallback`回调接口来处理变化过程。

HTML, XML
<template type="my-template-type" id="contactTemplate">
   <section>
     <h1>{{name}}</h1>
     Email: <a href="mailto:{{email}}">{{email}}</a>
  </section>
</template>

像上面这样一段代码会包含几种 TemplatePart 类型: NodeTemplatePart 对应 h1 里面的 {{name}}, AttributeTemplatePart 对应 a 标签href 属性上的 {{email}} NodeTemplatePart 对应 a 标签里面的 {{email}}

创建 template parts 的方法,感兴趣可以看 https://github.com/WICG/webcomponents/blob/gh-pages/proposals/Template-Instantiation.md#43-creating-template-parts

github 依据上面这套提案给 template parts 做了一个初步实现: GitHub - github/template-parts: An implementation of the Template Parts proposal

ImportsMap

当下有不少浏览器依然需要 Polyfill 支持  Web Components 标准,未来随着规范不断演进,各浏览器对规范的实现进度不一,polyfill 显然会长期存在。 在 sdk 之类的工具中我们通常需要手动判断环境并控制资源加载逻辑,而 Import maps 提案GitHub - WICG/import-maps: How to control the behavior of JavaScript imports 则可以让我们更好的控制 polyfill 的加载逻辑,自动切换。

ElementInternals

Shadow DOM 中的任何 、 字段都不会自动关联到包含的 form中,这也导致封装 form 类型的 CustomElement,尤其如果还想 ssr, seo 友好的话会需要很多额外操作。早期有的方案是将隐藏字段添加到 DOM 或用 FormData API来更新值,但实际上这也破坏了 Web Components 的封装隔离性。

而 ElementInternals 的作用就是允许 Web Component 与表单挂钩,从而定义自定义值和有效性。提案方式是添加一个 静态属性 static get formAssociated,之后就可以调用 attachInternals 返回一个 ElementInternals 的实例,对 form 的更新值操作就可以通过 ElementInternals 实例的 setFormValue 进行:

Javascript
class MyInput extends HTMLElement {
  static formAssociated = true;
    
  constructor() {
    super();
    this.internals = this.attachInternals();
    this.setValue('');
  }
  
  connectedCallback() {
      ...    
      this.setValue(123);   
  }
  
  setValue(v) {
    this.internals.setFormValue(v);
  }
  
}

这样就依然把对 form 的操作封装在 CustomElement 里面。 目前只有 Chrome 实现了这个规范,而 ElementInternals  上关于 form 的 inputValidation、formReset、formRestore 还有更多的 API,具体可以参考现在的 polyfill element-internals-polyfill - npm

Out Of Component

前面我写了很多规范的事,而在规范之外,Web Components 也赋予了组件独立性,让组件可以不再受应用本身架构、框架的限制,这也给组件开发带来了更多想象力。 如果每个组件都可以在项目之外研发构建测试,独立发布、共享复用,那么组件将不再是项目和应用的依赖,而是项目的云、项目的服务。而从项目架构角度看,如果应用可以做到完全依赖于三方云组件,那么应用本身就是一种类比无服务的无组件架构,以组件驱动的研发模式也许会带来更多的商业可能性。

如果想要做到云组件、组件即服务,那么可能需要彻底改变共享和研发组件的过程,需要保持独立和可控的托管组件,动态的模块化方案,共享和使用组件的方式。

总之,从 2022 年的时间点往后看,Web Components 能带给我们的还远不止现在这些,从组件库到动态集合、到组件云、独立部署等等,跳出组件之外,我们能想象的还有很多。

如果想了解  Web Components 的现状可以转到  👉  《你不知道的Web Components - 现状》

参考