【Web Components】渐进式进阶教程一

1,096 阅读3分钟

前言

关于本文

本篇文章作为上一篇「渐进式入门教程」的延续,带你学习 Web Components 中一些进阶的知识。

关于专栏

「Web Components 掘金专栏」 将带你从 0 到 1 的学习 Web Components 的概览和技术中每个常用的 API,以及如何基于第三方工程化方案来更简便的编写 Web Components 代码!

前景提要

先来回顾一下本专栏上一篇「渐进式入门教程」中最后的代码,本文将用新学的进阶知识继续在该代码结构上进行删减、添加、优化。

忘记的同学可以先再完整回顾一下上一篇文章~

// 定义模板
const template = document.createElement('template');
template.innerHTML = `
  <!-- 私有样式 -->
  <style>
    :host {
      width: 100%;
      display: flex;
      justify-content: center;
    }
    .button {
      color: #fff;
      background-color: #409eff;
      border: 1px solid #409eff;
      border-radius: 4px;
      padding: 12px 20px;
      font-size: 14px;
    }
  </style>
  <!-- 内容 -->
  <button class="button">默认按钮</button>
`;

class CustomButton extends HTMLElement {
  // 构造方法
  constructor() {
    // 调用父类的构造函数
    super();

    // 获取内容
    const content = template.content.cloneNode(true);
    const button = content.querySelector('.button');

    // 获取参数
    button.innerText = this.getAttribute('text');

    // 监听事件
    let num = 0;
    button.addEventListener('click', () => {
      button.innerText = `按钮被点击了 ${++num} 次`;
    });

    // 自定义元素调用 attachShadow() 方法开启 Shadow DOM
    const shadow = this.attachShadow({ mode: 'closed' });

    // shadow 表示影子 DOM
    shadow.append(content);
  }
}

window.customElements.define('custom-button', CustomButton);

自定义元素

两种自定义元素

  • Autonomous custom elements(自主自定义元素)
  • Customized built-in elements(自定义内建元素)

语法

其实自定义元素有两种,之前我们代码中一直用的写法是

class CustomButton extends HTMLElement {
  //...
}

代表继承自抽象类 HTMLElement。这就是其中一种,名叫 Autonomous custom elements(自主自定义元素)

另一种是 Customized built-in elements(自定义内建元素),需要继承自内建元素的类。比如我们要实现的 CustomButton 对应的内建元素就是按钮 <button>,然后对应的类就是 HTMLButtonElement

class CustomButton extends HTMLButtonElement {
  //...
}

值得关注的是,使用「自定义内建元素」,会对搜索引擎和无障碍设备非常友好。

更多的细节在下面的代码中演示,我们来完整的实现一遍:

  1. 继承 HTMLButtonElement
  2. customElements.define() 方法传第三个参数
  3. 使用 is 属性

HTML

<!-- 对普通 button 元素使用 is 属性 -->
<button is="custom-button">点我试试</button>
<button is="custom-button" disabled>禁用按钮</button>

JS

// 继承 HTMLButtonElement 类
class CustomButton extends HTMLButtonElement {
  // 构造方法
  constructor() {
    // 调用父类的构造函数
    super();

    // 监听事件
    let num = 0;
    this.addEventListener('click', () => {
      this.innerText = `按钮被点击了 ${++num} 次`;
    });

    // this 表示自定义元素实例
    this.append();
  }
}

// 给 customElements.define() 方法传第三个参数
window.customElements.define('custom-button', CustomButton, { extends: 'button' });

我们自定义按钮类 CustomButton 继承了内建按钮类 HTMLButtonElement,所以它拥有和内建按钮相同的样式和标准特性,比如 disabled 属性,在上面代码中也有所演示。

在线预览

生命周期

几个常见的生命周期:

  • constructor - 创建元素时
  • connectedCallback - 元素被挂载到 DOM 之后
  • disconnectedCallback - 元素被移除 DOM 时
  • adoptedCallback - 元素被移动到新的 DOM 时

语法

使用方法非常简单,就是在类中按需添加这几个方法即可:

// 定义模板
const template = document.createElement('template');
template.innerHTML = `
  <!-- 私有样式 -->
  <style>
    :host {
      width: 100%;
      display: flex;
      justify-content: center;
    }
    .button {
      color: #fff;
      background-color: #409eff;
      border: 1px solid #409eff;
      border-radius: 4px;
      padding: 12px 20px;
      font-size: 14px;
    }
  </style>
  <!-- 内容 -->
  <button class="button">默认按钮</button>
`;

class CustomButton extends HTMLElement {
  // 构造方法
  constructor() {
    console.log('生命周期 - constructor');

    // 调用父类的构造函数
    super();

    // 获取内容
    const content = template.content.cloneNode(true);
    const button = content.querySelector('.button');

    // 获取参数
    button.innerText = this.getAttribute('text');

    // 监听事件
    let num = 0;
    button.addEventListener('click', () => {
      button.innerText = `按钮被点击了 ${++num} 次`;
    });

    // 自定义元素调用 attachShadow() 方法开启 Shadow DOM
    const shadow = this.attachShadow({ mode: 'closed' });

    // shadow 表示影子 DOM
    shadow.append(content);
  }

  // 元素被挂载到 DOM 之后
  connectedCallback() {
    console.log('生命周期 - connectedCallback');
  }

  // 元素被移除 DOM 时
  disconnectedCallback() {
    console.log('生命周期 - disconnectedCallback');
  }

  // 元素被移动到新的 DOM 时
  adoptedCallback() {
    console.log('生命周期 - adoptedCallback');
  }
}

window.customElements.define('custom-button', CustomButton);

上述代码的打印结果和触发顺序是:

  1. "生命周期 - constructor"
  2. "生命周期 - connectedCallback"

正确的渲染时机

注意到,之前我们的渲染逻辑是放在 constructor 方法中的,这是一种相对不正确的方式。我们最好是将渲染逻辑放在元素被挂载到 DOM 之后,也就是 connectedCallback 生命周期方法中。

这样能够避免有些时候 getAttribute 返回 null 的情况发生。例如,当我们使用 JS 命令式创建元素的时候 document.createElement('custom-button'),或者是 <script> 脚本比 DOM 先加载的时候。

// 定义模板
const template = document.createElement('template');
template.innerHTML = `
  <!-- 私有样式 -->
  <style>
    :host {
      width: 100%;
      display: flex;
      justify-content: center;
    }
    .button {
      color: #fff;
      background-color: #409eff;
      border: 1px solid #409eff;
      border-radius: 4px;
      padding: 12px 20px;
      font-size: 14px;
    }
  </style>
  <!-- 内容 -->
  <button class="button">默认按钮</button>
`;

class CustomButton extends HTMLElement {
  // 构造方法
  constructor() {
    console.log('生命周期 - constructor');

    // 调用父类的构造函数
    super();

    // 获取内容
    this.$content = template.content.cloneNode(true);
    this.$button = this.$content.querySelector('.button');

    // 监听事件
    let num = 0;
    this.$button.addEventListener('click', () => {
      this.$button.innerText = `按钮被点击了 ${++num} 次`;
    });

    // 自定义元素调用 attachShadow() 方法开启 Shadow DOM
    this._shadowRoot = this.attachShadow({ mode: 'closed' });

    // _shadowRoot 表示影子 DOM
    this._shadowRoot.append(this.$content);
  }

  // 元素被挂载到 DOM 之后
  connectedCallback() {
    console.log('生命周期 - connectedCallback');
    this.render();
  }

  // 元素被移除 DOM 时
  disconnectedCallback() {
    console.log('生命周期 - disconnectedCallback');
  }

  // 元素被移动到新的 DOM 时
  adoptedCallback() {
    console.log('生命周期 - adoptedCallback');
  }

  // 渲染函数
  render() {
    // 获取参数
    this.$button.innerText = this.getAttribute('text');
  }
}

window.customElements.define('custom-button', CustomButton);

在线预览

自定义元素升级

解释

customElements.define() 语句被执行之前,元素是属于「未定义」状态,如果浏览器解析 DOM 时遇见了对应的元素,此时是不会有任何报错的,因为浏览器会把这个元素当作未知元素,就像任何非标准标签一样。

