Web 组件已经出现在开发人员的雷达中相当一段时间了。它们最初由 Alex Russell 在 2011 年 Fronteers 大会 上介绍。这个概念震惊了社区,并成为未来许多演讲和讨论的主题。
2013 年,一个名为 Polymer 的基于 Web 组件的框架由 Google 发布,旨在对这些新 API 进行测试,获取社区反馈并添加一些糖衣和观点。
到目前为止,已经过去了 4 年,Web 组件应该无处不在,但实际上,Chrome 是唯一一个具有某种版本 Web 组件的浏览器。即使使用 polyfill,也很明显,在大多数浏览器都支持 Web 组件之前,社区不会完全接受它们。
为什么花了这么长时间?
简而言之,供应商无法达成一致。
Web 组件是 Google 的一项工作,在发布之前,几乎没有与其他浏览器进行协商。就像生活中的大多数谈判一样,没有参与感的各方缺乏热情,并且往往不会达成一致。
Web 组件是一个雄心勃勃的提案。最初的 API 级别很高,并且实施起来很复杂(尽管有充分的理由),这只会加剧供应商之间的争端和分歧。
Google 推动了这项工作,他们寻求反馈,获得了社区的认可;但事后看来,在其他供应商发布之前,可用性被阻碍了。
Polyfill 意味着理论上 Web 组件可以在尚未实现的浏览器上运行,但它们从未被接受为“适合生产使用”。
除此之外,由于 Edge 的工作(即将完成),Microsoft 无法添加许多新的 DOM API。而 Apple 一直专注于为 Safari 开发替代功能。
自定义元素
在所有 Web 组件技术中,自定义元素争议最小。人们普遍认同,能够定义 UI 组件的外观和行为以及能够跨浏览器和跨框架分发该组件的价值。
“升级”
术语“升级”是指元素从普通的 HTMLElement
转换为具有定义的生命周期和 prototype
的闪亮自定义元素时。今天,当元素被升级时,它们的 createdCallback
被调用。
var proto = Object.create(HTMLElement.prototype);
proto.createdCallback = function() { ... };
document.registerElement('x-foo', { prototype: proto });
到目前为止,来自多个供应商的 五个提案 中,有两个最具希望。
“Dmitry”
一种 createdCallback
模式的演化版本,它与 ES6 类配合良好。createdCallback
概念依然存在,但子类化更传统。
class MyEl extends HTMLElement {
createdCallback() { ... }
}
document.registerElement("my-el", MyEl);
与今天的实现类似,自定义元素最初以 HTMLUnknownElement
的形式存在,然后在未来的某个时间点,其原型被替换(或“交换”)为注册的原型,并且调用 createdCallback
。
这种方法的缺点是它与平台本身的行为不同。元素最初是“未知的”,然后在未来的某个时间点转变为最终形式,这会导致开发者感到困惑。
同步构造函数
由开发者注册的构造函数在解析器创建自定义元素并将其插入树的时刻被调用。
class MyEl extends HTMLElement {
constructor() { ... }
}
document.registerElement("my-el", MyEl);
虽然这看起来很合理,但这意味着如果包含 registerElement
定义的脚本是异步加载的,则初始下载的文档中的任何自定义元素都将无法升级。在进入异步 ES6 模块的世界时,这不利于此。
此外,同步构造函数还存在与 平台问题 相关的 .cloneNode()
。
预计供应商将在 2015 年 7 月的面对面会议上决定一个方向。
is=””
is
属性使开发者能够将自定义元素的行为叠加在标准内置元素之上。
<input type="text" is="my-text-input">
支持论点
- 允许扩展内置元素的未公开为原语的功能(例如,可访问性特征、
<form>
控件、<template>
)。 - 它们提供了一种“渐进增强”元素的方法,使其在没有 JavaScript 的情况下仍然可以正常工作。
反对论点
- 语法令人困惑。
- 它回避了我们在平台中缺少许多关键的可访问性原语 的根本问题。
- 它回避了我们没有办法正确扩展内置元素的根本问题。
- 用例有限;一旦开发者引入 Shadow DOM,他们就会失去所有内置的可访问性功能。
共识
人们普遍认为,is
是 Custom Elements 规范上的一个“疣”。Google 已经实现了 is
,并将其视为在公开更低级的原语之前的权宜之计。目前,Mozilla 和 Apple 更愿意尽快发布 Custom Elements V1,并在 V2 中正确解决这个问题,而不会用“疣”污染平台。
HTML 作为自定义元素 是 Domenic Denicola 的一个项目,它尝试使用自定义元素重建内置的 HTML 元素,以试图发现平台中缺少的 DOM 原语。
Shadow DOM
Shadow DOM 迄今为止是供应商之间争议最大的功能。严重到不得不将功能拆分为“V1”和“V2”议程,以帮助更快地达成协议。
分发
分发是指将 Shadow 主机子元素在视觉上“投影”到主机 Shadow DOM 中的插槽的过程。这是使您的组件能够使用用户在其内部嵌套的内容的功能。
当前 API
当前 API 完全是声明式的。在 Shadow DOM 中,您可以使用特殊的 <content>
元素来定义您想要将主机子元素视觉插入的位置。
<content select="header"></content>
Apple 和 Microsoft 都反对这种方法,因为他们担心复杂性和性能问题。
新的命令式 API
即使在 面对面会议 上,也无法就声明式 API 达成一致,因此所有供应商都同意寻求命令式解决方案。
所有四个供应商(Microsoft、Google、Apple 和 Mozilla)都承担了在 2015 年 7 月截止日期之前指定此新 API 的任务。到目前为止,已经 提出了三个建议。其中最简单的一个看起来像这样
var shadow = host.createShadowRoot({
distribute: function(nodes) {
var slot = shadow.querySelector('content');
for (var i = 0; i < nodes.length; i++) {
slot.add(nodes[i]);
}
}
});
shadow.innerHTML = '<content></content>';
// Call initially ...
shadow.distribute();
// then hook up to MutationObserver
主要障碍是:时机。如果主机节点的子节点发生变化,并且我们在 MutationObserver
回调触发时重新分发,那么请求布局属性将返回错误的结果。
myHost.appendChild(someElement);
someElement.offsetTop; //=> old value
// distribute on mutation observer callback (async)
someElement.offsetTop; //=> new value
调用 offsetTop 将在分发之前执行同步布局!
这似乎并非世界末日,但脚本和浏览器内部机制通常依赖于 offsetTop
值的正确性来执行许多不同的操作,例如:将元素滚动到视图中。
如果这些问题无法解决,我们可能会看到回到声明式 API 的讨论中。这将采用当前 <content select>
样式的形式,或者采用新提出的 “命名插槽” API(来自 Apple)。
新的声明式 API - “命名插槽”
“命名插槽”提案是当前“内容选择”API 的一个更简单的变体,组件用户必须明确地用他们希望将其分发的插槽标记其内容。
<x-page> 的 Shadow 根
<slot name="header"></slot>
<slot></slot>
<slot name="footer"></slot>
<div>some shadow content</div>
<x-page> 的使用
<x-page>
<header slot="header">header</header>
<footer slot="footer">footer</footer>
<h1>my page title</h1>
<p>my page content<p>
</x-page>
组合/渲染的树(用户看到的内容)
<x-page>
<header slot="header">header</header>
<h1>my page title</h1>
<p>my page content<p>
<footer slot="footer">footer</footer>
<div>some shadow content</div>
</x-page>
浏览器已经查看了 Shadow 主机的直接子元素(myXPage.children
),并查看了其中是否有任何子元素具有与主机 shadowRoot
中的 <slot> 元素名称匹配的 slot 属性。
当找到匹配项时,该节点将视觉上“分发”到相应的 <slot> 元素的位置。在此匹配过程结束时,任何未分发的子元素都将分发到一个默认的(未命名的)<slot> 元素(如果存在)。
支持
- 分发更明确,更易于理解,更少“魔法”。
- 分发对于引擎来说更简单。
反对
- 没有解释内置元素(如 <select>)是如何工作的。
- 用 slot 属性装饰内容对用户来说是额外的负担。
- 表达能力较弱。
“closed” 与 “open”
当 shadowRoot
为“closed” 时,无法通过 myHost.shadowRoot
访问它。这使组件作者可以一定程度地确保用户不会窥视实现细节,类似于您可以使用闭包来保持私有性。
Apple 强烈认为这是一项重要功能,他们会对此进行阻碍。他们认为,实现细节永远不应该暴露给外部世界,并且在 “隔离的”自定义元素 成为现实时,“closed” 模式将成为一项必需的功能。
谷歌另一方面认为“封闭”的影子根将阻止一些可访问性和组件工具的使用案例。他们认为,意外地遇到shadowRoot
是不可能的,如果人们想这样做,他们很可能有一个充分的理由。JS/DOM是开放的,让我们保持这种状态。
在四月的会议上,很明显要继续前进,“模式”需要成为一个功能,但供应商正在努力就这是否应该默认为“打开”或“关闭”达成一致。因此,所有人都同意,对于V1,“模式”将是一个必需的参数,因此不需要指定默认值。
element.createShadowRoot({ mode: 'open' });
element.createShadowRoot({ mode: 'closed' });
穿透组合器
“穿透组合器”是一种特殊的CSS“组合器”,可以从外部世界定位影子根内部的元素。例如/deep/后来改名为>>>
.foo >>> div { color: red }
当Web组件首次被指定时,人们认为这些是必需的,但在查看它们的使用方式后,它似乎只带来了问题,使打破使Web组件如此有吸引力的样式边界变得太容易。
性能
如果引擎不必考虑任何外部选择器或状态,则在严格范围的Shadow DOM内部,样式计算可以非常快。穿透组合器本身的存在禁止了这种优化。
替代方案
放弃穿透组合器并不意味着用户将永远无法从外部自定义组件的外观。
CSS自定义属性(变量)
在Firefox OS中,我们使用CSS自定义属性公开可以从外部定义(或覆盖)的特定样式属性。
外部(用户)
x-foo { --x-foo-border-radius: 10px; }
内部(作者)
.internal-part { border-radius: var(--x-foo-border-radius, 0); }
自定义伪元素
我们还看到一些供应商表达了重新引入定义自定义伪选择器的能力的兴趣,这将公开给定的内部部分以供样式化(类似于我们今天对<input type=”range”>的部分进行样式化)。
<span class="hljs-tag">x-foo</span><span class="hljs-pseudo">::my-internal-part</span> <span class="hljs-rules">{ <span class="hljs-rule"><span class="hljs-attribute">... }</span></span></span>
这很可能将在Shadow DOM V2规范中考虑。
Mixins – @extend
有一个建议的规范将SASS的@extend行为引入CSS。这对组件作者来说将是一个有用的工具,允许用户提供一个属性“包”以应用于特定内部部分。
外部(用户)
<span class="hljs-class">.x-foo-part</span> <span class="hljs-rules">{
<span class="hljs-rule"><span class="hljs-attribute">background-color</span>:<span class="hljs-value"> red</span></span>;
<span class="hljs-rule"><span class="hljs-attribute">border-radius</span>:<span class="hljs-value"> <span class="hljs-number">4px</span></span></span>;
<span class="hljs-rule">}</span></span>
内部(作者)
<span class="hljs-class">.internal-part</span> <span class="hljs-rules">{
<span class="hljs-rule">@<span class="hljs-attribute">extend .x-foo-part;
}</span></span></span>
多个影子根
为什么我需要在同一个元素上拥有多个影子根?我听到你问道。答案是:继承。
让我们假设我正在编写一个<x-dialog>
组件。在这个组件中,我编写了所有标记、样式和交互,以提供一个打开和关闭的对话框窗口。
<x-dialog>
<h1>My title</h1>
<p>Some details</p>
<button>Cancel</button>
<button>OK</button>
</x-dialog>
影子根通过<content>
插入点将任何用户提供的内容拉入div.inner
。
<div class="outer">
<div class="inner">
<content></content>
</div>
</div>
我还想创建一个<x-dialog-alert>
,它看起来和行为与<x-dialog>
一样,但具有更受限制的API,有点类似于alert('foo')
。
<x-dialog-alert>foo</x-dialog-alert>
var proto = Object.create(XDialog.prototype);
proto.createdCallback = function() {
XDialog.prototype.createdCallback.call(this);
this.createShadowRoot();
this.shadowRoot.innerHTML = templateString;
};
document.registerElement('x-dialog-alert', { prototype: proto });
新组件将有自己的影子根,但它被设计为在父类的影子根之上工作。<shadow>
表示“较旧”的影子根,允许我们将其中的内容投影到里面。
<shadow>
<h1>Alert</h1>
<content></content>
<button>OK</button>
</shadow>
一旦你理解了多个影子根,它们就成为一个强大的概念。缺点是它们带来了很多复杂性,并引入了很多边缘情况。
没有多个阴影的继承
即使没有多个影子根,继承仍然是可能的,但它涉及手动修改父类的影子根。
var proto = Object.create(XDialog.prototype);
proto.createdCallback = function() {
XDialog.prototype.createdCallback.call(this);
var inner = this.shadowRoot.querySelector('.inner');
var h1 = document.createElement('h1');
h1.textContent = 'Alert';
inner.insertBefore(h1, inner.children[0]);
var button = document.createElement('button');
button.textContent = 'OK';
inner.appendChild(button);
...
};
document.registerElement('x-dialog-alert', { prototype: proto });
这种方法的缺点是
- 没有那么优雅。
- 你的子组件依赖于父组件的实现细节。
- 如果父组件的影子根是“封闭”的,这将是不可能的,因为
this.shadowRoot
将是undefined
。
HTML导入
HTML导入提供了一种将一个.html
文档中定义的所有资产导入到另一个文档范围内的能力。
<span class="tag"><link</span> <span class="atn">rel</span><span class="pun">=</span><span class="atv">"import"</span> <span class="atn">href</span><span class="pun">=</span><span class="atv">"/path/to/imports/stuff.html"</span><span class="tag">></span>
如先前所述,Mozilla当前不打算实现HTML导入。这部分是因为我们想看看ES6模块在发布另一种导入外部资产的方式之前如何展开,部分是因为我们不认为它们启用了很多目前无法实现的功能。
我们已经在Firefox OS中使用Web组件超过一年,并发现使用现有的模块语法(AMD或Common JS)来解析依赖关系树,注册元素,使用正常的<script>
标签加载似乎足以完成工作。
HTML导入确实适合更简单/更具声明性的工作流,例如较旧的<element>
和Polymer的当前注册语法。
随着这种简单性的到来,来自社区的批评认为导入没有提供足够的控制来被认真地视为依赖关系管理解决方案。
在几个月前做出决定之前,Mozilla有一个在标志后面的工作实现,但在不完整的规范中遇到了困难。
它们会怎样?
Apple的隔离的自定义元素提案利用HTML导入样式方法为自定义元素提供它们自己的文档范围:也许将来会有一席之地。
在Mozilla,我们想探索如何将导入自定义元素定义与即将推出的ES6模块API对齐。如果/当它们出现并能够让开发人员做一些他们无法做到的事情时,我们准备实施。
总结
Web组件是一个典型的例子,说明了如今在浏览器中添加大型功能是多么困难。每个添加的API都将无限期地存在,并成为下一个障碍。
类似于将一个巨大的打结的线球拆开,添加更多,然后再次打结。这个结,我们的平台,不断变得更大、更复杂。
Web组件已经规划了三年多,但我们乐观地认为终点即将到来。所有主要供应商都已加入,充满热情,并投入了大量时间来帮助解决剩余问题。
让我们准备好将网络组件化!
更多
- 加入public-webapps邮件列表上的持续讨论。
- 关注W3C Web组件仓库。
- 注册Web组件周刊通讯。
- 今天在Firefox中使用Web组件,方法是打开about:config中的“dom.webcomponents.enabled”首选项。
关于 Wilson Page
Mozilla的前端开发人员。
40条评论