Web Component入门教程

246 阅读11分钟

组件已经成为了页面组成的基本单元。日常开发中,我们会将一个完整的页面拆分为若干个组件,这些组件又会由一些更小的基础组件组成。之所以这么做是为了提升代码的可维护性以及可复用性,通常都是使用MVVM自带的组件功能进行封装。

有时不同产品间会使用不同的MVVM框架,有的使用Vue,有的使用React。即时是同一个框架不同的版本也可能无法兼容,例如Vue2和Vue3,这就导致了原本的组件库无法在不同技术栈的项目中使用,因为他们的组件规范都是由所用框架定义的,并不能通用。Web框架更新迭代速度很快,新框架层出不穷,几乎应用每种技术栈都需要重新封装一套组件库。

这个时候我们就需要开发一套与框架无关的通用组件库,而Web Component提供了原生的组件支持,无需依赖任何框架,很好的解决了这个问题。随着越来越多的产品开始逐渐放弃对老旧浏览器的兼容,Web Component的使用也越来越普及。

Web Component提供一套封装可复用内容的功能,也就是组件。它由三项主要技术组成:自定义元素、Shadow Dom、HTML模版。接下来我们详细介绍这三项技术。

自定义元素(Custom Element)

自定义标签允许用户定义任意标签,例如可以定义一个 <custom-button></custom-button> 标签。在使用前,我们必须先注册,浏览器才能正确识别:

class CustomButton extends HTMLElement {
	constructor() {
    super();
  }

  connectedCallback() {
    const span = document.createElement("span");
    span.classList.add("btn-text");
    span.innerText = "button";

    const style = document.createElement("style");
    style.textContent = `
      .btn-text {
        color: red;
        font-size: 16px;
      }
    `;

    // 加入到自定义元素节点内,this指向到就是自定义元素对应的dom对象
    this.appendChild(span);
    this.appendChild(style);
  }
}

// 注册组件
customElements.define("custom-button", CustomButton);

