Web Components 入门实战(上篇)

2,585 阅读10分钟

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

在前端快速发展的今天,组件贯彻着我们日常开发的方方面面,不管是针对业务封装的业务组件,还是项目中依赖的第三方基础 UI 组件(Ant Design、Element、Iviewui...),亦或是依赖的前端框架(Angular、Inferno、Preact、Vue、React、snabbdom、virtual-dom...),它们都贯彻着「组件化」的概念,「组件化」开发毅然成为前端主流开发方式,因为「组件化」开发在代码复用、提升团队效率方面有着无可比拟的优势,现在流行的 Vue、React、Angular 等等框架都是组件框架。所以毫不夸张的说 「组件化将会是前端的发展方向」

最早在 2011 年的时候 Google 就推出了 Web Components 的概念。那时候前端还处于百废待兴的一个状态,前端甚至都没有「组件化」的概念,但是就是这个时候 Google 就已经凭明锐的嗅觉察觉到「组件化」是未来发展的趋势,所以 Google 一直在推动浏览器的原生组件的发展,即 Web Components API。

相比第三方框架,原生组件简单直接,符合直觉,不用加载任何外部模块,代码量小。目前,它还在不断发展,但已经可用于生产环境。

Web Components API 内容比较多,本文并不会全面的实战演示,只会做简单演示,带大家入门看看怎么用它开发组件。本文将教您如何构建第一个 Web 组件

近年来,Web组件 -- Custom Elements 已成为多个浏览器的标准API,允许开发人员将 HTMLCSSJavaScript进行封装成为可重用组件,注意我们并不需要依赖如 React、Angular 或者 Vue 的第三方框架。例如,假设我们需要实现一个类似这样的下拉组件:

<web-dropdown
  label="Dropdown"
  option="option"
  options='[]'
></my-dropdown>

Web Components 入门实战,分为上下两篇,通过上下两篇,我们将通过从头开始一步一步实现下拉组件,并在应用程序中使用它。

实现一个按钮

首先下拉菜单需要一个触发对象,这个触发对象可以是一个文本也可以是一个按钮,我们实现一个按钮来充当下拉菜单的触发对象。让我们一步一步来完成所有的事情。

第一步

首先利用 template来写一个按钮的代码片段。

template: 本质是一种用于保存客户端内容机制,该内容在加载页面时不会呈现,但随后可以在运行时使用 JavaScript 实例化。当我们必须在网页上重复使用相同的标记结构时,使用某种模板而不是一遍又一遍地重复相同的结构是有意义的。

<template id="my-button">
  <style>
    .container {
      padding: 8px;
    }

    button {
      display: inline-block;
      line-height: 1;
      white-space: nowrap;
      cursor: pointer;
      background: #fff;
      border: 1px solid #dcdfe6;
      color: #606266;
      -webkit-appearance: none;
      text-align: center;
      box-sizing: border-box;
      outline: none;
      margin: 0;
      transition: .1s;
      font-weight: 500;
      -moz-user-select: none;
      -webkit-user-select: none;
      -ms-user-select: none;
      padding: 12px 20px;
      font-size: 14px;
      border-radius: 4px;
      color: #fff;
      background-color: #409eff;
      border-color: #409eff;
    }
  </style>

  <div class="container">
    <button>default text</button>
  </div>
</template>

第二步

然后注册一个按钮的类,通过 Element.attachShadow()方法给指定的元素挂载一个 Shadow DOM,并且返回对 ShadowRoot 的引用。

创建按钮类时这里我们继承 HTMLElement ,使用独立元素,使用 HTML 可以直接定义的标签。当然你也可以使用继承元素,继承 HTMLParagraphElement。

class Button extends HTMLElement {
  constructor() {
    super();
    // 返回对 ShadowRoot 的引用
    this._shadowRoot = this.attachShadow({ mode: 'open' });
    this._shadowRoot.appendChild(template.content.cloneNode(true));
  }
}

