目录

🍙 Custom Elements 自定义标签

可以通过描述带有 自己的方法、属性、事件等的类 创建自定义标签。自定义标签创建之后,可以与 原生 HTML 内建一同使用。

虽然 HTML 内建的标签有很多,但有时未必能满足开发的要求。

有两种类型的自定义标签:

  • 自主自定义标签 / Autonomous custom element ,继承自 HTMLElement 的抽象类;
  • 自定义内建元素 / Customized built-in element:继承自 HTML 的元素,例如可以自定义 HTMLButton Element。

# 创建自定义标签

创建自定义标签时,需要告诉浏览器一些细节:如何展示、添加元素到页面和将其从页面移除时要做什么。

可以通过创建带有几个特殊方法的类实现:

class MyElement extends HTMLElement {
  constructor() {
    super(); 
    // 在此创建元素
  }
  
  connectedCallback() {
    // 元素被添加到文档之后,浏览器调用这个方法
    // 如果反复被添加/移除,那么这个方法会反复被调用
  }
  
  disconnectedCallback() {
    // 元素被添加到文档之后,浏览器调用这个方法
    // 如果反复被添加/移除,那么这个方法会反复被调用
  }
  
  static get observedAttributes() {
    return [/*被监视的属性,发生变化时返回*/]
  }
  
  attributeChangedCallback(name, oldValue, newValue) {
    // 当上面数组中的属性发生变化的时候,这个方法会被调用
  }

  adoptedCallback() {
    // 在元素被移动到新的文档的时候,这个方法会被调用
    // (document.adoptNode 会用到, 非常少见)
  }
}
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

声明上面的方法之后,需要注册元素

customElements.define("my-element", MyElement)
1

此时,浏览器知道这个元素是 MyElement 类服务的。任何带有 <my-element> 标签的元素被创建的时候,一个 MyElement 的实例也会被创建,并且前面提到的方法也会被调用。

同样可以使用 document.createElement('my-element') 在 JavaScript 里创建元素。

提示

命名相关问题

  • 自定义元素标签的名称必须包括一个 -
  • 这样确保了不会与内建的元素标签发生冲突。

# 🌰 例子 / 自定义格式化时间元素标签

原生 HTML 标签中 <time> 可以显示 日期和时间。但是不会进行任何的格式化处理。

创建一个可以适用当前浏览器语言的时间格式的 <time-formatted> 元素。

JavaScript 部分:

class TimeFormatted extends HTMLElement {
  connectedCallback() {
    let date = new Date(this.getAttribute('datetime') || Date.now())
    
    this.innerHTML = new Intl.DateTimeFormat("default", {
      year: this.getAttribute('yaer') || 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)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

HTML:

<time-formatted
  year="numeric" month="long" day="numeric"
  hour="numeric" minute="numeric" second="numeric"
  time-zone-name="short"
></time-formatted>
1
2
3
4
5

connectedCallback() :在元素被添加到页面时(或者 HTML 解析器检测到这个元素),调用这个方法。使用内建的时间日期格式化工具 Intl.DateTimeFormat()

自定义元素的升级:

  • 如果浏览器在 customElements.define 之前的任何地方见到了 <time-formatted> 元素,并不会报错。但会把这个元素当作未知元素,就像任何非标准标签一样。

  • :not(:defined) CSS 选择器可以对这样「未定义」的元素加上样式。

  • customElement.define 被调用的时候,它们被「升级」了:一个新的 TimeFormatted 元素为每一个标签创建了,并且 connectedCallback 被调用。它们变成了 :defined

可以通过这些方法来获取更多的自定义标签的信息:

  • customElements.get(name) :返回指定 custom element name 的类。
  • customElements.whenDefined(name) :返回一个 promise,将会在这个具有给定 name 的自定义元素变为已定义状态的时候 resolve(不带值)。

注意元素的内容是在 connectedCallback 中渲染(创建)的。

  • 如果在 constructor 被调用的时候渲染,为时过早。因为虽然这个元素实例被创建,但是还没插入页面,浏览器还不能处理 / 创建元素属性,此时获取特性 getAttribute() 会得到 null
  • connectedCallback 有利于性能。这个元素不仅仅是被添加为了另一个元素的子元素,同样也成为了页面的一部分。因此可以构建分离的 DOM,创建元素并且让它们为之后的使用准备好。它们只有在插入页面的时候才会真的被渲染

为了让这个时间格式化组件的属性随着当前的时间发生变化,可以使用 监视属性 observedAttributes() 方法,当属性发生变化时 attributeChangedCallback 方法调用,并且只有指定的属性发生变化才会调用,优化性能。

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() {
    if(!this.rendered) {
      this.render();
      this.redered = true;
    }
  }
  
  static get observedAttributes() {
    return ['datetime', 'year', 'month', 'day', 'hour', 'minute', 'second', 'time-zone-name'];
  }

	attributeChangedCallback(name, oldValue, newValue) { 
    this.render();
  }
}

customElements.define("time-formatted", TimeFormatted);
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

创建元素:

<time-formatted
  id="elem"
  year="numeric" month="long" day="numeric"
  hour="numeric" minute="numeric" second="numeric"
  time-zone-name="short"
></time-formatted>
1
2
3
4
5
6

调用定时器,每秒渲染:

<script>
setInterval(() => elem.setAttribute('datetime', new Date()), 1000); // (5)
</script>
1
2
3
  • 将渲染的逻辑移动到 render() 方法中。这个方法在元素被插入到页面的时候调用。
  • attributeChangedCallbackobservedAttributes() 里的属性改变的时候被调用。在属性改变时重新渲染。
  • 计时器修改 datetime 会触发重新渲染。

# 自定义标签元素的渲染顺序

在 HTML 解析器构建 DOM 的时候,会按照先后顺序处理元素先处理父级元素再处理子元素

🌰 例子:如果有 <outer><inner></inner></outer> ,那么 <outer> 元素会首先被创建并接入到 DOM,然后才是 <inner>

这个渲染顺序对 自定义标签元素的渲染顺序 产生了影响。

🌰 例子 / 如果想要在 connectedCallback 中访问 this.innerHTML ,什么也获取不到。因为此时子元素还不存在, DOM 还没有完成构建, HMTL 解析器会先创建 <user-info> 然后再处理子元素,而子元素还没加载。

<script>
customElements.define('user-info', class extends HTMLElement {
  connectedCallback() {
    alert(this.innerHTML); 
  }
});
</script>

<user-info>John</user-info>
1
2
3
4
5
6
7
8
9

如果要给自定义标签元素 传入信息,可以使用 元素属性它们是即时生效的。或者,如果需要子元素,可以使用延迟时间为零的 setTimeout推迟访问子元素

🌰 例子:

<script>
customElements.define('user-info', class extends HTMLElement {
  connectedCallback() {
    setTimeout(() => alert(this.innerHTML)); // John (*)
  }
});
</script>

<user-info>John</user-info>
1
2
3
4
5
6
7
8
9

现在可以访问子元素的内容了。因为是在 HTML 解析完成之后,才异步执行这段程序,可以在这里处理必要的子元素并且结束初始化过程。

但是这个方法不是完美的。如果嵌套的 自定义标签元素同样使用了 setTimeout 初始化自身,那么它们会 按照先后顺序执行:外层的 setTimeout 首先触发,然后才是内层的。这样外层元素还是早于内层元素结束初始化

🌰 例子:

<script>
customElements.define('user-info', class extends HTMLElement {
  connectedCallback() {
    alert(`${this.id} 已连接。`);
    setTimeout(() => alert(`${this.id} 初始化完成。`));
  }
});
</script>

<user-info id="outer">
  <user-info id="inner"></user-info>
</user-info>
1
2
3
4
5
6
7
8
9
10
11
12

可以看到,外层的元素并没有等待内层的元素

并没有内建的回调方法可以在嵌套元素渲染完成之后触发的事件。如果要实现这样的回调。比如,内层元素可以分派像 initialized 这样的事件,同时外层的元素监听这样的事件并做出响应。

# 自定义内建的标签元素

上面一种的自定义标签元素可能无法被搜索引擎识别或者无障碍设备处理。

复用继承已有的内建元素的类:

🌰 例子 / 按钮 HTMLButtonElement

class HelloButton extends HTMLButtonElement { /* custom element 方法 */ }

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

createElements.define 时要使用第三个参数。保证 hello-button 标签共享的类是 button

插入一个普通的 <button> 标签, 添加 is="hello-button" 特性:

<button is="hello-button">...</button>
1

完整的例子:

<script>
// 这个按钮在被点击的时候说 "hello"
class HelloButton extends HTMLButtonElement {
  constructor() {
    super();
    this.addEventListener('click', () => alert("Hello!"));
  }
}
</script>

<button is="hello-button">Click me</button>

<button is="hello-button" disabled>Disabled</button>
1
2
3
4
5
6
7
8
9
10
11
12
13

新定义的按钮继承了内建按钮,所以它拥有和内建按钮相同的样式和标准特性,比如 disabled 属性。

📢 上次更新: 2022/09/02, 10:18:16