web-components

【Chrome官方文章翻译】可重用的Web Components

可重用的Web Components

Web Components可以让开发者创造标签,开发者可以增强现有HTML标签,扩展其他开发者编写的标签。

它提供了基于web标准的方式,可以让你用少量的、模块化的代码去编写可重用的组件。

定义一个新元素

使用window.customElements.define可以定义一个新元素。

它的第一个参数是标签名,第二个参数是一个继承HTMLElement的类。

1
2
3
class AppDrawer extends HTMLElement {}

window.customElements.define('app-drawer', AppDrawer);

那么如何使用它呢?你只要像使用正常的html标签一样使用它就可以了!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html>
<html>
<head>
<title>Web Components</title>
</head>
<body>
<app-drawer>app-drawer</app-drawer>
<script>
class AppDrawer extends HTMLElement {}

window.customElements.define("app-drawer", AppDrawer);
</script>
</body>
</html>

自定义的元素和普通的HTML元素没有区别。它的实例可以在页面中声明,然后使用JS定义它。它也可以使用事件监听等HTML元素具有的特性。这在后面将会详细介绍。

自定义元素的JS API

自定义元素的功能使用ES2015 class实现,由于它继承了HTMLElement,所以拥有完整的DOM API。这也意味着JS里的属性、方法成了DOM接口的一部分,就像是我们在用JS为标签创建API。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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();
}

// 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 => {
this.toggleDrawer();
});
}

toggleDrawer() {
}
}

customElements.define('app-drawer', AppDrawer);

在这个例子中,我们创建了拥有open属性和toggleDrawer()方法的app-drawer元素。

在定义元素的class语法中,this指向元素本身。在这个例子中它可以获取属性、监听事件。其实其他DOM API它都可以使用,比如获取children(this.children)、选择元素(this.querySelectorAll(‘.items’))等等。

自定义元素命名规则

  1. 必须包含破折号(-),这是为了区分自定义元素和HTML元素,并且确保兼容性(即使HTML增加新标签也不会导致冲突)

  2. 不能重复注册相同标签,否则会抛DOMException,因为这完全没必要。

  3. 不支持自闭合标签的写法,因为在HTML规范里只有一些标签是允许自闭合的。所以只有<app-drawer></app-drawer>这样写才是正确的。

自定义元素的生命周期(custom element reactions)

自定义元素有它的生命周期hooks。罗列如下:

名称 触发时机
constructor 实例被创建或升级时执行,通常用来初始化一些状态、设置事件监听或者创建shadow dom
connectedCallback 当元素被添加到DOM中触发,通常在这个时候进行数据请求等
disconnectedCallback 元素在DOM中被移除时触发,通常做一些清理操作
attributeChangedCallback(attrName, oldVal, newVal) 当监听的属性(在observedAttributes属性列表中)被增加、删除、更新、替换时触发。在元素被解析器创建或升级相应属性值初始化时也会触发
adoptedCallback 元素被移动到一个新文档中触发(如:调用document.adoptNode(el)))

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class AppDrawer extends HTMLElement {
constructor() {
super(); // always call super() first in the constructor.
}
connectedCallback() {

}
disconnectedCallback() {

}
attributeChangedCallback(attrName, oldVal, newVal) {

}
}

回调函数是同步的。比如当调用el.setAttribute(),attributeChangedCallback()会立刻执行。

这些回调函数不是在所有情况下都是可靠的,比如用户关闭标签页时disconnectedCallback不会执行。

JS和HTML属性

使用JS设置HTML属性

使用JS设置HTML属性是很常见的,比如在JS中执行

1
2
div.id = 'my-id';
div.hidden = true;

html属性就会变为

1
<div id="my-id" hidden>

这是很有用的功能。比如你想实现样式根据JS状态改变。

在下面这个例子中我们可以通过点击切换app-drawer元素的透明度,并为它设置一些属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
<!DOCTYPE html>
<html>
<head>
<title>Web Components</title>
</head>
<style>
app-drawer[disabled] {
opacity: 0.5;
}

app-drawer {
opacity: 1;
}
</style>
<body>
<app-drawer>app-drawer</app-drawer>
<script>
class AppDrawer extends HTMLElement {
static get observedAttributes() {
return ["disabled"];
}

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.
}

// 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 => {
this.toggleDrawer();
});
}

toggleDrawer() {
this.disabled = Math.random() > 0.5 ? true : false;
}
}

customElements.define("app-drawer", AppDrawer);
</script>
</body>
</html>

元素升级

我们前面学习了使用customElements.define定义元素,它的定义和注册(使用)是一起的,但其实你可以在定义之前就注册(使用)这个元素。也就说你先注册<app-drawer>,但不执行customElements.define('app-drawer', ...)也是可以的。