这里需要注意,并不是每一种类型的元素都可以附加到 Shadow Root上。出于安全的考虑,一些元素不能使用 Shadow DOM。这里列举一些可以挂载Shadow Root的元素:

  • 任何带有有效的名称且可独立存在的(autonomous)自定义元素
  • article
  • aside
  • blockquote
  • body
  • div
  • footer
  • h1 ~h6
  • header
  • main
  • nav
  • p
  • section
  • span

当我们Element.attachShadow()方法时,可以给它传递两个参数。

一个是 mode,表示模式。模式有两种状态:

  • open shadow root:元素可以从 js 外部访问根节点
 this.attachShadow({ mode: 'open' }); // 返回一个 ShadowRoot 对象
  • closed 拒绝从 js 外部访问关闭的 shadow root 节点
 this.attachShadow({ mode: 'closed' }); // 返回一个 null

另一个参数是 delegatesFocus,表示焦点委托。

当设置为 true 时,指定减轻自定义元素的聚焦性能问题行为。 当 shadow DOM 中不可聚焦的部分被点击时,让第一个可聚焦的部分成为焦点,并且 shadow host(影子主机)将提供所有可用的 :focus 样式。

第三步

接着创建 Custom Elements(自定义元素)。通过在窗口上定义自定义元素,将其定义为我们的 HTML 的有效元素。而第一个参数是我们的可重用自定义元素的名称,如 HTML——它必须有一个连字符——第二个参数是我们的自定义元素的定义,包括渲染的模板。

window.customElements.define('my-button', Button);

第四步

最后就可以在 HTML 中的某处使用我们新的自定义元素<my-button></my-button>

请注意,自定义元素不能/不应用作自闭合标签。

<my-button></my-button>

完整代码如下:

<template id="my-button">
  <style>
    .container {
      padding: 8px;
    }

    button {
      display: inline-block;
      line-height: 1;
      white-space: nowrap;
      cursor: pointer;
      background: #fff;
      border: 1px solid #dcdfe6;
      color: #606266;
      -webkit-appearance: none;
      text-align: center;
      box-sizing: border-box;
      outline: none;
      margin: 0;
      transition: .1s;
      font-weight: 500;
      -moz-user-select: none;
      -webkit-user-select: none;
      -ms-user-select: none;
      padding: 12px 20px;
      font-size: 14px;
      border-radius: 4px;
      color: #fff;
      background-color: #409eff;
      border-color: #409eff;
    }
  </style>

  <div class="container">
    <button>default text</button>
  </div>
</template>
<my-button></my-button>

<script>
  let template = document.getElementById('my-button');
  class Button extends HTMLElement {
    constructor() {
      super();

      this._shadowRoot = this.attachShadow({ mode: 'open' });
      this._shadowRoot.appendChild(template.content.cloneNode(true));
    }
  }

  window.customElements.define('my-button', Button);
</script>

到这里我们就实现了一个下拉菜单的触达对象,一个自定义元素的按钮。但是这个自定义元素除了拥有自己的样式和结构,我们没有做更多其他的事情。

为按钮设置属性

将属性传递

我们封装的触发对象是一个公共的代码片段,那必然在不同的场景,按钮的默认文案是不一样的,为了让按钮可以自定义文案内容,我们需要将不同场景的文案传递给按钮。但是我们并不能直接这样使用。

<my-button>更多菜单</my-button>

因为这样并不能生效。我们需要将文案内容当做一个属性传入。如这样:

<my-button text="更多菜单"></my-button>

为了使我们自定义的按钮元素对这个新属性做出反应,我们需要观察它,并使用来自扩展 HTMLElement 类的类方法对它做一些事情。这里我们会用到钩子函数attributeChangedCallback

每当元素的属性变化时(当自定义元素的属性被增加、移除或更改时被调用),attributeChangedCallback回调函数会执行。正如它的属性所示,我们可以查看属性的名称、旧值与新值,以此来对元素属性做单独的操作。

attributeChangedCallback(name, oldVal, newVal) {
  this[name] = newVal;
  ...
}

需要注意的是,如果需要在元素属性变化后,触发attributeChangedCallback回调函数,我们必须监听这个属性。这可以通过定义observedAttributes() get 函数来实现,observedAttributes()函数体内包含一个 return 语句,返回一个数组,包含了需要监听的属性名称:

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

