当下,在前端开发中,组件化开发方式越来越受到开发者的喜爱,因为一个组件基本可以实现一个相对独立的功能,复用性很强。

在使用三大主流框架开发应用时,我们会不停地创建各种各样的组件来提高开发效率,甚至会应用一些成熟的 UI 组件库,但有一个问题不得不提,那就是使用框架创建的组件会依赖框架本身,无法在其他框架中使用,导致更换框架时也需要更换组件库,造成重复劳动,而 Web Components 可以很好地解决这一问题。

Web Components 允许开发者将 html 页面的功能封装为 custom elements(自定义元素,在 JavaScript 中是 DOM 对象,如 HTMLParagraphElement;在 html 中是标签,如 <p>),自定义元素和组件一样,可以拥有独立的功能,由于它是基于浏览器标准的,可以和普通标签一样随意使用,这篇文章就来聊一聊如何使用 Web Components 创建组件。

创建自定义元素

虽然使用 Web Components 可以创建自定义元素,但其毕竟是基于浏览器标准的,因此在创建时需要继承原生 DOM 对象,创建方法为:

customElements.define(name, constructor, options);
  • name 为自定义元素的名称,为了和原生元素区别开来,自定义元素的名称中必须含有短横线,如 my-button
  • constructor 为该自定义元素的构造器;
  • options 接收一个可选的配置对象,目前仅有一个属性 extends,其值为原生元素的名称,用于指定继承哪一个原生 DOM 对象,如 button;当指定了 extends 时,构造器必须继承对应 DOM 对象,否则,一律继承 HTMLElement 对象; extends 对于以后如何使用该自定义元素会有影响,后文会做讲解。

从参数可以看出,创建自定义元素最重要的就是创建构造器。接下来,本文将通过介绍两种方法来创建构造器(纯 JavaScript 方式和 Web Components 方式),实现同一个自定义按钮元素 <my-button>

纯 JavaScript 方式

此方法就是完全使用 DOM 操作来创建构造器:

class MyButton extends HTMLElement {
  constructor() {
    super();
    const btn = document.createElement('button');
    btn.textContent = '自定义按钮';
    btn.className = 'my-btn';
    const style = document.createElement('style');
    style.textContent = `
    .my-btn{
      display: inline-block;
      line-height: 1;
      white-space: nowrap;
      cursor: pointer;
      text-align: center;
      box-sizing: border-box;
      outline: none;
      margin: 0;
      transition: .1s;
      font-weight: 500;
      padding: 12px 20px;
      font-size: 14px;
      border-radius: 4px;
      color: #fff;
      background-color: #409eff;
      border:1px solid #409eff;
    }`;
    this.appendChild(style);
    this.appendChild(btn);
  }
}

customElements.define('my-button', MyButton);

使用时和普通 button 标签一样:

<my-button></my-button>

在页面上将出现一个蓝色的按钮:

my-button

在调用 define 时,如果我们指定 extendsbutton:

customElements.define('my-button', MyButton, {extends: 'button'});

MyButton 需要改为继承 HTMLButtonElement

class MyButton extends HTMLButtonElement {
  //内部代码不变
}

使用时通过原生标签的 is 属性指定自定义元素名称:

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

得到的效果如下:

is_my-button

可以看到自定义元素多了一圈黑色的边框,这是因为,我们是在定义的 MyButton 构造器中添加的 button 元素,等同于给 button 又嵌了一个 button

指定了 extends 的自定义元素称为 customized built-in elements,而未指定 extends 的自定义元素称为 autonomous custom elements,注意两者的区别。

从构造器内部的代码可以看出,一旦自定义元素内容较多,写起来将非常吃力,尤其是在编辑样式时。

Web Components 方式

由于纯 JavaScript 方式创建自定义元素的这些问题,HTML5 提供了一些新的元素、属性和方法,便于更好地创建自定义元素,其中最重要的就是 template 元素。

template 元素

当页面上出现 template 元素时,其内容并不会被渲染出来,但可以使用常规的 DOM 方法访问,因此我们可以将 my-button 的内部 html 代码放到一个 template 元素中:

<template id="my-button">
  <style>
    .my-btn {
      display: inline-block;
      line-height: 1;
      white-space: nowrap;
      cursor: pointer;
      text-align: center;
      box-sizing: border-box;
      outline: none;
      margin: 0;
      transition: .1s;
      font-weight: 500;
      padding: 12px 20px;
      font-size: 14px;
      border-radius: 4px;
      color: #fff;
      background-color: #409eff;
      border: 1px solid #409eff;
    }
  </style>
  <button class="my-btn">自定义按钮</button>
</template>

然后在在构造器中去使用:

class MyButton extends HTMLElement {
  constructor() {
    super();
    const templateElm = document.getElementById('my-button');
    const content = templateElm.content.cloneNode(true);
    this.appendChild(content);
  }
}



 
 



得到的效果和采用纯 JavaScript 方式创建构造器时是一样的,但这种写法肯定更加容易阅读与调试。

事实上,使用 template 元素来承载自定义元素的内部代码在解决了阅读与调试问题的同时,也带来了一个新的问题,那就是当编写一个独立的公共组件时,如何处理 template 元素?

答案是,目前并没有一个好的解决办法😂。

shadow DOM

通过前文的两种方式,我们就可以实现自定义元素,但仔细一看会发现,这和以前创建各种插件的方式并没有多大区别。不过,Web Components 提供了一个接口让它们有了区别,这个接口就是 shadow DOM。

shadow DOM,直译过来可以称作影子 DOM,它可以将一个隐藏的、独立的 DOM 附加到一个元素上,并将其内部的结构、样式和行为都隐藏起来, 除非开启了特定的属性,否则内外 DOM 完全隔离,互不影响。

回想一下我们使用的 videoaudio 元素,它们有自己的方法、样式,不会受到外部代码的影响,有了 shadow DOM,我们也可以实现这样的自定义元素。

使用 shadow DOM 的方法也很简单,调用元素的 attachShadow 方法即可:

class MyButton extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({mode: 'open'});
    const templateElm = document.getElementById('my-button');
    const content = templateElm.content.cloneNode(true);
    shadow.appendChild(content);
  }
}



 





得到的效果和未使用 shadow DOM 时一致。

attachShadow 接收一个参数对象,目前仅有一个属性 mode,值为 openclosed,当值为 closed 时外部将无法获取到自定义元素的 shadow DOM,从而实现了和 video 元素一样的效果。

要获取自定义元素的 shadow DOM 可以使用 shadowRoot 属性:

console.log(document.querySelector('my-button').shadowRoot);

得到:

shadow_root

如果 mode 值为 closed,返回值将为 null。获取到的 shadow DOM 和普通 DOM 元素在使用上没有任何区别。

提示

需要注意的是,并不是所有元素都有 attachSahdow 方法,这里open in new window可以查看所有可使用该方法的元素。

如果我们在外部的添加一个同名 class:

.my-btn {
  background-color: lawngreen;
}

按钮并不会受到影响,我们最担心的样式覆盖问题也彻底得到了解决。

参数传递

在使用上述的自定义元素时,如果我们尝试像使用普通 button 一样去修改 my-button 的文本内容:

<my-button>改变元素文本</my-button>

会发现并没有得到预期的效果:

change_text

这是因为在自定义元素的构造器内部,我们是将内部元素“append”到元素上的,所以文本还会渲染到内部 button 之前。这肯定是不满足需求的,用户在使用自定义元素时必然要对元素做一些修改。

那么如何让用户可以修改自定义元素呢?有三种方式可以做到:使用自定义属性、使用 slot 元素以及使用伪类。

使用自定义属性

删除 button 中原有的文本,在构造器内部,预定一些可以用于修改自定义元素的属性,并实现修改功能:

class MyButton extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({mode: 'open'});
    const templateElm = document.getElementById('my-button');
    const content = templateElm.content.cloneNode(true);
    content.querySelector('button').innerText = this.getAttribute('name');
    shadow.appendChild(content);
  }
}






 



使用时,向这些属性传入需要的值:

<my-button name="新的按钮文本"></my-button>

得到的效果如下:

new_text

