本文翻译自 Custom Elements: defining new elements in HTML,在保证技术要点表达准确的前提下,行文风格有少量改编和瞎搞。
本文目录
注意!这篇文章介绍的 API 尚未完全标准化,并且仍在变动中,在项目中使用这些实验性 API 时请务必谨慎。
引言
现在的 web 严重缺乏表达能力。你只要瞄一眼“现代”的 web 应用,比如 GMail,就会明白我的意思。
看看这一坨 DIV,这也叫现代?然而可悲的是,这就是我们构建 web 应用的方式。难道 web 开发就不能追求更粗更硬更长……哦不对,是更高更快更强的奥林匹克精神?
用时髦标记整点儿像样的
HTML 为我们提供了一个完美的文档组织工具,然而 HTML 规范定义的元素却很有限。
假如 GMail 的标记不是那么糟糕,结果会怎样?
<hangout-module>
<hangout-chat from="Paul, Addy">
<hangout-discussion>
<hangout-message from="Paul" profile="profile.png" profile="118075919496626375791" datetime="2013-07-17T12:02">
<p>Feelin' this Web Components thing.</p>
<p>Heard of it?</p>
</hangout-message>
</hangout-discussion>
</hangout-chat>
<hangout-chat>...</hangout-chat>
</hangout-module>
亮瞎狗眼颠覆三观!这清晰的结构,不识字也看得懂啊!最爽的是,它还有很强的可维护性,只要瞧一眼它的声明结构就可以清楚地知道它到底要干嘛。
赶紧开始吧
自定义元素允许开发者定义新的 HTML 元素类型。该规范只是 web 组件模块提供的众多新 API 中的一个,但它也很可能是最重要的一个。缺少自定义元素带来的以下特性,web 组件根本玩不转:
- 定义新的 HTML/DOM 元素
- 基于其他元素创建扩展元素
- 给一个标签绑定一组自定义功能
- 扩展已有 DOM 元素的 API
注册新元素
使用 document.register()
可以创建一个自定义元素
var XFoo = document.register('x-foo');
document.body.appendChild(new XFoo());
document.register()
的第一个参数是标签名,这个标签名必须包括一个连字符(-)。因此,诸如 <x-tags>
、<my-element>
、 <my-awesome-app>
都是合法的标签名,而 <tabs>
和 <foo_bar>
则不是。这个限定使解析器能很容易的区分自定义元素和 HTML 规范定义的元素,同时确保了 HTML 增加新标签时的向前兼容。
第二个参数是一个可选(译注:经测试,Chrome 29 中不能省略第二个参数)的对象,用于描述该元素的原型。在这里可以为元素添加自定义功能(公开属性和方法)。这个到 添加 JS 属性和方法 一节再细说。
自定义元素默认会继承 HTMLElement
的原型,因此上一个示例等同于:
var XFoo = document.register('x-foo', {
prototype: Object.create(HTMLElement.prototype)
});
调用 document.register('x-foo')
向浏览器注册了这个新元素,并返回一个可以用来创建 <x-foo>
元素实例的构造器。如果你不想使用构造器,也可以使用其他实例化元素的技术。
提示:如果你不希望在 window
全局对象中创建元素构造器,还可以把它放进命名空间:
var myapp = {};
myapp.XFoo = document.register('x-foo');
扩展原生元素
假设原生 <button>
元素不能满足你的需求,你想将其增强为一个“超级按钮”,可以通过创建一个继承 HTMLButtonElement
原型的新元素,来扩展 <button>
元素:
var MegaButton = document.register('mega-button', {
prototype: Object.create(HTMLButtonElement.prototype)
});
这类自定义元素被称为类型扩展自定义元素。它们以继承一个特定的 HTMLElement
的方式表达了“元素 X 是一个 Y”。
示例:
<button is="mega-button">
元素如何提升
你有没有想过为什么 HTML 解析器不会对不是规范定义的标签报错?比如我们在页面中声明一个 <randomtag>
,一切都很和谐。根据 HTML 规范的表述,非规范定义的元素将使用 HTMLUnknownElement
接口。<randomtag>
不是规范定义的,它会继承自 HTMLUnknownElement
。
对自定义元素来说,情况就不一样了。拥有合法元素名的自定义元素将继承 HTMLElement
。你可以打开控制台(不知道快捷键的都滚粗……),运行下面这段代码,看看结果是不是 true
:
// “tabs”不是一个合法的自定义元素名
document.createElement('tabs').__proto__ === HTMLUnknownElement.prototype // “x-tabs”是一个合法的自定义元素名
document.createElement('x-tabs').__proto__ == HTMLElement.prototype
注意:在不支持 document.register()
的浏览器中,<x-tabs>
仍为 HTMLUnknownElement
。
unresolved(未提升)元素
由于自定义元素是由 JavaScript 代码 document.register()
注册的,因此它们可能在元素定义被注册到浏览器之前就已经声明或创建过了。比如你可以先在页面中声明 <x-tabs>
,再调用 document.register('x-tabs')
。
在被提升到其定义之前,这些元素被称为“unresolved 元素”。它们是拥有合法自定义元素名的 HTML 元素,只是还没有注册成为自定义元素。
下面这个表格看起来更直观一些:
类型 | 继承自 | 示例 |
---|---|---|
unresolved 元素 | HTMLElement |
<x-tabs> 、<my-element> 、<my-awesome-app>
|
未知元素 | HTMLUnknownElement |
<tabs> 、<foo_bar>
|
实例化元素
我们创建普通元素用到的一些技术也可以用于自定义元素。和所有标准定义的元素一样,自定义元素既可以在 HTML 中声明,也可以通过 JavaScript 在 DOM 中创建。
实例化自定义标签
声明元素:
<x-foo></x-foo>
在 JS 中创建 DOM:
var xFoo = document.createElement('x-foo');
xFoo.addEventListener('click', function(e) {
alert('Thanks!');
});
使用 new
操作符创建实例:
var xFoo = new XFoo();
document.body.appendChild(xFoo);
实例化类型扩展元素
实例化类型扩展自定义元素的方法和普通自定义标签惊人的相似。
声明:
<!-- <button> “是一个”超级按钮 -->
<button is="mega-button">
在 JS 中创建 DOM:
var megaButton = document.createElement('button', 'mega-button');
// megaButton instanceof MegaButton === true
看,这是一个接收第二个参数为 is
属性值的 document.createElement()
重载。
使用 new
操作符:
var megaButton = new MegaButton();
document.body.appendChild(megaButton);
现在,我们已经学习了如何使用 document.register()
来向浏览器注册一个新标签。但这还不够,接下来我们要向新标签添加属性和方法。
添加 JS 属性和方法
自定义元素最强大的地方在于,你可以在元素定义中加入属性和方法,给元素绑定特定的功能。你可以把它想象成一种给你的元素创建公开 API 的方法。
下面是一个完整的示例:
var XFooProto = Object.create(HTMLElement.prototype);
// 1. 为 x-foo 创建 foo() 方法
XFooProto.foo = function() {
alert('foo() called');
}; // 2. 定义一个只读属性 "bar".
Object.defineProperty(XFooProto, "bar", {value: 5}); // 3. 注册 x-foo
var XFoo = document.register('x-foo', {prototype: XFooProto}); // 4. 创建一个 x-foo 实例.
var xfoo = document.createElement('x-foo'); // 5. 插入页面
document.body.appendChild(xfoo);
构造原型的方法多种多样,如果你不喜欢上面这种方式,还有一个更简洁的例子:
var XFoo = document.register('x-foo', {
prototype: Object.create(HTMLElement.prototype, {
bar: {
get: function() { return 5; }
},
foo: {
value: function() {
alert('foo() called');
}
}
})
});
以上两种方式,第一种使用了 ES5 的 Object.defineProperty,第二种则使用了 get/set。
生命周期回调方法
元素可以定义特殊的方法,来注入其生存期内关键的时间点。这些方法各自有特定的名称和用途,它们被恰如其分地命名为生命周期回调:
回调方法名称 | 调用时间点 |
---|---|
createdCallback | 创建元素实例 |
enteredDocumentCallback | 向文档插入实例 |
leftDocumentCallback | 从文档中移除实例 |
attributeChangedCallback(attrName, oldVal, newVal) | 添加,移除,或修改一个属性 |
示例:为 <x-foo>
定义 createdCallback()
和 enteredDocumentCallback()
var proto = Object.create(HTMLElement.prototype); proto.createdCallback = function() {...};
proto.enteredDocumentCallback = function() {...}; var XFoo = document.register('x-foo', {prototype: proto});
所有生命周期回调都是可选的,你可以只在需要关注的时间点定义它们。举个例子,你有一个很复杂的元素,它会在 createdCallback()
打开一个 indexedDB 连接。在将其从 DOM 移除时,leftDocumentCallback()
会做一些必要的清理工作。注意:不要过于依赖这些生命周期方法(如果用户直接关闭浏览器标签,生命周期方法是没有机会执行的),仅将其作为可能的优化点。
另一个生命周期回调的例子是为元素设置默认的事件监听器:
proto.createdCallback = function() {
this.addEventListener('click', function(e) {
alert('Thanks!');
});
};
添加标记
我们已经创建好 <x-foo>
并添加了 JavaScript API,但它还没有任何内容。要不我们给它整点?
生命周期回调在这个时候就派上用场了。我们甚至可以用 createdCallback()
给一个元素赋予一些默认的 HTML:
var XFooProto = Object.create(HTMLElement.prototype); XFooProto.createdCallback = function() {
this.innerHTML = "<b>I'm an x-foo-with-markup!</b>";
}; var XFoo = document.register('x-foo-with-markup', {prototype: XFooProto});
实例化这个标签并在 DevTools 中观察,可以看到如下结构:
用 Shadow DOM 封装内部实现
Shadow DOM 是一个封装内容的强大工具,配合使用自定义元素就更神奇了!
Shadow DOM 为自定义元素提供了:
- 一种隐藏内部实现的方法,从而将用户与血淋淋的实现细节隔离开。
- 简单有效的样式隔离。
从 Shadow DOM 创建元素,跟创建一个渲染基础标记的元素非常类似,区别在于 createdCallback()
回调:
var XFooProto = Object.create(HTMLElement.prototype); XFooProto.createdCallback = function() {
// 1. Attach a shadow root on the element.
var shadow = this.createShadowRoot(); // 2. Fill it with markup goodness.
shadow.innerHTML = "<b>I'm in the element's Shadow DOM!</b>";
};
var XFoo = document.register('x-foo-shadowdom', {prototype: XFooProto});
我们并没有直接设置 <x-foo-shadowdom>
的 innerHTML
,而是为其创建了一个用于填充标记的 Shadow Root。在 DevTools 中选中“显示 Shadow DOM”,你就会看到一个可以展开的 #document-fragment:
这就是 Shadow Root!
从模板创建元素
HTML Template 是另一组跟自定义元素完美融合的新 API。
模板元素可用于声明 DOM 片段。它们可以被解析并在页面加载后插入,以及延迟到运行时才进行实例化。模板是声明自定义元素结构的理想方案。
示例:注册一个由模板和 Shadow DOM 创建的元素:
<template id="sdtemplate">
<style>
p { color: orange; }
</style>
<p>I'm in Shadow DOM. My markup was stamped from a &lt;template&gt;.</p>
</template> <script>
var proto = Object.create(HTMLElement.prototype, {
createdCallback: {
value: function() {
var t = document.querySelector('#sdtemplate');
this.createShadowRoot().appendChild(t.content.cloneNode(true));
}
}
});
document.register('x-foo-from-template', {prototype: proto});
</script>
短短几行做了很多事情,我们挨个来看都发生了些什么:
- 我们在 HTML 中注册了一个新元素:
<x-foo-from-template>
- 这个元素的 DOM 是从一个模板创建的
- Shadow DOM 隐藏了该元素的实现细节
- Shadow DOM 也对元素的样式进行了隔离(
p {color: orange;}
不会把整个页面都搞成橙色)
牛逼!
为自定义元素增加样式
和其他 HTML 标签一样,自定义元素也可以通过选择器定义样式:
<style>
app-panel {
display: flex;
}
[is="x-item"] {
transition: opacity 400ms ease-in-out;
opacity: 0.3;
flex: 1;
text-align: center;
border-radius: 50%;
}
[is="x-item"]:hover {
opacity: 1.0;
background: rgb(255, 0, 255);
color: white;
}
app-panel > [is="x-item"] {
padding: 5px;
list-style: none;
margin: 0 7px;
}
</style> <app-panel>
<li is="x-item">Do</li>
<li is="x-item">Re</li>
<li is="x-item">Mi</li>
</app-panel>
为使用 Shadow DOM 的元素增加样式
有了 Shadow DOM 场面就热闹得多了,它可以极大增强自定义元素的能力。
Shadow DOM 为元素增加了样式封装的特性。Shadow Root 中定义的样式不会暴露到宿主外部或对页面产生影响。对自定义元素来说,元素本身是宿主。样式封装的属性也使得自定义元素能够为自己定义默认样式。
Shadow DOM 的样式是一个很大的话题!如果你想更多地了解它,推荐你阅读我写的其他文章:
- Polymer 文档:《元素样式指南》。
- 发表于 html5rocks.com 的《Shadow DOM 201:CSS 和样式》
使用 :unresolved 伪类避免无样式内容闪烁(FOUC)
为了缓解无样式内容闪烁的影响,自定义元素规范提出了一个新的 CSS 伪类 :unresolved
。在浏览器调用你的createdCallback()
(请看生命周期回调方法一节)之前,这个伪类都可以匹配到 unresolved 元素。一旦产生调用,就意味着元素已经完成提升,成为它被定义的形态,该元素就不再是一个 unresolved 元素。
Chrome 29 已经原生支持 :unresolved
伪类。
示例:注册后渐显的 <x-foo>
标签:
x-foo {
opacity: 1;
transition: opacity 300ms;
}
x-foo:unresolved {
opacity: 0;
}
请记住 :unresolved
伪类只能用于 unresolved 元素,而不能用于继承自 HTMLUnkownElement
的元素(请看元素如何提升一节)。
<style>
/* 给所有未提升元素添加边框 */
:unresolved {
border: 1px dashed red;
display: inline-block;
}
/* 未提升的 x-panel 文本内容为红色 */
x-panel:unresolved {
color: red;
}
/* 完成注册的 x-panel 文本内容为绿色 */
x-panel {
color: green;
display: block;
padding: 5px;
display: block;
}
</style> <panel>
I'm black because :unresolved doesn't apply to "panel".
It's not a valid custom element name.
</panel> <x-panel>I'm red because I match x-panel:unresolved.</x-panel>
了解更多 :unresolved
的知识,请看 Polymer 文档《元素样式指南》。
历史和浏览器支持
特性检测
特性检测就是检查浏览器是否提供了 document.register()
接口:
function supportsCustomElements() {
return 'register' in document;
} if (supportsCustomElements()) {
// 使用自定义元素 API
} else {
// 使用其他类库创建组件
}
浏览器支持
Chrome 27 和 Firefox 23 都提供了对 document.register()
的支持,不过之后规范又有一些演化。Chrome 31 将是第一个支持新规范的版本。提示:在 Chrome 31 中使用自定义元素,需要开启 about:flags 中的“实验性 web 平台特性(Experimental Web Platform features)”选项。
在浏览器支持稳定之前,也有一些很好的过渡方案:
HTMLElementElement 怎么了?
紧跟过标准的人都知道曾经有一个 <element>
标签。它非常好用,你只要像下面这样就可以声明式的注册一个新元素:
<element name="my-element">
...
</element>
不幸的是,在它的提升过程、边界案例,以及末日般的复杂场景中,需要处理大量的时序问题。<element>
因此*搁置。2013 年 8 月,Dimitri Glazkov 在 public-webapps 邮件组中宣告废弃 <element>
,至少目前看来是废掉了。
值得注意的是,Polymer 实现了用
形式声明式地注册元素。这是怎么做到的?它用的正是 document.register('polymer-element')
以及从模板创建元素一节介绍的技术。
结语
自定义元素为我们提供了一个工具,通过它我们可以扩展 HTML 的词汇,赋予它新的特性,并把不同的 web 平台连接在一起。结合其他新的基本平台,如 Shadow DOM 和模板,我们领略了 web 组件的宏伟蓝图。标记语言将再次变得很时髦!
如果你对使用 web 组件感兴趣,建议你去看看 Polymer 框架,从它开始玩吧。