这里需要明确的是,observedAttributes 被定义为 static,因此它将被从类中调用,而不是实例。

通过结合 attributeChangedCallbackobservedAttributes我们就可以实现属性的自定义。

<my-button text="更多菜单"></my-button>

<script>
  let template = document.getElementById('my-button');
  class Button extends HTMLElement {
    constructor() {
      super();

      this._shadowRoot = this.attachShadow({ mode: 'open' });
      this._shadowRoot.appendChild(template.content.cloneNode(true));

      this.$button = this._shadowRoot.querySelector('button');
    }

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

    render() {
      this.$button.innerHTML = this.text;
    }

    attributeChangedCallback(name, oldVal, newVal) {
      this[name] = newVal;
      this.render();
    }
  }

  window.customElements.define('my-button', Button);
</script>

这样自定义元素的内容就初始化完成了,当然你也可以用户相同的方式实现其他属性。到这里我们基本就将属性传递给了自定义元素,每当属性更改时,我们都会在回调函数中将此属性设置为 Web Component 实例中的属性。

将属性映射

在上面我们通过设置自定义元素组件的属性,来动态更新元素的内容,但是我们也可以通过 get set 方法来进行属性的映射。什么意思了?

我们也可以将信息设置为元素的属性,而不是使用属性来渲染我们的按钮。通常在将对象和数组等信息分配给我们的元素时使用这种方式:

<my-button text="更多菜单"></my-button>

===> 
  
<my-button></my-button>

const element = document.querySelector('my-button');
element.text = '更多菜单';

这种方式就和上面的方式不太一样了,使用get 方法属性反映到 property。这样一来我们确保总是获得最新的值,而不是我们自己在回调函数中分配它。然后,this.text总是从我们的 getter 函数返回最近的属性。然后通过元素的 setter 方法通过将元素的属性设置为反射的属性值,确保将属性反射到属性。之后,我们的属性回调再次运行,因为属性已更改,因此我们恢复了渲染机制。

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

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

完整代码如下:

<my-button></my-button>

<script>
  let template = document.getElementById('my-button');
  class Button extends HTMLElement {
    constructor() {
      super();
  
      this._shadowRoot = this.attachShadow({ mode: 'open' });
      this._shadowRoot.appendChild(template.content.cloneNode(true));
  
      this.$button = this._shadowRoot.querySelector('button');
    }
  
    static get observedAttributes() {
      return ['text'];
    }
  
    get text() {
      return this.getAttribute('text');
    }
  
    set text(value) {
      this.setAttribute('text', value);
    }
  
    render() {
      this.$button.innerHTML = this.text;
    }
  
    attributeChangedCallback(name, oldVal, newVal) {
      this.render();
    }
  }
  
  window.customElements.define('my-button', Button);
  
  const element = document.querySelector('my-button');
  element.text = '更多菜单';
</script>

这样一来,就将属性直接进行了映射。

通过控制台的输入,我们也可以看到整个流程的执行过程,了解到方法的触发顺序,更加深入的理解整个过程。

为按钮添加交互

注册事件

上面的步骤我们已经完成了自定义元素属性的传递和设置。接下来我们需要为自定义元素注册事件来对用户的操作做出响应。例如,我们可以获取按钮并向其添加事件侦听器:

// 方法1:
const element = document.querySelector('my-button');
element.addEventListener('click', () => {
  // do something
  console.log(1);
});

// 方法2
class Button extends HTMLElement {
  constructor() {
    super();

    this._shadowRoot = this.attachShadow({ mode: 'open' });
    this._shadowRoot.appendChild(template.content.cloneNode(true));

    this.$button = this._shadowRoot.querySelector('button');
    this.$button.addEventListener('click', () => {
      // do something
      console.log(2);
    });
  }
}

注意:方法1和方法2都可以给元素添加事件侦听器,方法1是在元素外部,方法2是在自定义元素内部,两个方法没有太大的差别,只是方法2可以让你更好的控制自定义元素的侦听注册。

并且我们还可以将事件作为属性传入元素内部,以此来作为监听回调。