用一个定时器延迟一下 define 的时机就可以验证了:

// 延迟定义
setTimeout(() => {
  window.customElements.define('custom-button', CustomButton);
}, 1000)

对于「未定义」和「已定义」的元素,都有对应的 CSS 伪类可以设置其样式::not(:defined) 作用于未定义元素,:defined 作用于定义好的元素。

/* 给「未定义」的自定义元素添加样式 */
custom-button:not(:defined) {
  color: #fff;
  background-color: #e6a23c;
}

/* 给「已定义」的自定义元素添加样式 */
custom-button:defined {
  display: block;
}

当自定义元素从「未定义」变为「已定义」时,也就是 customElements.define() 语句被执行时,被称为「升级」。

「升级」是可以被订阅到的,只需要用到 customElements.whenDefined() 方法即可:

// 订阅状态是否变为「已定义」
customElements.whenDefined('custom-button').then(() => {
  console.log('状态变为已定义');
  const customButtonClass = customElements.get('custom-button');
  console.log('custom-button 元素对应的类', customButtonClass);
})

该方法返回一个 promise,所以我们传递一个回调给 then 方法即可。

代码中还出现了一个 customElements.get() 方法,该方法返回指定自定义元素的类。

在线预览

点击「运行」按钮体验「升级」过程

侦听器

侦听属性(参数)的变化

语法

observedAttributes 是一个静态的 getter,它允许我们侦听多个属性:

// 侦听器
static get observedAttributes() {
  // 将需要侦听的属性放进数组里
  return ['text'];
}

被侦听的属性变化后,会触发 attributeChangedCallback 回调,它的三个回调参数分别是属性名称、旧值、新值:

// 侦听器回调
attributeChangedCallback(name, oldValue, newValue) {
  //...
}

值得注意的是,侦听器回调 attributeChangedCallback 虽然不是立即触发的,但首次触发时机是从属性为 null 开始计算起的。也就是说,如果我们默认不设置该属性,那么回调不会被触发;如果我们默认给属性传递了一个值,那么回调会被立即触发,并且要早于生命周期 connectedCallback

完整代码如下:

HTML

<custom-button id="custom-button" text="侦听器"></custom-button>

JS

// 定义模板
const template = document.createElement('template');
template.innerHTML = `
  <!-- 私有样式 -->
  <style>
    :host {
      width: 100%;
      display: flex;
      justify-content: center;
    }
    .button {
      color: #fff;
      background-color: #409eff;
      border: 1px solid #409eff;
      border-radius: 4px;
      padding: 12px 20px;
      font-size: 14px;
    }
  </style>
  <!-- 内容 -->
  <button class="button">默认按钮</button>
`;

class CustomButton extends HTMLElement {
  // 构造方法
  constructor() {
    // 调用父类的构造函数
    super();

    // 获取内容
    this.$content = template.content.cloneNode(true);
    this.$button = this.$content.querySelector('.button');

    // 自定义元素调用 attachShadow() 方法开启 Shadow DOM
    this._shadowRoot = this.attachShadow({ mode: 'closed' });

    // _shadowRoot 表示影子 DOM
    this._shadowRoot.append(this.$content);
  }

  // 元素被挂载到 DOM 之后
  connectedCallback() {
    if (!this.rendered) {
      this.render();
      this.rendered = true;
    }
  }

  // 侦听器
  static get observedAttributes() {
    // 将需要侦听的属性放进数组里
    return ['text'];
  }

  // 侦听器回调
  attributeChangedCallback(name, oldValue, newValue) {
    this.render();
  }

  // 渲染函数
  render() {
    // 获取参数
    this.$button.innerText = this.getAttribute('text');
  }
}

window.customElements.define('custom-button', CustomButton);

// 修改参数
let num = 0;
const customButtonElem = document.getElementById('custom-button');
setInterval(() => {
  customButtonElem.setAttribute('text', `text 属性自动更新了 ${++num} 次`);
}, 1000);

上述代码有一个小小的细节优化:

// 元素被挂载到 DOM 之后
connectedCallback() {
  if (!this.rendered) {
    this.render();
    this.rendered = true;
  }
}

增加一个 if (!this.rendered) 的判断条件,当在 connectedCallback 生命周期被多次调用时(如果一个元素被反复添加到文档/移除文档),不必重复执行 render 方法。

在线预览

优化

上面的侦听器代码还不够完美,例如我们将修改属性的方式从 customButtonElem.setAttribute() 改为 customButtonElem.text = ··· 就侦听不到属性的变化了。

我们必须兼容这种情况。

需要新增这样一个 setter 方法:

set text(value) {
  this.setAttribute('text', value);
}

这个过程我们称为「reflecting properties to attributes

有了 setter 方法后,习惯上我们也会同步写一个 getter 方法:

get text() {
  return this.getAttribute('text');
}

然后将 render 中的代码修改为这样即可:

// 渲染函数
render() {
  // 获取参数
  // this.$button.innerText = this.getAttribute('text');
  this.$button.innerText = this.text;
}

完整代码:

// 定义模板
const template = document.createElement('template');
template.innerHTML = `
  <!-- 私有样式 -->
  <style>
    :host {
      width: 100%;
      display: flex;
      justify-content: center;
    }
    .button {
      color: #fff;
      background-color: #409eff;
      border: 1px solid #409eff;
      border-radius: 4px;
      padding: 12px 20px;
      font-size: 14px;
    }
  </style>
  <!-- 内容 -->
  <button class="button">默认按钮</button>
`;

class CustomButton extends HTMLElement {
  // 构造方法
  constructor() {
    // 调用父类的构造函数
    super();

    // 获取内容
    this.$content = template.content.cloneNode(true);
    this.$button = this.$content.querySelector('.button');

    // 自定义元素调用 attachShadow() 方法开启 Shadow DOM
    this._shadowRoot = this.attachShadow({ mode: 'closed' });

    // _shadowRoot 表示影子 DOM
    this._shadowRoot.append(this.$content);
  }

  // 元素被挂载到 DOM 之后
  connectedCallback() {
    if (!this.rendered) {
      this.render();
      this.rendered = true;
    }
  }

  // 侦听器
  static get observedAttributes() {
    // 将需要侦听的属性放进数组里
    return ['text'];
  }

  // 侦听器回调
  attributeChangedCallback(name, oldValue, newValue) {
    this.render();
  }

  // 渲染函数
  render() {
    // 获取参数
    // this.$button.innerText = this.getAttribute('text');
    this.$button.innerText = this.text;
  }

  get text() {
    return this.getAttribute('text');
  }

  set text(value) {
    this.setAttribute('text', value);
  }
}

window.customElements.define('custom-button', CustomButton);

// 修改参数
let num = 0;
const customButtonElem = document.getElementById('custom-button');
setInterval(() => {
  // 修改方式 1
  // customButtonElem.setAttribute('text', `text 属性自动更新了 ${++num} 次`);
  // 修改方式 2
  customButtonElem.text = `text 属性自动更新了 ${++num} 次`;
}, 1000);

在线预览

父子组件渲染顺序

解释

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

例如下面代码,会先处理并挂载 <custom-button> 元素,再处理并挂载「子元素」。

<custom-button text="渲染顺序">子元素</custom-button>

这个特性非常重要,因为父元素想在自己挂载后立即访问 innerHTML,是可能什么都拿不到的(比如 <script> 脚本比 DOM 先加载的时候):

// 元素被挂载到 DOM 之后
connectedCallback() {
  this.render();
}

// 渲染函数
render() {
  // 获取参数可行
  // this.$button.innerText = this.getAttribute('text');
  // 获取 innerHTML 不一定有值
  this.$button.innerText = this.innerHTML;
}

当遇见 innerHTML 拿不到值的情况时,比较简单的解决办法就是添加一个定时器 setTimeout

setTimeout(() => {
  this.$button.innerText = this.innerHTML;
}, 0)

参考

部分内容参考以下文章 & 讨论:

End

最最最后,要不要考虑点个关注?嗯!?