Web Components

Table of Contents

前端组件化已经是大势所趋了,随着组件化的口号越来越响,在这种背景下诞生了一些用来实现组件化的开发工具,

在另外一方面,随着浏览器的现代化,WEB前端标准化也越来越重要,就连微软也意识到了这个趋势,在今年4月左右开始宣布新版的Edge开始使用 chromium 内核,

截至目前为止,Edge有两个分支在同时更新:旧版以及 beta 版,后者的内核就是 chromium, 这里是更新状态.

在这个时代下,组件化也迎来了它的标准.

WEB组件诞生的目的

传统WEB前端开发的一大问题: 缺少代码复用.

Web Components 就是出于解决这个问题为目的而诞生的,同时也是作为一个标准的组件化手段而诞生的.

与第三方框架(Angular/React/Vue)相比

优点: 无任何依赖,原生,代码量少,更低的学习成本(没有第三方框架的一些抽象概念以及一大堆工具)/更加直观

缺点: 兼容问题,没有 MVVM 这种抽象概念,相比下更少便利功能,无需操作DOM

WEB组件简单介绍

WEB组件是一套 EcmaScript API,分为三大部分:

  1. 自定义元素 (Custom Elements)
  2. Shadow DOM
  3. HTML模板 (HTML templates)

实现一个WEB组件的整体流程如下:

  1. 创建一个类(ECMAScript 2015的语法规范)或者函数来指定组件的功能
  2. 利用 CustomElementRegistry.define(cusElName, class/function[, inheritedEl]) 对定义的组件进行注册
  3. 如果有用到 Shadow DOM 的话,需要使用 Element.attachShadow({ mode: 'open'/'closed' }) 创建一个 ShadowRoot, 然后像正常操作 DOM 那样对这个 ShadowRoot 进行事件监听,添加子元素等操作.
  4. 如果有必要,可以使用 <template> 以及 slot 来定义 HTML 模板,然后像正常操作 DOM 那样把模板 cloneShadowRoot 上.
  5. 在页面上像原生元素那样使用自定义元素.

一个简单的例子

完全参照上一小节的流程,

首先,页面如下:

<html>
    <head>
        <meta charset="utf-8">
    </head>
    <body>
        <cus-input></cus-input>
        <script src="webcomponent.js"></script>
    </body>