因为浏览器对未知标签会区别对待。

如果你先元素标签,后面再调用define()方法定义元素,这种方式就叫做元素升级。

你可以使用window.customElements.whenDefined()方法来监听元素在什么时候被定义。

1
2
3
customElements.whenDefined('app-drawer').then(() => {
console.log('app-drawer defined');
});

下面这个例子,在所有子元素被升级后再做了一些操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<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>

<script>
// 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.
// do some thing
});
</script>

元素定义

自定义元素可以在它的内部代码中使用DOM API管理自己的内容。而元素生命周期也使得这种管理更加方便。

例子 - 使用默认的HTML创建一个元素

1
2
3
4
5
6
customElements.define('x-foo-with-markup', class extends HTMLElement {
connectedCallback() {
this.innerHTML = "<b>I'm an x-foo-with-markup!</b>";
}
...
});

网页上会呈现为

1
2
3
<x-foo-with-markup>
<b>I'm an x-foo-with-markup!</b>
</x-foo-with-markup>

使用Shadow DOM创建元素

Shadow DOM可以在页面中提供一块区域让元素有隔离的渲染和样式。你甚至可以将整个应用隐藏到一个标签中。

在constructor中调用this.attachShadow方法就可以使用Shadow DOM了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<!DOCTYPE html>
<html>
<head>
<title>Web Components</title>
</head>
<body>
<x-foo-shadowdom>
<p><b>User's</b> custom text</p>
</x-foo-shadowdom>
<script>
let tmpl = document.createElement("template");
tmpl.innerHTML = `
<style>:host {}</style> <!-- look ma, scoped styles -->
<b>I'm in shadow dom!</b>
<slot></slot>
`;

customElements.define(
"x-foo-shadowdom",
class extends HTMLElement {
constructor() {
super(); // always call super() first in the constructor.

// Attach a shadow root to the element.
let shadowRoot = this.attachShadow({ mode: "open" });
shadowRoot.appendChild(tmpl.content.cloneNode(true));
}
}
);
</script>
</body>
</html>

网页呈现结果

1
2
3
4
5
<x-foo-shadowdom>
#shadow-root
<b>I'm in shadow dom!</b>
<slot></slot> <!-- slotted content appears here -->
</x-foo-shadowdom>

使用<template>创建元素

template可以让你很方便地声明元素的结构。

例子 - 使用template注册一个Shadow DOM元素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<template id="x-foo-from-template">
<style>
p { color: green; }
</style>
<p>I'm in Shadow DOM. My markup was stamped from a &lt;template&gt;.</p>
</template>

<script>
let tmpl = document.querySelector('#x-foo-from-template');
// If your code is inside of an HTML Import you'll need to change the above line to:
// let tmpl = document.currentScript.ownerDocument.querySelector('#x-foo-from-template');

customElements.define('x-foo-from-template', class extends HTMLElement {
constructor() {
super(); // always call super() first in the constructor.
let shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.appendChild(tmpl.content.cloneNode(true));
}
...
});
</script>

这个例子中有几个关键的知识点:

  1. 定义了一个新标签<x-foo-from-template>

  2. 使用template创建Shadow DOM

  3. 由于Shadow DOM的存在,元素DOM是内置的

  4. 由于Shadow DOM的存在,元素的CSS也是内置的,并且样式作用限定在了元素的内部

给自定义元素加样式

即使你的元素使用Shadow DOM限定了样式,元素的样式也会受页面样式的影响。

页面的样式也被称为用户定义样式(user-defined styles)。如果它和Shadow DOM有相同的样式,则用户自定义样式生效。

给未定义的元素设置样式

元素未定义(升级)之前,你可以使用:not(:defined)伪类为它设置样式。

这种预设样式也是有用的,比如你可以让元素占有一定布局空间,即使它还没被定义。

下面这个例子,元素在被定义之前也占据一定空间。当然了,当元素被定义后,app-drawer:not(:defined)选择器也就失效了。

1
2
3
4
5
6
app-drawer:not(:defined) {
/* Pre-style, give layout, replicate app-drawer's eventual styles, etc. */
display: inline-block;
height: 100vh;
width: 100vh;
}

扩展元素

自定义元素API不仅对创建新的HTML元素有用的,对于扩展其他自定义元素甚至是浏览器内置元素也是有用的。

扩展自定义元素

拓展自定义元素使用继承它的class定义来完成。

例子 - 创建<fancy-app-drawer>继承自<app-drawer>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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);

继承原生HTML元素

假如你想创造一个功能更强大的<button>,用来替代原生<button>的功能和行为。最佳方案就是使用自定义元素去扩展已有HTML元素的功能。