class Button extends HTMLElement {
  constructor() {
    super();

    this._shadowRoot = this.attachShadow({ mode: 'open' });
    this._shadowRoot.appendChild(template.content.cloneNode(true));

    this.$button = this._shadowRoot.querySelector('button');
    this.$button.addEventListener('click', () => {
      this.onClick('设置的点击回调被触发~~~');
    });
  }
}

const element = document.querySelector('my-button');
element.onClick = (value) => {
  console.log(value);
}

通过这种方式,也可以将自定义元素内部的信息传递给外部。

支持自定义事件

如果还希望为组件提供自定义事件(例如 onXXX)作为 API。我们也可以手动将自定义元素的点击事件映射到 onXXX 函数上。这里需要借助dispatchEvent()new CustomEvent()

dispatchEvent() 方法会向一个指定的事件目标派发一个 Event,并以合适的顺序(同步地)调用所有受影响的 EventListener。标准事件处理规则(包括事件捕获和可选的冒泡过程)同样适用于通过手动使用 dispatchEvent() 方法派发的事件。

new CustomEvent() 方法创建一个新的 CustomEvent 对象。

class Button extends HTMLElement {
  constructor() {
    super();

    this._shadowRoot = this.attachShadow({ mode: 'open' });
    this._shadowRoot.appendChild(template.content.cloneNode(true));

    this.$button = this._shadowRoot.querySelector('button');

    this.$button.addEventListener('click', () => {
      this.dispatchEvent(new CustomEvent('onCustomClick', {
        detail: '设置的点击回调被触发~~~'
      }))
    });
  }
}

const element = document.querySelector('my-button');
element.addEventListener('onCustomClick', e => console.log(e));
element.addEventListener('onCustomClick', e => console.log(e));

不过在使用new CustomEvent()的时候需要注意了,需要把想要传递的参数包裹在一个包含detail属性的对象,否则传递的参数不会被挂载。

为了方便我们后续的操作,我们将这个按钮的封装,抽离到一个 js 文件中,当做一个模块。完整代码如下:

const template = document.createElement('template');

template.innerHTML = `
<style>
  .container {
    padding: 8px;
  }

  button {
    display: inline-block;
    line-height: 1;
    white-space: nowrap;
    cursor: pointer;
    background: #fff;
    border: 1px solid #dcdfe6;
    color: #606266;
    -webkit-appearance: none;
    text-align: center;
    box-sizing: border-box;
    outline: none;
    margin: 0;
    transition: .1s;
    font-weight: 500;
    -moz-user-select: none;
    -webkit-user-select: none;
    -ms-user-select: none;
    padding: 12px 20px;
    font-size: 14px;
    border-radius: 4px;
    color: #fff;
    background-color: #409eff;
    border-color: #409eff;
  }
  </style>

  <div class="container">
    <button>default text</button>
  </div>
`;

class Button extends HTMLElement {
  constructor() {
    super();

    this._shadowRoot = this.attachShadow({ mode: 'open' });
    this._shadowRoot.appendChild(template.content.cloneNode(true));

    this.$container = this._shadowRoot.querySelector('.container');
    this.$button = this._shadowRoot.querySelector('button');

    this.$button.addEventListener('click', () => {
      this.dispatchEvent(
        new CustomEvent('onCustomClick', {
          detail: '设置的点击回调被触发~~~',
        })
      );
    });
  }
  get text() {
    return this.getAttribute('text');
  }

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

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

  attributeChangedCallback(name, oldVal, newVal) {
    this.render();
  }

  render() {
    this.$button.innerHTML = this.text;
  }
}

window.customElements.define('my-button', Button);

到这里下拉菜单的触发对象自定义按钮就完成了。

总结

我们在从头回忆一下上面的实现过程。

  • 第一步:通过Custom Elements 和 template 实现自定义元素的样式和结构。
  • 第二步:通过attributeChangedCallbackobservedAttributes实现属性的传递,在通过gettersetter实现属性的映射。
  • 最后一步:响应用户操作,为自定义元素注册事件。

整个过程,不是很复杂,那么 Web Components 入门实战的(上篇)到这里就结束了。第二篇将结合已实现的自定义按钮,在此基础上,实现自定义下拉菜单,敬请期待。

参考