</html>
  • Step 1

    自定义的组件 <cus-input>,

    // webcomponent.js
    class CusInput extends HTMLElement {
        constructor() {
            super();
            console.log("I am showing how hard I'm working !!!");
            // 这是为了区分组件是否经过定义以及注册,因为DOM tree 还是会有没有经过定义以及注册元素
        }
    }
    
    
  • Step 2

    注册自定义组件, customElementsCustomElementRegistry 的一个内置实例,

    // webcomponent.js(cont)
    customElements.define('cus-input', CusInput);
    

    需要注意的一点是: 自定义元素的名字必须包含一个 -(hyphen).对于自定义元素来说,只要名字合法,这种元素就能通过浏览器的解释.

    哪怕没有经过定义以及注册,也是被浏览器承认的,并且它们实现的是 HTMLElement 的接口.或者反过来说,只要名字符合这种规范的都是自定义元素.

    除此以外的就是 HTML 对象的第三类: HTMLUnknownElement,与它的名字意思不一样,它代表了所有不合法的元素,

    比如 <hello> 这种既非内置元素也非自定义元素.

    结论就是自定义元素和 HTMLUnknownElement 没有任何交集,不能简单通过判断元素是否 HTMLUnknownElement 的实例来断定这个元素是不是自定义元素.

    (看名字就得了).

    document.querySelector('cus-input') instanceof HTMLUnknownElement; // false
    document.querySelector('cus-input') instanceof HTMLElement; // true
    document.querySelector('undefined-el') instanceof HTMLUnknownElement; // false
    document.querySelector('undefined-el') instanceof HTMLElement; // true
    document.querySelector('hello') instanceof HTMLUnknownElement; // true
    document.querySelector('hello') instanceof HTMLElement; // false
    
  • Step 3

    <cus-input> 是一个空组件,里面什么内容也没有,

    现在要求是这样: <cus-input><input> 元素以及 <button> 组成,并且支持 placeholder 以及 btntext 两个属性.

    有两种实现方法: 操作 DOM 和操作 Shadow DOM.

    1. 操作 DOM

      // webcomponent.js
      class CusInput extends HTMLElement {
      
          constructor() {
              super();
              console.log("I am showing how hard I'm working !!!");
      
              var placeholder = this.getAttribute('placeholder'),
                  btnText = this.getAttribute('btntext');
      
              var input = document.createElement('input'),
                  button = document.createElement('button');
      
              input.classList.add('input');
              input.type = 'text';
              if (placeholder) {
                  input.placeholder = placeholder;
                  console.log(placeholder);
              }
      
              button.classList.add('button');
              if (btnText) {
                  button.innerText = btnText;
                  console.log(btnText);
              }
      
              this.setAttribute('style', 'display:block');
              this.append(input);
              this.append(button);
          }
      }
      

      这种方法有个问题:可以通过 DOM API 来操作里面的 <input><button>,而 Shadow DOM 可以解决这个问题.

    2. 操作 Shadow DOM

      关于 Shadow DOM 的介绍可以阅读这里.

      class CusInput extends HTMLElement {
      
          constructor() {
              super();
              console.log("I am showing how hard I'm working !!!");
      
              const _shadowRoot = this.attachShadow({ mode: 'closed' });
      
              var placeholder = this.getAttribute('placeholder'),
                  btnText = this.getAttribute('btntext');
      
              var input = document.createElement('input'),
                  button = document.createElement('button');
      
              input.classList.add('input');
              input.type = 'text';
              if (placeholder) {
                  input.placeholder = placeholder;
                  console.log(placeholder);
              }
      
              button.classList.add('button');
              if (btnText) {
                  button.innerText = btnText;
                  console.log(btnText);
              }
      
              _shadowRoot.appendChild(input);
              _shadowRoot.appendChild(button);
          }
      }
      

      Element.attachShadowmode'open' 模式的时候可以通过 document.querySelector('cus-input').shadowRoot 访问里面的元素,

      你可以像这样来操作里面的元素 document.querySelector('cus-input').shadowRoot.querySelector('input').

      但是暴露出去不是我们想要的,所以就用 closed,这样 document.querySelector('cus-input').shadowRoot 得到的值就为 null.

      自带的 <video> 元素就使用了 Shadow DOM,所以说其实 Shadow DOM 并不是什么新鲜事物.

      不过哪一种模式下,都是不能通过 document 对象使用 DOM API 获取以及操作 ShadowRoot 里面的内容.

      现在可以给组件传递属性并且不用担心受到外界的影响了.目前这个阶段可以说是完成了一个完整的 WEB 组件了.当是还有得优化.

  • Step 4

    目前的组件还是相对比较简单的,但如果组件比较复就建议使用 <template> 以及 <slot>,当然现在还是用这个简单的 <cus-input> 来作为例子.

    <template> 是一个持有 HTML 内容(style,html元素,甚至script)的元素,这个元素类似于 <script> 这种元素一样默认样式为 display:none,它的目的是用于后续渲染,

    它的对象遵守 HTMLTemplateElement 的接口设计,这种和其它 HTML 元素对象有一个差别: 有一个特有的 read-only content 属性,

    它的值就是一个 DOM subtree.

    <slot> 这是一个占位符(placeholder),可以用于后续填充想要的 HTML 内容,是 <template> 的好兄弟;这个元素有一个 name 属性作为标识,

    HTML 元素有一个 slot 全局属性,该属性的值就是 <slot> 的标识,指定这个值意味着该元素被用于"替换"到对应的 <slot>.

    先从简单的开始 - 改用 <template>,

    1. 首先改写页面文件

      <html>
          <head>
              <meta charset="utf-8">
          </head>
          <body>
              <template id="cus-input-tpl">
                  <input class="input" type="text" />
                  <button class="button"></button>
              </template>
              <cus-input placeholder="请输入内容" btntext="提交"></cus-input>
             <script src="webcomponent.js"></script>
          </body>
      </html>
      
    2. 然后把模板内容添加到 ShadowRoot 下,通过 ShadowRoot 来对模板上的内容进行操作,

      // webcomponent.js
      class CusInput extends HTMLElement {
      
          constructor() {
              super();
              console.log("I am showing how hard I'm working !!!");
      
              const tpl = document
                    .getElementById('cus-input-tpl')
                    .content;
      
              const _shadowRoot = this.attachShadow({ mode: 'closed' });
      
              _shadowRoot.appendChild(tpl.cloneNode(true));
      
              // point A
      
              var placeholder = this.getAttribute('placeholder'),
                  btnText = this.getAttribute('btntext');
      
              var input = _shadowRoot.querySelector('input'),
                  button = _shadowRoot.querySelector('button');
              // point B
      
              input.classList.add('input');
              input.type = 'text';
              if (placeholder) {
                  input.placeholder = placeholder;
                  console.log(placeholder);
              }
      
              button.classList.add('button');
              if (btnText) {
                  button.innerText = btnText;
                  console.log(btnText);
              }
          }
      }
      
      // cont ...
      

      这里有两个重点,

      • A. <template> 对象的 content 属性是 read-only 的,当是不代表 content 指向的内容不能改变, 这是一个类似于 C 语言里面典型的指针变量是 const 的问题,这个变量指向的内容地址不可改变,但该地址上的内容并非不可改变. 所以为了防止发生意外改变了模板内容,需要使用 Node.cloneNode() 进行深拷贝(如果你的 <template> 需要在其它地方使用的话).
      • B. 因为组件里面的 inputbutton 都不是我们手动创建的,所以要对它们进行操作只能通过 _shadowRoot 获取进行修改.
      • C. (4大天王有5个人是常识,所以两个重点有三个也没什么问题),除了 HTML 外, 别忘了 <template> 也可以把 cssjs 包含进去,这里就不展示了.
    3. 拓展(需求变更): 要求用户可以自己提供一个清除按钮.

      这个听上去很麻烦,实际上只需要添加一句代码就搞掂了.

      <html>
          <head>
              <meta charset="utf-8">
          </head>
          <body>
              <template id="cus-input-tpl">
                  <input class="input" type="text" />
                  <button class="button"></button>
                  <slot name="btnClear"></slot>
                  <!-- 使用slot -->
              </template>
      
              <!-- 用法展示 -->
              <cus-input placeholder="请输入内容" btntext="提交">
                  <button slot="btnClear">清除</button>
                  <!-- 在这里插入指定了slot属性的元素 -->
              </cus-input>
              <script src="webcomponent.js"></script>
          </body>
      </html>
      

      这里需要注意一下,"清除"按钮是可以通过 document.querySelector() 来获取到的,也就是说新插入的内容不会被封闭到组件里面, 很好的与组件细节隔离开.你可以对它进行样式化以及各种 DOM 操作.

    4. 把组件封装成单独一个文件实现复用

      目前为止组件脱离不了页面文件上的 <template>,所以现在的组件还是不能复用,其实这个也好解决,通过编程创建 <template> 就好了.

      // webcomponent.js
      class CusInput extends HTMLElement {
      
          constructor() {
      
              super();
      
              console.log("I am showing how hard I'm working !!!");
      
              const template = document.createElement('template');
      
              template.innerHTML = `
                  <input class="input" type="text" />
                  <button class="button"></button>
                  <slot name="btnClear"></slot> <!-- 使用slot -->`;
      
              const tpl = template.content;
      
              const _shadowRoot = this.attachShadow({ mode: 'closed' });
      
              _shadowRoot.appendChild(tpl.cloneNode(true));
      
              // things done before ...
          }
      }
      
      customElements.define('cus-input', CusInput);
      

      这样一来,一个可复用的组件 <cus-input> 就诞生了.

  • Step 5

    现在你可以在任何一个页面的任何地方使用这个没有任何依赖的原生组件 <cus-input> 了.