这种继承自HTML元素的自定义元素也被称作定制内置元素(customized built-in element)。它不仅能获得原生元素的特性(属性、方法、可访问性),还能增强元素的功能。没有比使用定制内置元素去编写一个逐步增强的web应用更好的方式了。

注:定制内置元素不是所有浏览器都支持。

例子 - <FancyButton>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 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()需要指定继承了哪个浏览器标签。这是必要的,因为即使是不同的标签也可能继承相同的DOM接口。比如<q><blockquote>就都继承HTMLQuoteElement。

定制内置元素可以为原生标签添加is=””属性。

1
2
3
4
5
6
7
8
9
10
<!-- This <button> is a fancy button. -->
<button is="fancy-button" disabled>Fancy button!</button>

<script>
// 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);
</script>

或者使用new操作符

1
2
3
let button = new FancyButton();
button.textContent = 'Fancy button!';
button.disabled = true;

例子 - 扩展<img>

1
2
3
4
5
6
7
8
9
10
11
<!-- This <img> is a bigger img. -->
<img is="bigger-img" width="15" height="20">

<script>
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'});
</script>

或者创建一个image实例

1
2
3
4
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);

一些细节

未知元素vs未定义元素

HTML是开放、灵活的。比如你声明一个<randomtagthatdoesntexist>标签是不会抛错的。这是因为html规范允许这么做,规范中这个标签会被解析为HTMLUnknownElement。

所以对于自定义元素,不合法的自定义元素命名可能会被解析为HTMLElement(或HTMLUnknownElement)。

API参考

全局customElements上的defines可以用来定义元素。

define(tagName, constructor, options)

例子:

1
2
3
customElements.define('my-app', class extends HTMLElement { ... });
customElements.define(
'fancy-button', class extends HTMLButtonElement { ... }, {extends: 'button'});

get(tagName)

传入一个有效的自定义元素名称,它会返回这个元素的构造函数。如果元素没被注册则返回undefined。

例子

1
2
let Drawer = customElements.get('app-drawer');
let drawer = new Drawer();

whenDefined(tagName)

返回一个Promise,当元素被定义时会执行resolve。如果元素已经被定义,则立即执行resolve。当传入tagName是无效命名时执行reject。

例子:

1
2
3
customElements.whenDefined('app-drawer').then(() => {
console.log('ready!');
});

历史和浏览器支持

历史

Chrome 36+实现了v0版的自定义元素API,使用的是document.registerElement(不是customElements.define)来定义元素。v0这个版本已经弃用了。

目前浏览器供应商们使用的都是现在的v1版,使用customElements.define()来定义元素。

浏览器支持

Chrome 54, Safari 10.1, Firefox 63都实现了v1版,Edg也在开发中。

你可以使用下面代码来判断浏览器是否支持v1版。

1
const supportsCustomElementsV1 = 'customElements' in window;

Polyfill

到浏览器广泛支持之前,v1版可以使用Polyfill。

我们建议使用webcomponents.js loader去实现web components polyfill的最优加载。它使用了特征检测,只有在需要的时候才会异步加载polyfill。

polyfill安装

1
npm install --save @webcomponents/webcomponentsjs

polyfill使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<!-- Use the custom element on the page. -->
<my-element></my-element>

<!-- Load polyfills; note that "loader" will load these async -->
<script src="node_modules/@webcomponents/webcomponentsjs/webcomponents-loader.js" defer></script>

<!-- Load a custom element definitions in `waitFor` and return a promise -->
<script type="module">
function loadScript(src) {
return new Promise(function(resolve, reject) {
const script = document.createElement('script');
script.src = src;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}

WebComponents.waitFor(() => {
// At this point we are guaranteed that all required polyfills have
// loaded, and can use web components APIs.
// Next, load element definitions that call `customElements.define`.
// Note: returning a promise causes the custom elements
// polyfill to wait until all definitions are loaded and then upgrade
// the document in one batch, for better performance.
return loadScript('my-element.js');
});
</script>

注:defined css伪类不能被polyfill。

总结

自定义元素让我们可以定义新的HTML标签,创建可复用的组件。结合其他的特性比如Shadow DOM、<template>等,我们开始逐渐意识到Web Components这个蓝图。

它可以:

  1. 跨浏览器(web标准)创建和扩展可复用组件

  2. 不需要库或框架的开发(强大的JS/HTML)

  3. 熟悉的编程模型(DOM/CSS/HTML)

  4. 和其他新的web平台特性完美融合( (Shadow DOM, <template>, CSS自定义属性等)

  5. 和浏览器DevTools的紧密集成

  6. 使用已有的可访问特性。

本文翻译自

  • Reusable Web Components By Eric Bidelman
    Engineer @ Google working on web tooling: Headless Chrome, Puppeteer, Lighthouse
0%