2021 年 3 月 26 日

自定义元素

我们可以创建自定义 HTML 元素,由我们的类描述,具有自己的方法和属性、事件等等。

一旦定义了自定义元素,我们就可以像使用内置 HTML 元素一样使用它。

这很棒,因为 HTML 词典很丰富,但不是无限的。没有<easy-tabs><sliding-carousel><beautiful-upload>… 只要想想我们可能需要的任何其他标签。

我们可以用一个特殊的类来定义它们,然后像它们一直是 HTML 的一部分一样使用。

自定义元素有两种类型

  1. 自主自定义元素 – “全新的”元素,扩展抽象HTMLElement类。
  2. 自定义内置元素 – 扩展内置元素,例如基于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-elementsuper-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>
  1. 该类只有一个方法connectedCallback() - 当<time-formatted>元素被添加到页面(或当 HTML 解析器检测到它时),浏览器会调用它,它使用内置的Intl.DateTimeFormat数据格式化程序(在所有浏览器中都得到很好的支持)来显示格式良好的时间。
  2. 我们需要通过customElements.define(tag, class)注册我们的新元素。
  3. 然后我们就可以在任何地方使用它。
自定义元素升级

如果浏览器在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>
  1. 渲染逻辑已移至 `render()` 辅助方法。
  2. 当元素插入页面时,我们调用它一次。
  3. 对于 `observedAttributes()` 中列出的属性的更改,`attributeChangedCallback` 会触发。
  4. …并重新渲染元素。
  5. 最后,我们可以轻松地制作一个实时计时器。

渲染顺序

当 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>

输出顺序

  1. 外部已连接。
  2. 内部已连接。
  3. 外部已初始化。
  4. 内部已初始化。

我们可以清楚地看到,外部元素在内部元素 (4) 之前完成初始化 (3)

没有内置的回调会在嵌套元素就绪后触发。如果需要,我们可以自己实现这样的功能。例如,内部元素可以分派诸如 initialized 之类的事件,而外部元素可以监听并对它们做出反应。

自定义内置元素

我们创建的新元素,例如 <time-formatted>,没有任何关联的语义。它们对搜索引擎来说是未知的,辅助设备无法处理它们。

但这些东西可能很重要。例如,搜索引擎会想知道我们实际上显示的是时间。如果我们正在制作一种特殊的按钮,为什么不重用现有的 <button> 功能呢?

我们可以通过继承它们的类来扩展和自定义内置 HTML 元素。

例如,按钮是 HTMLButtonElement 的实例,让我们以此为基础。

  1. 使用我们的类扩展 HTMLButtonElement

    class HelloButton extends HTMLButtonElement { /* custom element methods */ }
  2. customElements.define 提供第三个参数,该参数指定标签

    customElements.define('hello-button', HelloButton, {extends: 'button'});

    可能存在多个共享相同 DOM 类的标签,这就是为什么需要指定 extends 的原因。

  3. 最后,要使用我们的自定义元素,请插入一个常规的 <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 属性。

参考资料

总结

自定义元素可以分为两种类型

  1. “自治” - 新标签,扩展 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> */
  2. “自定义内置元素” - 现有元素的扩展。

    需要一个额外的 .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.

任务

我们已经有了<time-formatted> 元素来显示格式化的日期时间。

创建<live-timer> 元素来显示当前时间

  1. 它应该在内部使用<time-formatted>,而不是重复其功能。
  2. 每秒更新一次。
  3. 对于每次更新,应该生成一个名为tick 的自定义事件,并将当前日期保存在event.detail 中(参见章节 派发自定义事件)。

用法

<live-timer id="elem"></live-timer>

<script>
  elem.addEventListener('tick', event => console.log(event.detail));
</script>

演示

打开任务的沙盒。

请注意

  1. 当元素从文档中移除时,我们清除setInterval 计时器。这很重要,否则它会继续计时,即使不再需要了。浏览器也无法从这个元素和它引用的内存中清除。
  2. 我们可以通过elem.date 属性访问当前日期。所有类方法和属性都是元素方法和属性。

在沙盒中打开解决方案。

教程地图

评论

在评论之前阅读…
  • 如果您有改进建议,请提交 GitHub 问题或拉取请求,而不是评论。
  • 如果您不理解文章中的某些内容,请详细说明。
  • 要插入几行代码,请使用<code> 标签,对于多行代码,请用<pre> 标签包裹,对于超过 10 行的代码,请使用沙盒(plnkrjsbincodepen…)