自定义元素通过类语法定义,规范中对自定义元素的构造函数内容有一定的限制,如果使用构造函数需要遵循以下规则:

  • super()必须是构造函数中的第一个语句
  • 构造函数中不允许出现return语句(除了return;return this;
  • 构造函数不能使用 document.write()document.open()方法。
  • 构造函数中不允许操作自定义元素自身DOM(即通过this调用DOM API),否则浏览器可能会抛出Failed to construct 'CustomElement’的异常,这些操作应该放到connectedCallback() 回调中处理,但需要注意connectCallback() 可以触发多次,需要一个保护机制防止多次触发。

自定义类需要指定一个基类(HTMLElement或者其他内置元素类)以获取基本的DOM功能,可以在connectedCallback() 回调中通过DOM API创建自定义元素里的具体内容。上面代码中我们定义了一个custom-button元素继承自HTMLElement,里面包含红色的‘button’文案,显示效果如下:

image.png

这里需要注意,为了区分自定义元素和原生元素,自定义元素的名称必须带‘-’,不能是单个单词,否则无法注册。此外自定义元素也不可以使用自闭合标签的形式。

这里也可将基类改为内置元素类,例如将HTMLElement改为HTMLButtonElement ,就可以实现对内置元素进行扩展:

    
class CustomButton extends HTMLButtonElement {
  constructor() {
    super();
  }

  connectedCallback() {
    const span = document.createElement("span");
    span.classList.add("btn-text");
    span.innerText = "button";

    const style = document.createElement("style");
    style.textContent = `
      .btn-text {
        color: red;
        font-size: 16px;
      }
    `;

    // 加入到自定义元素节点内,this指向到就是自定义元素对应的dom对象
    this.appendChild(span);
    this.appendChild(style);
  }
}

// 这里需要配置extends指定继承自哪个元素
customElements.define("custom-button", CustomButton, { extends: "button" });

扩展内建元素在使用上也有些区别,不能直接使用自定义标签,如果跟上面一样直接使用 是不会生效的,而是要使用基类元素,通过is属性指定为自定义元素:

<button is="custom-button"></button>    

image.png

最终渲染结果也是<button>标签,拥有<button> 的默认行为,同时又拥有自定扩展的功能。

注意:自定义元素创建后更改is属性不会改变元素行为,不能通过is动态切换

Shadow Dom

Shadow Dom的官方定义如下:

一组 JavaScript API,用于将封装的“影子”DOM 树附加到元素(与主文档 DOM 分开呈现)并控制其关联的功能。通过这种方式,你可以保持元素的功能私有,这样它们就可以被脚本化和样式化,而不用担心与文档的其他部分发生冲突。

上面我们定义了一个custom-button,它的内部元素包含一个.btn-text选择器,如果此时HTML中还有另一个元素也包含同样的class会怎样呢:

<custom-button></custom-button>
<div class="btn-text">text</div>

image.png

可以看到,自定义元素外部带有btn-text选择器的其他元素也被应用了同样的样式。执行document.getElementsByClassName('btn-text') 也会将内部span元素返回,这显然不是我们希望的效果。组件内部的样式不应该污染外部样式,组件内部的元素也不应该被外部直接访问,这时候就需要使用到Shadow Dom。

它相当于一个沙箱,只要将自定义元素的内容放在Shadow DOM中,不仅可以防止内部样式污染外部,也可以阻止外部访问内部元素。使用Shadow DOM改造后的custom-button代码如下:

class CustomButton extends HTMLElement {
  connectedCallback() {

		// 为自定义元素创建shadow dom
    const shadow = this.attachShadow({ mode: "open" });
    const span = document.createElement("span");
    span.classList.add("btn-text");
    span.innerText = "button";

    const style = document.createElement("style");
    style.textContent = `
      .btn-text {
        color: red;
        font-size: 16px;
      }
    `;
    shadow.appendChild(span);
    shadow.appendChild(style);
  }
}

customElements.define("custom-button", CustomButton);

image.png

可以看到,使用Shadow DOM后,外部的同名class无法生效,并且自定义元素下多了一个shadow-root节点(也就是Shadow DOM的根节点)。这里有一些相关术语需要了解:

image.png

  • Shadow host:一个常规 DOM 节点,Shadow DOM 会被附加到这个节点上。
  • Shadow tree:Shadow DOM 内部的 DOM 树。
  • Shadow boundary:Shadow DOM 结束的地方,也是常规 DOM 开始的地方。
  • Shadow root: Shadow tree 的根节点。

以上面的代码为例, 就是Shadow host,我们自定义创建的内容与Shadow root组成Shadow Tree附着到 节点。

Shadow DOM不仅可以用于自定义元素,一些内置元素也可以创建Shadow DOM,具体可以查询相关文档

此时我们再次通过document.getElementsByClassName('btn-text') 查询,只会返回div.btn-text。如果想访问怎么办呢?在使用attachShadow创建Shadow DOM时,传递了一个mode属性,这个属性的作用就是就是设置是否放开外部访问,如果设置为open,那么外部就可以通过元素的shadowRoot接口进行访问:

image.png

如果设置为closed,那么访问shadowRoot则会返回null,禁止外部访问。

模版与插槽

上面代码中,自定义元素的内容都是通过DOM API创建,当内容复杂时就需要写一大堆的创建逻辑。Web Component提供了模版<template> 可以在里面以常规HTML元素书写可复用内容,模版内容不会展示在页面中,但是可以通过js读取,我们将自定义元素的内容用模版的方式实现:

// html
<template id="custom-button-template">
  <style>
    .btn-text {
      color: red;
    }
  </style>
  <span class="btn-text">button</span>
</template>

// js
class CustomButton extends HTMLElement {
  connectedCallback() {
    const shadow = this.attachShadow({ mode: "closed" });

		// 通过content属性拿到模版内容,而不包含template元素本身
    const content = document.getElementById("custom-button-template").content;

		// 深拷贝一份content作为Shadow DOM内容,只有深拷贝才能复制完整的DOM结构,浅拷贝只能复制元素本身,不包含子元素
		// 当然也可以不拷贝直接讲模版内容移到自定义元素中,但这样就其他组件就没法复用这个模版了
    shadow.appendChild(content.cloneNode(true));
  }
}

customElements.define("custom-button", CustomButton);

此外Web Component还提供了插槽 提升模版灵活度,上面代码中里的文案就可以通过插槽从外部插入:

// html
<template id="custom-button-template">
  <style>
    .btn-text {
      color: red;
    }
  </style>
  <span class="btn-text">
		<slot></slot>
	</span>
</template>

<custom-button>button</custom-button>

// js
class CustomButton extends HTMLElement {
  connectedCallback() {
    const shadow = this.attachShadow({ mode: "closed" });

		// 通过content属性拿到模版内容,而不包含template元素本身
    const content = document.getElementById("custom-button-template").content;

		// 深拷贝一份content作为Shadow DOM内容,只有深拷贝才能复制完整的DOM结构,浅拷贝只能复制元素本身,不包含子元素
    shadow.appendChild(content.cloneNode(true));
  }
}

customElements.define("custom-button", CustomButton);

如果有需要多个插槽时可以使用具名插槽,通过name属性标记不同的插槽,例如给 再添加一个icon插槽就可以这样写:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <template id="custom-button-template">
      <style>
        .btn-text {
          color: red;
        }
      </style>
      <slot name="icon"></slot>
      <span class="btn-text">
        <slot></slot>
      </span>
    </template>
    <custom-button>
      <span slot="icon">i</span>
      button
    </custom-button>
    <div class="btn-text">text</div>
    <script src="./comp.js"></script>
  </body>
</html>

Attribute与Property

通常我们使用组件时会通过props传递各种类型的配置。而web component只是一个自定义元素,所以它和普通元素一样,并没有开箱即用的props功能,但是可以通过attribute和property来替代。例如给添加一个width配置:

class CustomButton extends HTMLElement {
  connectedCallback() {
    const shadow = this.attachShadow({ mode: "closed" });

		this.setWidth(this.getAttribute("width"));

		// 通过content属性拿到模版内容,而不包含template元素本身
    const content = document.getElementById("custom-button-template").content;

		// 深拷贝一份content作为Shadow DOM内容,只有深拷贝才能复制完整的DOM结构,浅拷贝只能复制元素本身,不包含子元素
    shadow.appendChild(content.cloneNode(true));
  }

	setWidth(width) {
    if (!isNaN(+width)) {
      this.style.width = width + "px";
    } else {
      this.style.width = width || "auto";
    }
  }
}


// html
<custom-button width="60">button</custom-button>

此时如果改变width属性的值,按钮宽度并不会变化,想要监听属性的变化可以通过 static get observedAttributes: () => string[] 这个静态方法指定需要监听的属性列表,这样就可以在attributeChangedCallback: (name: string, oldVal: string, newVal: string) => void生命周期回调中获取变化后的属性值。将上面width改成响应式后的代码:

class CustomButton extends HTMLElement {
  connectedCallback() {
    const shadow = this.attachShadow({ mode: "closed" });

    this.setWidth(this.getAttribute("width"));

    // 通过content属性拿到模版内容,而不包含template元素本身
    const content = document.getElementById("custom-button-template").content;

		// 深拷贝一份content作为Shadow DOM内容,只有深拷贝才能复制完整的DOM结构,浅拷贝只能复制元素本身,不包含子元素
    shadow.appendChild(content.cloneNode(true));
  }
    setWidth(width) {
    if (!isNaN(+width)) {
      this.style.width = width + "px";
    } else {
      this.style.width = width || "auto";
    }
  }

	static get observedAttributes() {
    return ["width"];
  }

  attributeChangedCallback(name, oldVal, newVal) {
    if (name === "width") {
      this.setWidth(newVal);
    }
  }
}


// html
<custom-button width="60">button</custom-button>

width通过attribute的形式传给组件,但attribute只能接收string类型的值一些复杂的对象配置无法通过这种方式传递。这种情况下可以通过DOM对象property进行传值:

class CustomButton extends HTMLElement {
	
  // 定义实例属性
  config = {
    fontSize: "14px",
    color: "#ddd",
  };
  connectedCallback() {
    const shadow = this.attachShadow({ mode: "closed" });

    this.setWidth(this.getAttribute("width"));

    // 通过content属性拿到模版内容,而不包含template元素本身
    const content = document.getElementById("custom-button-template").content;

    // 深拷贝一份content作为Shadow DOM内容,只有深拷贝才能复制完整的DOM结构,浅拷贝只能复制元素本身,不包含子元素
    shadow.appendChild(content.cloneNode(true));
  }

  setWidth(width) {
    if (!isNaN(+width)) {
      this.style.width = width + "px";
    } else {
      this.style.width = width || "auto";
    }
  }
	
  setStyle() {
    Object.keys(this.config).forEach((key) => {
      this.style[key] = this.config[key];
    });
  }
	
  // 外部调用更新配置
  setConfig(config) {
    this.config = config;
    this.setStyle();
  }

  static get observedAttributes() {
    return ["width"];
  }

  attributeChangedCallback(name, oldVal, newVal) {
    if (name === "width") {
      this.setWidth(newVal);
    }
  }
}

// html
<custom-button id="btn" width="60">button</custom-button>
<script>
	document.getElementById('btn').setConfig({...})
</script>

另一种方式则是以JSON字符串的形式传递复杂值,在组件内部进行解析转换,但JSON无法传递函数类型的属性,所以还是推荐使用property的形式传递复杂值。

事件

Web Component中,通过自定义事件CustomEvent 进行通信:

class CustomButton extends HTMLElement {
  config = {
    fontSize: "14px",
    color: "#ddd",
  };

  connectedCallback() {
    const shadow = this.attachShadow({ mode: "open" });
    const content = document.getElementById("custom-button-template").content;
    const node = content.cloneNode(true);

    node.querySelector(".btn-text").addEventListener("click", () => {
      this.dispatchEvent(
        new CustomEvent("text-click", {
          detail: {
            text: "text",
          },
        })
      );
    });
    ...
  }
}

// html
<script>
  document
    .querySelector("custom-button")
    .addEventListener("text-click", (e) => console.log(e.detail));
</script>

内部抛出自定义事件,外部监听该事件以获取内部传递出的数据。

生命周期

自定义元素类有以下几种生命周期回调:

  • connectedCallback:当自定义元素第一次被连接到文档 DOM 时被调用。
  • disconnectedCallback:当自定义元素与文档 DOM 断开连接时被调用。
  • adoptedCallback:当自定义元素被移动到新文档时被调用。
  • attributeChangedCallback:当自定义元素的一个属性被增加、移除或更改时被调用。

其中attributeChangedCallback上面已经介绍过了,需配合observedAttributes使用,另外几个回调执行顺序如下:

class CustomButton extends HTMLElement {
	connectedCallback() {
    console.log("connectd");
  }

  disconnectedCallback() {
    console.log("disconnected");
  }

  adoptedCallback() {
    console.log("adopted");
  }
}

// html
<script>
      const customBtn = document.querySelector("custom-button");

      document.querySelector("#delete").addEventListener("click", () => {
        customBtn.remove();
      });

      document.querySelector("#move").addEventListener("click", () => {
        document.querySelector(".btn-text").appendChild(customBtn);
      });
 </script>

刷新页面,当CustomButton构造函数被执行后connectedCallback被调用。接着点击move按钮,将CustomButton移动到另一个DOM中,此时会先出发disconnectedCallback,再触发adoptedCallback

实现一个倒计时组件

最后我们利用web component封装一个倒计时组件作为练习。组件支持设置倒计时时长(单位:秒)、开始、暂停、前缀配置。时长变化重新倒计时,倒计时单位最大为天,最小为秒,以x天x小时x分钟x秒到形式展示,最终实现效果如下:

image.png

具体实现代码如下:

class CountDown extends HTMLElement {
  constructor() {
    super();
  }
  
  connectedCallback() {

    this.autoStart = this.getAttribute("auto-start") !== null;
    this.seconds = +this.getAttribute("seconds");
    this.timer = null;

    const content = this.initTemplate(this.seconds);
    const shadow = this.attachShadow({ mode: "open" });
    shadow.innerHTML = content;
    
    if (this.autoStart) {
      this.start();
    }
  }

  start(skipFirst) {
    const $time = this.shadowRoot.querySelector("#time");
    const render = () => {
      $time.innerText = this.formatTime(this.seconds--);
    };

    if (skipFirst) {
      render();
    }

    if (this.timer) {
      clearInterval(this.timer);
    }

    this.timer = setInterval(() => {
      if (this.seconds === 0) {
        this.dispatchEvent(new CustomEvent("finished"));
        clearInterval(this.timer);
      }

      render();
    }, 1000);
  }

  stop() {
    if (this.timer) {
      clearInterval(this.timer);
    }
  }

  formatTime(seconds) {
    if (seconds === 0) {
      return "0秒";
    }
    const days = Math.floor(seconds / 86400); // 1天有86400秒
    seconds %= 86400;
    const hours = Math.floor(seconds / 3600); // 1小时有3600秒
    seconds %= 3600;
    const minutes = Math.floor(seconds / 60); // 1分钟有60秒
    seconds %= 60;

    let result = "";
    if (days > 0) {
      result += days + "天";
    }
    if (hours > 0) {
      result += hours + "小时";
    }
    if (minutes > 0) {
      result += minutes + "分钟";
    }
    if (seconds > 0) {
      result += seconds + "秒";
    }

    return result;
  }

  initTemplate(seconds) {
    return `
      <style>
        :host {
          display: block;
        }

        .count-down {
          display: flex;
          align-items: center;
          gap: 8px;
          font-size: 14px;
          color: #000;
          font-weight: 600;
        }

        .count-down__prefix {
          color: #ddd;
          font-weight: 400;
        }
      </style>
      <div class="count-down">
        <div class="count-down__prefix">
          <slot name="prefix"></slot>
        </div>

        <div id="time">
          ${this.formatTime(seconds)}
        </div>
      </div>
    `;
  }

  static get observedAttributes() {
    return ["seconds"];
  }

  attributeChangedCallback(name, oldVal, newVal) {
    if (name === "seconds") {
      this.seconds = +newVal;
      this.start(true);
    }
  }
}

customElements.define("count-down", CountDown);

html:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <count-down id="countDown" seconds="100000" auto-start>
      <span slot="prefix">😊距离开学还有:</span>
    </count-down>

    <button id="start">start</button>
    <button id="stop">stop</button>
    <button id="random">random</button>
    <script src="./comp.js"></script>
    <script>
      const countDown = document.querySelector("#countDown");

      countDown.addEventListener("finished", () => {
        console.log("开学啦!");
      });

      document.querySelector("#start").addEventListener("click", () => {
        countDown.start();
      });

      document.querySelector("#stop").addEventListener("click", () => {
        countDown.stop();
      });

      document.querySelector("#random").addEventListener("click", () => {
        countDown.setAttribute("seconds", Math.ceil(Math.random() * 200 + 10));
      });
    </script>
  </body>
</html>