对于某些特殊的元素如 input 登,其本身就有 name 属性,在给自定义属性命名时应当考虑是否需要避免使用这些名称。

使用 slot 元素

一般来说,用户不仅想修改自定义元素的文本,也许还想在文本前(或后)添加一个图标(或随便什么元素),此时自定义属性就不那么好用了,不过 HTML5 提供了 slot 元素。

修改自定义元素的代码,在 template 中预留图标的位置:

<button class="my-btn"><slot></slot></button>

使用时:

<my-button name="新的按钮文本"><i class="fa fa-edit"></i></my-button>

得到的效果如下:

slot

还可以通过 slot 元素的 name 定向插入内容,详情可以点此open in new window查看。

使用伪类

前两种方法都是在修改自定义元素的 html 内容,用户当然也希望能够修改元素的样式,通过自定义属性的方式可以实现修改样式,但需要经过 DOM 操作,因此 Web Components 提供了一些新的伪类可以做到不通过 DOM 操作。

本文并不准备讲解所有的伪类,将仅演示 :host 的用法,修改 template 中的 style,使用 :host 伪类定义一个颜色变量,并在 .my-btn 中使用它:

<style>
  :host {
    --background-color: #409eff;
  }

  .my-btn {
    display: inline-block;
    line-height: 1;
    white-space: nowrap;
    cursor: pointer;
    text-align: center;
    box-sizing: border-box;
    outline: none;
    margin: 0;
    transition: .1s;
    font-weight: 500;
    padding: 12px 20px;
    font-size: 14px;
    border-radius: 4px;
    color: #fff;
    background-color: var(--background-color);
    border: 1px solid var(--background-color);
  }
</style>


 

















 
 


在外部样式中就可以通过修改变量值实现修改自定义元素的样式:

my-button {
  --background-color: lawngreen;
}

得到的效果如下:

host

:host 伪类可以选中 shadow DOM 的宿主元素,在本例中即为 my-button,因此当修改 my-button 中变量的值后,自定义元素内部的样式也就改变了。

生命周期

和依赖框架的组件库一样,Web Components 中也有生命周期,分别是:connectedCallbackdisconnectedCallbackadoptedCallbackattributeChangedCallback

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

前两个函数很好理解,也很好使用,但第三个和第四个就需要注意了。

第三个函数只有在自定义元素被移动到新文档时才会被调用,也就是说如果将自定义元素从一个元素移动到了另一个元素中,并不会触发该函数。

而第四个函数直接使用是无效的,它必须配合静态方法 observedAttributes 一起使用。

observedAttributes 方法内部就一条 return 语句,返回要监听的属性数组,只有该数组中的属性值改变时 attributeChangedCallback 才会调用:

class MyButton extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({mode: 'open'});
    const templateElm = document.getElementById('my-button');
    const content = templateElm.content.cloneNode(true);
    content.querySelector('button').innerText = this.getAttribute('name');
    shadow.appendChild(content);
  }

  static get observedAttributes() {
    return ['name'];
  }

  connectedCallback() {
    console.log('connected')
  }

  disconnectedCallback() {
    console.log('disconnected')
  }

  adoptedCallback() {
    console.log("I'm been moved")
  }

  attributeChangedCallback(attrName, oldVal, newVal) {
    console.log(`the old value of ${attrName} is ${oldVal}, new value is ${newVal}`)
    this.shadowRoot.querySelector('button').innerText = this.getAttribute('name');
  }
}










 
 
 


















事实上,在自定义元素初次被添加到文档中时,attributeChangedCallback 也会调用,因为 name 的初始值为 null,所以 constructor 中这条语句也可以不要了:

content.querySelector('button').innerText = this.getAttribute('name');

attributeChangedCallback 中执行即可。

结语

对于使用过 Vue 的朋友来说,会发现文中的 templateslot 看上去非常熟悉,这是因为 Vue 本身参考了这些标准。

相对于依赖框架的组件库来说, Web Components 有更高的通用性,不受框架限制,虽然还处于发展之中,但在兼容性要求不高的情况下已经可以使用了。

最近更新:
作者: MeFelixWang