By Eric Bidelman.
Engineer @ Google working on web tooling: Headless Chrome, Puppeteer, Lighthouse
借助自定义元素,网络开发者可以创建新的 HTML 标记、扩展现有 HTML 标记,或者扩展其他开发者编写的组件。API 是网络组件的基础。它提供了基于网络标准来使用原生 JS/HTML/CSS 创建可重用组件的方法。其结果是代码更精简且模块化,并且在我们的应用中的可重用性更好。
Note: 本文说明新的自定义元素 v1 规范。如果您有自定义元素的使用经验,则应该了解随 Chrome 33 提供的 v0 版。这些概念是相同的,只不过 v1 规范的 API 存在一些重要差异。请继续阅读,了解新的内容。或者参阅历史记录和浏览器支持,了解详细信息。
浏览器提供了一个用于实现结构化网络应用的良好工具。该工具称为 HTML。 您可能已经对它有所了解!它是一种声明式、可移植、受广泛支持且易于使用的工具。HTML 虽然很伟大,但其词汇和可扩展性却相当有限。HTML 现行标准缺乏自动关联 JS 行为和标记的方法,直到今天,情况才有所改观。
自定义元素使 HTML 变得现代化;补充了缺少的部件,并将结构与行为相结合。 如果 HTML 无法为问题提供解决方案,我们可以创建自定义元素来解决。 自定义元素在保留 HTML 优点的同时为浏览器带来新功能。
要定义新的 HTML 元素,我们需要 JavaScript 的帮助!
customElements
全局性用于定义自定义元素,并让浏览器学习新的标记。
以需要创建的标记名称调用 customElements.define()
,并使用 JavaScriptclass
扩展基础 HTMLElement
。
示例 - 定义一个移动抽屉面板 <app-drawer>
:
class AppDrawer extends HTMLElement {...}
window.customElements.define('app-drawer', AppDrawer);
// Or use an anonymous class if you don't want a named constructor in current scope.
window.customElements.define('app-drawer', class extends HTMLElement {...});
示例用法:
<app-drawer></app-drawer>
需要记住的是,自定义元素与 <div>
或任何其他元素的使用没有区别。可以在页面上声明 JavaScript 动态创建的实例,可添加事件侦听器,诸如此类。继续阅读,查看更多示例。
自定义元素的功能使用 ES2015 class
来定义,它扩展了 HTMLElement
。扩展HTMLElement
可确保自定义元素继承完整的 DOM API,并且添加到类的任何属性/方法都将成为元素 DOM 接口的一部分。实际上,可使用类来为标记创建公共 JavaScript API。
示例: - 定义 DOM 的 <app-drawer>
接口:
class AppDrawer extends HTMLElement {
// A getter/setter for an open property.
get open() {
return this.hasAttribute('open');
}
set open(val) {
// Reflect the value of the open property as an HTML attribute.
if (val) {
this.setAttribute('open', '');
} else {
this.removeAttribute('open');
}
this.toggleDrawer();
}
// A getter/setter for a disabled property.
get disabled() {
return this.hasAttribute('disabled');
}
set disabled(val) {
// Reflect the value of the disabled property as an HTML attribute.
if (val) {
this.setAttribute('disabled', '');
} else {
this.removeAttribute('disabled');
}
}
// Can define constructor arguments if you wish.
constructor() {
// If you define a constructor, always call super() first!
// This is specific to CE and required by the spec.
super();
// Setup a click listener on <app-drawer> itself.
this.addEventListener('click', e => {
// Don't toggle the drawer if it's disabled.
if (this.disabled) {
return;
}
this.toggleDrawer();
});
}
toggleDrawer() {
...
}
}
customElements.define('app-drawer', AppDrawer);
在本例中,我们创建了一个具有open
属性、disabled
属性和toggleDrawer()
方法的抽屉式导航栏。
它还以 HTML 属性来反映属性。
自定义元素有一个超赞功能,即:类定义中的this
引用 DOM 元素自身,亦即类的实例。
在本例中,this
是指 <app-drawer>
。这 (😉) 就是元素向自身添加 click
侦听器的方式!您不限于事件侦听器。完整的 DOM API 在元素代码内提供。使用 this
来访问元素属性、检验子项 (this.children
) 和查询节点 (this.querySelectorAll('.items')
) 等。
有关创建自定义元素的规则
1. 自定义元素的名称**必须包含短横线 (-)**。因此,`<x-tags>`、`<my-element>` 和 `<my-awesome-app>` 等均为有效名称,而 `<tabs>` 和 `o_bar>` 则为无效名称。这一要求使得 HTML 解析器能够区分自定义元素和常规元素。它还可确保向 HTML 添加新标记时的向前兼容性。
2. 您不能多次注册同一标记。否则,将产生 `DOMException`。让浏览器了解新标记后,它就这样定了下来。您不能撤回。
3. 自定义元素不能自我封闭,因为 HTML 仅允许[少数元素](https://html.spec.whatwg.org/multipage/syntax.html#void-elements)自我封闭。必须编写封闭标记 (`<app-drawer></app-drawer>`)。
Custom Elements API 对创建新的 HTML 元素很有用,但它也可用于扩展其他自定义元素,甚至是浏览器的内置 HTML。
扩展其他自定义元素可通过扩展其类定义来实现。
示例 - 创建扩展 <app-drawer>
的 <fancy-app-drawer>
:
class FancyDrawer extends AppDrawer {
constructor() {
super(); // always call super() first in the constructor. This also calls the extended class' constructor.
...
}
toggleDrawer() {
// Possibly different toggle implementation?
// Use ES2015 if you need to call the parent method.
// super.toggleDrawer()
}
anotherMethod() {
...
}
}
customElements.define('fancy-app-drawer', FancyDrawer);
假定您希望创建一个漂亮的 <button>
。除了复制 <button>
的行为和功能,更好的选择是使用自定义元素逐渐增补现有元素。
自定义内置元素是用于扩展某个浏览器内置 HTML 标记的自定义元素。 扩展现有元素的主要好处是能获得其所有功能(DOM 属性、方法、无障碍功能)。 编写 Progressive Web App 的最佳方法是逐渐增补现有 HTML 元素。
要扩展元素,您需要创建继承自正确 DOM 接口的类定义。
例如,扩展 <button>
的自定义元素需要从 HTMLButtonElement
而不是 HTMLElement
继承。
同样,扩展 <img>
的元素需要扩展 HTMLImageElement
。
示例 - 扩展 <button>
:
// See https://html.spec.whatwg.org/multipage/indices.html#element-interfaces
// for the list of other DOM interfaces.
class FancyButton extends HTMLButtonElement {
constructor() {
super(); // always call super() first in the constructor.
this.addEventListener('click', e => this.drawRipple(e.offsetX, e.offsetY));
}
// Material design ripple animation.
drawRipple(x, y) {
let div = document.createElement('div');
div.classList.add('ripple');
this.appendChild(div);
div.style.top = `${y - div.clientHeight/2}px`;
div.style.left = `${x - div.clientWidth/2}px`;
div.style.backgroundColor = 'currentColor';
div.classList.add('run');
div.addEventListener('transitionend', e => div.remove());
}
}
customElements.define('fancy-button', FancyButton, {extends: 'button'});
扩展原生元素时,对 define()
的调用会稍有不同。所需的第三个参数告知浏览器要扩展的标记。这很有必要,因为许多 HTML 标记均使用同一 DOM 接口。例如,<section>
、<address>
和 <em>
(以及其他)都使用 HTMLElement
;<q>
和 <blockquote>
则使用 HTMLQuoteElement
;等等。指定 {extends: 'blockquote'}
可让浏览器知道您创建的是增强的 <blockquote>
而不是 <q>
。有关 HTML DOM 接口的完整列表,请参阅 HTML 规范。
自定义内置元素的用户有多种方法来使用该元素。他们可以通过在原生标记上添加 is=""
属性来声明:
<!-- This <button> is a fancy button. -->
<button is="fancy-button" disabled>Fancy button!</button>
在 JavaScript 中创建实例:
// Custom elements overload createElement() to support the is="" attribute.
let button = document.createElement('button', {is: 'fancy-button'});
button.textContent = 'Fancy button!';
button.disabled = true;
document.body.appendChild(button);
或者使用 new
运算符:
let button = new FancyButton();
button.textContent = 'Fancy button!';
button.disabled = true;
此处为扩展 <img>
的另一个例子。
示例 - 扩展 <img>
:
customElements.define('bigger-img', class extends Image {
// Give img default size if users don't specify.
constructor(width=50, height=50) {
super(width * 10, height * 10);
}
}, {extends: 'img'});
用户声明此组件为:
<!-- This <img> is a bigger img. -->
<img is="bigger-img" width="15" height="20">
或者在 JavaScript 中创建实例:
const BiggerImage = customElements.get('bigger-img');
const image = new BiggerImage(15, 20); // pass constructor values like so.
console.assert(image.width === 150);
console.assert(image.height === 200);
自定义元素可以定义特殊生命周期钩子,以便在其存续的特定时间内运行代码。 这称为自定义元素响应。
名称 | 调用时机 |
---|---|
`constructor` | 创建或[升级](#upgrades)元素的一个实例。用于初始化状态、设置事件侦听器或[创建 Shadow DOM](#shadowdom)。参见[规范](https://html.spec.whatwg.org/multipage/scripting.html#custom-element-conformance),了解可在 `constructor` 中完成的操作的相关限制。 |
`connectedCallback` | 元素每次插入到 DOM 时都会调用。用于运行安装代码,例如获取资源或渲染。一般来说,您应将工作延迟至合适时机执行。 |
`disconnectedCallback` | 元素每次从 DOM 中移除时都会调用。用于运行清理代码(例如移除事件侦听器等)。 |
`attributeChangedCallback(attrName, oldVal, newVal)` | 属性添加、移除、更新或替换。解析器创建元素时,或者[升级](#upgrades)时,也会调用它来获取初始值。**Note: **仅 `observedAttributes` 属性中列出的特性才会收到此回调。 |
`adoptedCallback()` | 自定义元素被移入新的 `document`(例如,有人调用了 `document.adoptNode(el)`)。 |
浏览器对在 attributeChangedCallback()
数组中添加到白名单的任何属性调用 observedAttributes
(请参阅保留对属性的更改)。实际上,这是一项性能优化。当用户更改一个通用属性(如 style
或 class
)时,您不希望出现大量的回调。
响应回调是同步的。如果有人对您的元素调用 el.setAttribute(...)
,浏览器将立即调用 attributeChangedCallback()
。
同理,从 DOM 中移除元素(例如用户调用 el.remove()
)后,您会立即收到 disconnectedCallback()
。
示例:向 <app-drawer>
中添加自定义元素响应:
class AppDrawer extends HTMLElement {
constructor() {
super(); // always call super() first in the constructor.
...
}
connectedCallback() {
...
}
disconnectedCallback() {
...
}
attributeChangedCallback(attrName, oldVal, newVal) {
...
}
}
必要时应定义响应。如果您的元素足够复杂,并在 connectedCallback()
中打开 IndexedDB 的连接,请在 disconnectedCallback()
中执行所需清理工作。但必须小心!您不能认为您的元素任何时候都能从 DOM 中正常移除。例如,如果用户关闭了标签,disconnectedCallback()
将无法调用。
示例:将自定义元素移动到另一文档,观察其 adoptedCallback()
:
function createWindow(srcdoc) {
let p = new Promise(resolve => {
let f = document.createElement('iframe');
f.srcdoc = srcdoc || '';
f.onload = e => {
resolve(f.contentWindow);
};
document.body.appendChild(f);
});
return p;
}
// 1. Create two iframes, w1 and w2.
Promise.all([createWindow(), createWindow()])
.then(([w1, w2]) => {
// 2. Define a custom element in w1.
w1.customElements.define('x-adopt', class extends w1.HTMLElement {
adoptedCallback() {
console.log('Adopted!');
}
});
let a = w1.document.createElement('x-adopt');
// 3. Adopts the custom element into w2 and invokes its adoptedCallback().
w2.document.body.appendChild(a);
});
HTML 属性通常会将其值以 HTML 特性的形式映射回 DOM。例如,如果 hidden
或 id
的值在 JS 中发生变更:
div.id = 'my-id';
div.hidden = true;
值将以特性的形式应用于活动 DOM:
<div id="my-id" hidden>
这称为“将属性映射为特性”。几乎所有的 HTML 属性都会如此。为何?特性也可用于以声明方式配置元素,且无障碍功能和 CSS 选择器等某些 API 依赖于特性工作。
如果您想要让元素的 DOM 状态与其 JavaScript 状态保持同步,映射属性非常有用。 您可能想要映射属性的另一个原因是,用户定义的样式在 JS 状态变更时应用。
回到我们的 <app-drawer>
例子。此组件的用户可能会希望其灰色显示和/或停用,以避免用户交互:
app-drawer[disabled] {
opacity: 0.5;
pointer-events: none;
}
disabled
属性在 JS 中发生变更时,我们希望该特性能添加到 DOM,以便用户选择器能匹配。
元素可通过将值映射到具有同一名称的特性上来提供该行为:
...
get disabled() {
return this.hasAttribute('disabled');
}
set disabled(val) {
// Reflect the value of `disabled` as an attribute.
if (val) {
this.setAttribute('disabled', '');
} else {
this.removeAttribute('disabled');
}
this.toggleDrawer();
}
HTML 属性可方便地让用户声明初始状态:
<app-drawer open disabled></app-drawer>
元素可通过定义 attributeChangedCallback
来对属性的更改作出响应。对于 observedAttributes
数组中列出的每一属性更改,浏览器都将调用此方法。
class AppDrawer extends HTMLElement {
...
static get observedAttributes() {
return ['disabled', 'open'];
}
get disabled() {
return this.hasAttribute('disabled');
}
set disabled(val) {
if (val) {
this.setAttribute('disabled', '');
} else {
this.removeAttribute('disabled');
}
}
// Only called for the disabled and open attributes due to observedAttributes
attributeChangedCallback(name, oldValue, newValue) {
// When the drawer is disabled, update keyboard/screen reader behavior.
if (this.disabled) {
this.setAttribute('tabindex', '-1');
this.setAttribute('aria-disabled', 'true');
} else {
this.setAttribute('tabindex', '0');
this.setAttribute('aria-disabled', 'false');
}
// TODO: also react to the open attribute changing.
}
}
在示例中,我们在 <app-drawer>
属性发生变化时对 disabled
设置额外的属性。
虽然我们这里没有这样做,您也可以使用 attributeChangedCallback
来让 JS 属性与其属性同步。
我们已经了解到,自定义元素通过调用 customElements.define()
进行定义。但这不意味着您需要一次性定义并注册自定义元素。
自定义元素可以在定义注册之前使用。
渐进式增强是自定义元素的一项特点。换句话说,您可以在页面声明多个 <app-drawer>
元素,并在等待较长的时间之后才调用 customElements.define('app-drawer', ...)
。之所以会这样,原因是浏览器会因为存在未知标记而采用不同方式处理潜在自定义元素。调用 define()
并将类定义赋予现有元素的过程称为“元素升级”。
要了解标记名称何时获得定义,可以使用 window.customElements.whenDefined()
。它提供可在元素获得定义时进行解析的 Promise。
customElements.whenDefined('app-drawer').then(() => {
console.log('app-drawer defined');
});
示例 - 推迟生效时间,直至一组子元素升级
<share-buttons>
<social-button type="twitter"><a href="...">Twitter</a></social-button>
<social-button type="fb"><a href="...">Facebook</a></social-button>
<social-button type="plus"><a href="...">G+</a></social-button>
</share-buttons>
// Fetch all the children of <share-buttons> that are not defined yet.
let undefinedButtons = buttons.querySelectorAll(':not(:defined)');
let promises = [...undefinedButtons].map(socialButton => {
return customElements.whenDefined(socialButton.localName);
));
// Wait for all the social-buttons to be upgraded.
Promise.all(promises).then(() => {
// All social-button children are ready.
});