我们可以创建自定义 HTML 元素,由我们的类描述,具有自己的方法和属性、事件等等。
一旦定义了自定义元素,我们就可以像使用内置 HTML 元素一样使用它。
这很棒,因为 HTML 词典很丰富,但不是无限的。没有<easy-tabs>
、<sliding-carousel>
、<beautiful-upload>
… 只要想想我们可能需要的任何其他标签。
我们可以用一个特殊的类来定义它们,然后像它们一直是 HTML 的一部分一样使用。
自定义元素有两种类型
- 自主自定义元素 – “全新的”元素,扩展抽象
HTMLElement
类。 - 自定义内置元素 – 扩展内置元素,例如基于
HTMLButtonElement
等的自定义按钮。
首先我们将介绍自主元素,然后转向自定义内置元素。
要创建自定义元素,我们需要告诉浏览器关于它的几个细节:如何显示它,当元素被添加到页面或从页面中删除时该做什么等等。
这是通过创建一个带有特殊方法的类来完成的。这很简单,因为只有很少的方法,而且它们都是可选的。
以下是完整列表的草图
class MyElement extends HTMLElement {
constructor() {
super();
// element created
}
connectedCallback() {
// browser calls this method when the element is added to the document
// (can be called many times if an element is repeatedly added/removed)
}
disconnectedCallback() {
// browser calls this method when the element is removed from the document
// (can be called many times if an element is repeatedly added/removed)
}
static get observedAttributes() {
return [/* array of attribute names to monitor for changes */];
}
attributeChangedCallback(name, oldValue, newValue) {
// called when one of attributes listed above is modified
}
adoptedCallback() {
// called when the element is moved to a new document
// (happens in document.adoptNode, very rarely used)
}
// there can be other element methods and properties
}
之后,我们需要注册元素
// let the browser know that <my-element> is served by our new class
customElements.define("my-element", MyElement);
现在,对于任何带有标签<my-element>
的 HTML 元素,都会创建一个MyElement
的实例,并调用上述方法。我们也可以在 JavaScript 中使用document.createElement('my-element')
。
-
自定义元素名称必须包含连字符-
,例如my-element
和super-button
是有效的名称,但myelement
不是。
这是为了确保内置 HTML 元素和自定义 HTML 元素之间没有名称冲突。
示例:“time-formatted”
例如,HTML 中已经存在<time>
元素,用于日期/时间。但它本身不会进行任何格式化。
让我们创建<time-formatted>
元素,它以一种友好的、语言感知的格式显示时间
<script>
class TimeFormatted extends HTMLElement { // (1)
connectedCallback() {
let date = new Date(this.getAttribute('datetime') || Date.now());
this.innerHTML = new Intl.DateTimeFormat("default", {
year: this.getAttribute('year') || undefined,
month: this.getAttribute('month') || undefined,
day: this.getAttribute('day') || undefined,
hour: this.getAttribute('hour') || undefined,
minute: this.getAttribute('minute') || undefined,
second: this.getAttribute('second') || undefined,
timeZoneName: this.getAttribute('time-zone-name') || undefined,
}).format(date);
}
}
customElements.define("time-formatted", TimeFormatted); // (2)
</script>
<!-- (3) -->
<time-formatted datetime="2019-12-01"
year="numeric" month="long" day="numeric"
hour="numeric" minute="numeric" second="numeric"
time-zone-name="short"
></time-formatted>
- 该类只有一个方法
connectedCallback()
- 当<time-formatted>
元素被添加到页面(或当 HTML 解析器检测到它时),浏览器会调用它,它使用内置的Intl.DateTimeFormat数据格式化程序(在所有浏览器中都得到很好的支持)来显示格式良好的时间。 - 我们需要通过
customElements.define(tag, class)
注册我们的新元素。 - 然后我们就可以在任何地方使用它。
如果浏览器在customElements.define
之前遇到任何<time-formatted>
元素,这不是错误。但该元素仍然未知,就像任何非标准标签一样。
这样的“未定义”元素可以使用 CSS 选择器:not(:defined)
进行样式化。
当调用customElement.define
时,它们会被“升级”:为每个元素创建一个新的TimeFormatted
实例,并调用connectedCallback
。它们将变为:defined
。
要获取有关自定义元素的信息,可以使用以下方法
customElements.get(name)
- 返回具有给定name
的自定义元素的类,customElements.whenDefined(name)
- 返回一个承诺,当具有给定name
的自定义元素被定义时,该承诺将解析(无值)。
connectedCallback
中渲染,而不是在constructor
中在上面的示例中,元素内容是在connectedCallback
中渲染(创建)的。
为什么不在constructor
中呢?
原因很简单:当 `constructor` 被调用时,还为时过早。元素已创建,但浏览器在此阶段尚未处理/分配属性:对 `getAttribute` 的调用将返回 `null`。因此我们无法在那里真正渲染。
此外,如果你仔细想想,从性能角度来看,这更好 - 将工作延迟到真正需要的时候。
当元素被添加到文档中时,`connectedCallback` 会触发。不仅仅是作为子元素附加到另一个元素,而是真正成为页面的一部分。因此我们可以构建分离的 DOM,创建元素并为以后使用做好准备。它们只有在进入页面时才会真正渲染。
观察属性
在当前的 `<time-formatted>` 实现中,元素渲染后,进一步的属性更改不会有任何影响。对于 HTML 元素来说,这很奇怪。通常,当我们更改属性时,例如 `a.href`,我们希望更改立即可见。所以让我们修复它。
我们可以通过在 `observedAttributes()` 静态 getter 中提供它们的列表来观察属性。对于这些属性,`attributeChangedCallback` 在它们被修改时被调用。它不会针对其他未列出的属性触发(出于性能原因)。
这是一个新的 `<time-formatted>`,它在属性更改时自动更新
<script>
class TimeFormatted extends HTMLElement {
render() { // (1)
let date = new Date(this.getAttribute('datetime') || Date.now());
this.innerHTML = new Intl.DateTimeFormat("default", {
year: this.getAttribute('year') || undefined,
month: this.getAttribute('month') || undefined,
day: this.getAttribute('day') || undefined,
hour: this.getAttribute('hour') || undefined,
minute: this.getAttribute('minute') || undefined,
second: this.getAttribute('second') || undefined,
timeZoneName: this.getAttribute('time-zone-name') || undefined,
}).format(date);
}
connectedCallback() { // (2)
if (!this.rendered) {
this.render();
this.rendered = true;
}
}
static get observedAttributes() { // (3)
return ['datetime', 'year', 'month', 'day', 'hour', 'minute', 'second', 'time-zone-name'];
}
attributeChangedCallback(name, oldValue, newValue) { // (4)
this.render();
}
}
customElements.define("time-formatted", TimeFormatted);
</script>
<time-formatted id="elem" hour="numeric" minute="numeric" second="numeric"></time-formatted>
<script>
setInterval(() => elem.setAttribute('datetime', new Date()), 1000); // (5)
</script>
- 渲染逻辑已移至 `render()` 辅助方法。
- 当元素插入页面时,我们调用它一次。
- 对于 `observedAttributes()` 中列出的属性的更改,`attributeChangedCallback` 会触发。
- …并重新渲染元素。
- 最后,我们可以轻松地制作一个实时计时器。
渲染顺序
当 HTML 解析器构建 DOM 时,元素一个接一个地处理,父元素在子元素之前。例如,如果我们有 `<outer><inner></inner></outer>`,那么 `<outer>` 元素首先被创建并连接到 DOM,然后是 `<inner>`。
这对自定义元素有重要影响。
例如,如果自定义元素尝试在 `connectedCallback` 中访问 `innerHTML`,它将一无所获
<script>
customElements.define('user-info', class extends HTMLElement {
connectedCallback() {
alert(this.innerHTML); // empty (*)
}
});
</script>
<user-info>John</user-info>
如果你运行它,`alert` 将为空。
这正是因为在这个阶段没有子元素,DOM 未完成。HTML 解析器连接了自定义元素 `<user-info>`,并将继续处理其子元素,但尚未完成。
如果我们想将信息传递给自定义元素,我们可以使用属性。它们立即可用。
或者,如果我们真的需要子元素,我们可以使用零延迟 `setTimeout` 延迟访问它们。
这有效
<script>
customElements.define('user-info', class extends HTMLElement {
connectedCallback() {
setTimeout(() => alert(this.innerHTML)); // John (*)
}
});
</script>
<user-info>John</user-info>
现在,由于我们异步运行它,在 HTML 解析完成后,(*)
行中的 alert
显示“John”。如果需要,我们可以处理子元素并完成初始化。
另一方面,此解决方案也不完美。如果嵌套的自定义元素也使用 setTimeout
来初始化自身,那么它们就会排队:外部 setTimeout
首先触发,然后是内部 setTimeout
。
因此,外部元素在内部元素之前完成初始化。
让我们在示例中演示这一点
<script>
customElements.define('user-info', class extends HTMLElement {
connectedCallback() {
alert(`${this.id} connected.`);
setTimeout(() => alert(`${this.id} initialized.`));
}
});
</script>
<user-info id="outer">
<user-info id="inner"></user-info>
</user-info>
输出顺序
- 外部已连接。
- 内部已连接。
- 外部已初始化。
- 内部已初始化。
我们可以清楚地看到,外部元素在内部元素 (4)
之前完成初始化 (3)
。
没有内置的回调会在嵌套元素就绪后触发。如果需要,我们可以自己实现这样的功能。例如,内部元素可以分派诸如 initialized
之类的事件,而外部元素可以监听并对它们做出反应。
自定义内置元素
我们创建的新元素,例如 <time-formatted>
,没有任何关联的语义。它们对搜索引擎来说是未知的,辅助设备无法处理它们。
但这些东西可能很重要。例如,搜索引擎会想知道我们实际上显示的是时间。如果我们正在制作一种特殊的按钮,为什么不重用现有的 <button>
功能呢?
我们可以通过继承它们的类来扩展和自定义内置 HTML 元素。
例如,按钮是 HTMLButtonElement
的实例,让我们以此为基础。
-
使用我们的类扩展
HTMLButtonElement
class HelloButton extends HTMLButtonElement { /* custom element methods */ }
-
为
customElements.define
提供第三个参数,该参数指定标签customElements.define('hello-button', HelloButton, {extends: 'button'});
可能存在多个共享相同 DOM 类的标签,这就是为什么需要指定
extends
的原因。 -
最后,要使用我们的自定义元素,请插入一个常规的
<button>
标签,但向其中添加is="hello-button"
<button is="hello-button">...</button>
这是一个完整的示例
<script>
// The button that says "hello" on click
class HelloButton extends HTMLButtonElement {
constructor() {
super();
this.addEventListener('click', () => alert("Hello!"));
}
}
customElements.define('hello-button', HelloButton, {extends: 'button'});
</script>
<button is="hello-button">Click me</button>
<button is="hello-button" disabled>Disabled</button>
我们的新按钮扩展了内置按钮。因此它保留了相同的样式和标准功能,例如 disabled
属性。
参考资料
- HTML Living Standard: https://html.whatwg.com.cn/#custom-elements.
- 兼容性: https://caniuse.cn/#feat=custom-elementsv1.
总结
自定义元素可以分为两种类型
-
“自治” - 新标签,扩展
HTMLElement
。定义方案
class MyElement extends HTMLElement { constructor() { super(); /* ... */ } connectedCallback() { /* ... */ } disconnectedCallback() { /* ... */ } static get observedAttributes() { return [/* ... */]; } attributeChangedCallback(name, oldValue, newValue) { /* ... */ } adoptedCallback() { /* ... */ } } customElements.define('my-element', MyElement); /* <my-element> */
-
“自定义内置元素” - 现有元素的扩展。
需要一个额外的
.define
参数,以及 HTML 中的is="..."
class MyButton extends HTMLButtonElement { /*...*/ } customElements.define('my-button', MyElement, {extends: 'button'}); /* <button is="my-button"> */
自定义元素在浏览器中得到很好的支持。有一个 polyfill https://github.com/webcomponents/polyfills/tree/master/packages/webcomponentsjs.
评论
<code>
标签,对于多行代码,请用<pre>
标签包裹,对于超过 10 行的代码,请使用沙盒(plnkr,jsbin,codepen…)