欢迎关注微信公众号:前端阅读室
Web Components可以让开发者创造标签
,开发者可以增强现有HTML标签,扩展其他开发者编写的标签。
它提供了基于web标准的方式,可以让你用少量的、模块化的代码去编写可重用的组件。
定义一个新元素
使用window.customElements.define可以定义一个新元素。
它的第一个参数是标签名,第二个参数是一个继承HTMLElement的类。
class AppDrawer extends HTMLElement {}
window.customElements.define('app-drawer', AppDrawer);
复制代码
那么如何使用它呢?你只要像使用正常的html标签一样使用它就可以了!
<!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。
例子:
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’))等等。
自定义元素命名规则
-
必须包含破折号(-),这是为了区分自定义元素和HTML元素,并且确保兼容性(即使HTML增加新标签也不会导致冲突)
-
不能重复注册相同标签,否则会抛DOMException,因为这完全没必要。
-
不支持自闭合标签的写法,因为在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))) |
例子:
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中执行
div.id = 'my-id';
div.hidden = true;
复制代码
html属性就会变为
<div id="my-id" hidden>
复制代码
这是很有用的功能。比如你想实现样式根据JS状态改变。
在下面这个例子中我们可以通过点击切换app-drawer元素的透明度,并为它设置一些属性。
<!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()
方法来监听元素在什么时候被定义。
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>
<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创建一个元素
customElements.define('x-foo-with-markup', class extends HTMLElement {
connectedCallback() {
this.innerHTML = "<b>I'm an x-foo-with-markup!</b>";
}
...
});
复制代码
网页上会呈现为
<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了。
<!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>
复制代码
网页呈现结果
<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元素
<template id="x-foo-from-template">
<style>
p { color: green; }
</style>
<p>I'm in Shadow DOM. My markup was stamped from a <template>.</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>
复制代码
这个例子中有几个关键的知识点:
-
定义了一个新标签
<x-foo-from-template>
-
使用template创建Shadow DOM
-
由于Shadow DOM的存在,元素DOM是内置的
-
由于Shadow DOM的存在,元素的CSS也是内置的,并且样式作用限定在了元素的内部
给自定义元素加样式
即使你的元素使用Shadow DOM限定了样式,元素的样式也会受页面样式的影响。
页面的样式也被称为用户定义样式(user-defined styles)。如果它和Shadow DOM有相同的样式,则用户自定义样式生效。
给未定义的元素设置样式
元素未定义(升级)之前,你可以使用:not(:defined)伪类为它设置样式。
这种预设样式也是有用的,比如你可以让元素占有一定布局空间,即使它还没被定义。
下面这个例子,元素在被定义之前也占据一定空间。当然了,当元素被定义后,app-drawer:not(:defined)选择器也就失效了。
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>
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>
// 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=””属性。
<!-- 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操作符
let button = new FancyButton();
button.textContent = 'Fancy button!';
button.disabled = true;
复制代码
例子 – 扩展<img>
<!-- 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实例
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)
例子:
customElements.define('my-app', class extends HTMLElement { ... });
customElements.define(
'fancy-button', class extends HTMLButtonElement { ... }, {extends: 'button'});
复制代码
get(tagName)
传入一个有效的自定义元素名称,它会返回这个元素的构造函数。如果元素没被注册则返回undefined。
例子
let Drawer = customElements.get('app-drawer');
let drawer = new Drawer();
复制代码
whenDefined(tagName)
返回一个Promise,当元素被定义时会执行resolve。如果元素已经被定义,则立即执行resolve。当传入tagName是无效命名时执行reject。
例子:
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版。
const supportsCustomElementsV1 = 'customElements' in window;
复制代码
Polyfill
到浏览器广泛支持之前,v1版可以使用Polyfill。
我们建议使用webcomponents.js loader去实现web components polyfill的最优加载。它使用了特征检测,只有在需要的时候才会异步加载polyfill。
polyfill安装
npm install --save @webcomponents/webcomponentsjs
复制代码
polyfill使用
<!-- 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这个蓝图。
它可以:
-
跨浏览器(web标准)创建和扩展可复用组件
-
不需要库或框架的开发(强大的JS/HTML)
-
熟悉的编程模型(DOM/CSS/HTML)
-
和其他新的web平台特性完美融合( (Shadow DOM,
<template>
, CSS自定义属性等) -
和浏览器DevTools的紧密集成
-
使用已有的可访问特性。
本文翻译自
- Reusable Web Components By Eric Bidelman
Engineer @ Google working on web tooling: Headless Chrome, Puppeteer, Lighthouse
欢迎关注微信公众号:前端阅读室