更加高级的内容

  1. 拓展现有元素

    这里通过拓展 <p> 元素以及使用一个 is 属性来对现有 <p> 作增强:

    https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/is.

  2. 特有的CSS伪类和伪元素
  3. 实现MVVM

    MVVM 的本质是 Publisher/Subscriber 模式,简单点说就是更新的同时触发回调.

    MVVM 并不是御三家的专属,实际上 Web Components 也可以实现双向绑定,而且十分简单.

    1. Object.defineProperty()

      可以给一个对象设定一个 property,并且给这个 property 设定 settergettter,在 set 这个 property 的时候做更新操作.

      比如在 set 的时候触发 getter.

      var obj = new Object();
      
      function callGetterAfterSetter() {
          console.log('Calling getter');
          return this.value === undefined ? 'EMPTY' : this.value;
      }
      
      Object.defineProperty(
          obj,
          'key',
          {
              get() {
                  return callGetterAfterSetter.call(this);
              },
      
              set(value) {
                  this.value = value;
                  callGetterAfterSetter.call(this);
              }
          }
      );
      
      obj.key = 2;
      

      实际上,把 Vue 里面的 data 打印出来也是一大堆 settergetter,至于是不是用 Object.defineProperty() 实现就不清楚了.

    2. Web Components 的生命周期

      https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements#Using_the_lifecycle_callbacks

      利用 attributeChangedCallback 钩子可以做到在指定的属性发生改变的时候做出反应.

更多参考例子

结论

和目前流行的御三家来比, Web Components 其实没那么好用,当是优点还是明显的,那就是简单.实际上也有不少非主流框架基于 Web Components 开发.

而微软收购了 Github 后也采用了 Web Components 进行改写,目前 Github 体验良好.

作为一个前端开发者可以说是十分希望这套标准组件能够流行,但是因为"老"用户的存在以及技术竞争的原因导致这东西在工业上不太容易被接受,

所以目前还是使用御三家来工作吧(除非你们不在乎),但个人还是推荐学一下这个东西,其实内容没多少,加起来还没到 Vue 的一个入门指南的页面多.

实在要在低版本浏览器使用也是可以的,可以使用 polyfill, 比如谷歌的polymer.

而我这篇笔记也就425行,还有一大部分是虚高的代码.真的感觉 前端需要一套标准才能够让开发者不会那么累.

Author: saltb0rn (asche34@outlook.com)

Date: 2019-11-17

Emacs 28.2 (Org mode 9.5.5)

Validate