Web Components 四板斧

3,294 阅读11分钟

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

引言

Web Components 是一组 Web 平台 API,建立在 Web 标准之上,它允许开发人员创建新的自定义可重用被封装HTML 标记在网页和 Web 应用程序中使用。 HTML 标记基于 Web Components 标准构建,可跨现代浏览器工作,并可与任何支持 HTML 的 JavaScript 库或框架(Vue、React、Angular... )一起使用。

并且目前正在将支持 Web Components 的功能添加到 HTML 和 DOM 规范中,从而使 Web 开发人员可以轻松地使用具有封装样式和自定义行为的新元素来扩展 HTML。

Web Components 不是一门单一的技术,而是四门技术的组合,这四门技术分别是:

  • HTML Imports(被废弃,被 ES Modules 取而代之)
  • Custom Elements
  • Shadow DOM
  • HTML templates

这篇文章带你了解 Web Components 这四门技术。

HTML Imports

虽然 HTML imports 被废弃,但是还是简单聊聊它。

HTML imports 提供了一种在一个 HTML中包含和重用另一个 HTML 的方法,使用 HTML imports,我们可以很容易的在一个 HTML 引入其他 HTML,实现复用。

例如:用 Web Components 写的一个 HTML 组件。

<!--component-div.html -->
<template>
  <div>你好,我是一个 Web Components 组件.</div>
</template>

<script>
  const dom = document.currentScript.ownerDocument.querySelector('template').content;
  class Div extends HTMLElement {
    constructor() {
      super();
      this.attachShadow({ mode: 'open' }).appendChild(dom);
    }
  }

  // 注册组件
  customElements.define('test-div-import', Div);
</script>

<style>
  div {
    color: red;
  }
</style>

使用 HTML Imports 在 HTML 页面中引入组件:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Web Components</title>
  <!-- HTML Imports -->
  <link rel="import" href="component-div.html">
</head>
<body>
  <test-div-import></test-div-import>
</body>
</html>

备注: 由于 HTML Imports 已经废弃(被 ES Modules 代替),所以不能使用这种方式。如果想正常使用 HTML Imports 体验效果,可以安装低版本浏览器使用。

HTML Imports 被废弃, ES Modules 取而代之,使用 ES Modules 也可以独立模板的方式引入 Component。

// components-div.js
class ComponentsDiv extends HTMLElement {
  constructor() {
    super();
    this.render();
  }

  render() {
    const shadow = this.attachShadow({ mode: 'open' });
    const dom = document.createElement('div');
    const style = document.createElement('style');
    dom.textContent = 'components-div';
    style.textContent = `
      div {
        color: red;
      }
    `;
    shadow.appendChild(style);
    shadow.appendChild(dom);
  }
}

// 注册组件
customElements.define('components-div', ComponentsDiv)
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <title>Document</title>
    <!-- ES Modules -->
    <script type="module" src="components-div.js"></script>
  </head>
  <body>
    <components-div></my-span>
  </body>
</html>

注意:如果你想复制示例中的代码,自己尝试运行,请注意不管是使用 ES Modules 还是 HTML Imports,在调试时都需要开启一个 WEB 服务。如果直接打开 HTML 文件,会以文件协议(file://)的方式打开,控制台会报跨域错误。

HTML Imports 虽然被废弃,但是个人觉得这种方式很好使用。至于为什么会被废弃和 Web Components 的发展有分不开的关系。

最早在 2011 年的时候 Google 就推出了 Web Components 的概念,也算是前端发展的早期了。那时候前端还处于百废待兴的一个状态,前端甚至都没有「组件化」的概念,但是就是这个时候 Google 就已经凭明锐的嗅觉察觉到「组件化」是未来发展的趋势,所以提出了 Web Components 。不过在最开始时 Google 也只是提出了这样一个概念,并没有去实现它,所以并没有出现太大的浪花。到了 2013 年,Google 浏览器和 Opera 浏览器联合推出 Web Components 规范的 V0 版本。这也算是 Web Components 最早的版本了。

虽然 Web Components 有了 V0 版本,但是在当时来说,其他浏览器厂商并不想去实现这个新的特性。原因在于其他浏览器厂商认为 Web Components 是非常重大的一项功能,但是 API 的设计都是由 Google 浏览器说的算,凭什么呀?

Google 也意识到了问题,就算 Google 浏览器实现了 Web Components 规范,其他浏览器不实现兼容性就会成为一个大的问题。那怎么办了?

所以 Google 决定,和其它流浏览器厂商一起讨论,在讨论中大家就产生了激烈的分歧,例如:

  • Safari 认为 Shadow DOM 应该始终保持封闭并且保证独立性。而 Google 则认为要始终保持开放,让用户能够访问到,否则 Web Components 组件库在用户的眼中始终是一个无法窥视内部构造的黑盒;
  • Firefox 认为 ES6 出来之后,HTML Imports 可以不用实现,ES Modules 也能代替 HTML Imports 的功能。
  • ...

没想到的是 ES Modules 真能代替 HTML Imports 的功能,所以在后续实现 Web Components 时,大家都不实现 HTML Imports 这一规范了,而改用 ES Modules 。Google 转头一想,既然这样,那就把 HTML Imports 废弃吧,这样一来 HTML Imports 就真的被完全废弃了。

Custom Elements

从名字来看,就知道 Custom Elements 是用来创建自定义 HTML 标签Cumtom elements 这个概念对于写过 Vue、React、Angular 的开发者而言应该非常的熟悉,在框架中通过组件的形式,使用自己定义的标签。就拿 Vue 来举例:

// Custom.vue
<template>
  <p>大家好,我是拜小白,我是一个自定义标签。</p>
</template>
<template>
  <div>
    <x-custom></x-custom>
    <p>你好,{{ name }},我知道你是一个自定义标签!</p>
  </div>
</template>

<script>
import Custom from "./Custom";
module.exports = {
  data: function () {
    return {
      name: "拜小白",
    };
  },
  components: {
    "x-custom": Custom,
  },
};
</script>

在这里,x-custom 就是一个自定义标签,用于定义自己的功能。而对于 Web Component,可以使用 CustomElementRegistry.define方法用来注册一个 custom element

customElements.define(name, constructor, options);
  • name:表示创建的元素的名称,不能是单个单词,必须要有短横线
  • constructor:定义元素行为的类
  • options(可选):配置对象

基于以上定义,我们可以实现一个自定义标签。

<template id="my-custom">
    <p>我是一个自定义标签。</p>
  </template>
  <my-custom>
  </my-custom>
  <script>
    class MyCustom extends HTMLElement {
        constructor() {
          super();
          let template = document.getElementById('my-custom');
          let templateContent = template.content;

          const shadowRoot = this.attachShadow({mode: 'open'})
            .appendChild(templateContent.cloneNode(true));
      }
    }
    customElements.define('my-custom', MyCustom);
  </script>

这里需要说明一下,针对第二个参数 ,它有两种类型,通过继承来确认。

Autonomous custom elements

第一种类型, Autonomous custom elements , 独立元素,即 html 中可以直接使定义的标签,需要继承 HTMLElement。

class MyCustom extends HTMLElement {
  ....
}

使用时,可以直接使用。

<my-custom>
</my-custom>

Customized built-in elements

第二种类型, Customized built-in elements ,继承基本元素,并不像 Autonomous custom elements,它依赖于 HTML 中基本元素标签(p、div、span...),通过继承对应的标签,来拓展其功能,具体使用的时候,通过 is 属性来区分原生标签。需要继承 HTMLParagraphElement。

class MyCustom extends HTMLParagraphElement {
  constructor() {
    super();
    let template = document.getElementById('my-custom');
    let templateContent = template.content;

    const shadowRoot = this.attachShadow({mode: 'open'})
      .appendChild(templateContent.cloneNode(true));
  }
}
customElements.define('my-custom', MyCustom, { extends: 'p' });

使用时,在 html 中需要配合 is 属性使用。

<p is="my-custom"></p>

Life Cycle

当我们在使用如 Vue、React、Angular 等等这些框架时,它会告诉我们,它们具有一些生命周期钩子函数,你可以利用这些钩子函数做出不同的动作,例如:created、mounted、update...等,这些生命周期钩子函数会在不同的阶段被触发。当然 Web Components 也有属于自己的生命周期钩子函数,当我们定义一个元素时,它会在元素的不同阶段触发它们。

  • connectedCallback:当 custom element 首次被插入文档 DOM 时,被调用。
  • disconnectedCallback:当 custom element 从文档 DOM 中删除时,被调用。
  • adoptedCallback:当 custom element 被移动到新的文档时,被调用。
  • attributeChangedCallback: 当 custom element 增加、删除、修改自身属性时,被调用。

备注: Firefox、Chrome 和 Opera 默认就支持 custom elements。Safari 目前只支持 autonomous custom elements(自主自定义标签),而 Edge 也正在积极实现中。

Shadow DOM

Shadow DOM 其实对于大家来说既陌生又熟悉,我相信你一定见过,在某些 HTML 元素下,有一段 #shadow-root 代码块,这里的 #shadow-root 所包含的内容其实就是所谓的 shadow-dom

例如:video 标签

注意:浏览器默认是看不到 #shadow-root,需要自己将 #shadow-root 设置为可见。

video 标签 本身是没有更多的元素了,但是被渲染出来之后,你会发现它远不止于此。

<!DOCTYPE html>
<html>
  <head> 
  <meta charset="utf-8"> 
  </head>
  <body>
  
  <video width="320" height="240" controls autoplay>
    <source src="movie.ogg" type="video/ogg">
    <source src="movie.mp4" type="video/mp4">
    <source src="movie.webm" type="video/webm">
    <object data="movie.mp4" width="320" height="240">
      <embed width="320" height="240" src="movie.swf">
    </object>
  </video>
  </body>
</html>

当实际在浏览器进行查看时,你就会发现,Dom 结构并非代码看到的这么简单。

这些操作的关键在于,Shadow DOM,它可以将一个隐藏的、独立的 DOM 附加到一个元素上。Shadow DOM 允许将隐藏的 DOM 树附加到常规的 DOM 树中——它以 shadow root 节点为起始根节点,在这个根节点的下方,可以是任意元素,和普通的 DOM 元素一样

但是请注意,虽然 Shadow DOM 允许在文档(document)渲染时插入一棵 DOM 元素子树,但是这棵子树不在主 DOM 树中。

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

操作 Shadow DOM 就和我们操作普通的 DOM 是一样的,可以为 Shadow DOM 添加属性、样式、子节点,也可以为子节点添加样式。但是不同的是 Shadow DOM 内部的元素不会影响到它外部的元素。Web components 的封装能力 Shadow DOM 是最关键的一环,Shadow DOM 可以将标记结构、样式和行为隐藏起来,并与页面上的其他代码相隔离,保证不同的部分不会混在一起,可使代码更加干净、整洁。

备注: Firefox(从版本 63 开始),Chrome,Opera 和 Safari 默认支持 Shadow DOM。基于 Chromium 的新 Edge 也支持 Shadow DOM;而旧 Edge 未能撑到支持此特性。

HTML templates

template 模板想必用过 Vue 的同学,对它一定不陌生,我们在使用 Vue 项目的单页面组件时会经常使用到 template。

<ul>
  <template v-for="item in items">
    <li>{{ item.msg }}</li>
    <li class="divider" role="presentation"></li>
  </template>
</ul>

其实 template 也是 Web Components API 提供的一个标签,它的特性就是包裹在 template 中的 HTML 片段不会在页面加载的时候解析渲染,但是可以被 js 访问到,进行一些插入显示等操作。所以它作为自定义组件的核心内容,用来承载 HTML 模板,是不可或缺的一部分。

HTML template 本质是一种用于保存客户端内容机制,该内容在加载页面时不会呈现,但随后可以在运行时使用 JavaScript 实例化。当我们必须在网页上重复使用相同的标记结构时,使用某种模板而不是一遍又一遍地重复相同的结构是有意义的。以前这是可行的,但 HTML template 元素使它更容易实现 (这在现代浏览器中得到了很好的支持)。此元素及其内容不会在 DOM 中呈现,但仍可使用 JavaScript 去引用它。

备注: 模板在浏览器中的支持情况很好。

Web Components 使用 HTML template 其实和在 Vue 中使用 template 类似,例如:

<template id="my-template">
  <style>
    p {
      color: yellow;
      background-color: red;
      padding: 10px;
    }
  </style>
  <p>My template</p>
</template>

定义一段这样的模板,然后将这段模板内容添加到 Shadow Dom 上,并且在模板添加的样式也会封装到自定义的元素中。

customElements.define('my-template',
  class extends HTMLElement {
    constructor() {
      super();
      let template = document.getElementById('my-template');
      let templateContent = template.content;
  
      const shadowRoot = this.attachShadow({mode: 'open'})
        .appendChild(templateContent.cloneNode(true));
    }
  });
<my-template>
</my-template>

说到 template就不得不说说 slot了。尽管我们使用 template已经把想要封装的元素封装起来了,但是它还是不够灵活,我们只能在里面放一点文本,如果想要元素丰富起来,我们可以使用 slot 让它能在单个实例中通过声明式的语法展示不同的文本。

备注:slot 在 Chrome 53, Opera 40, Safari 10,火狐 59 和 Edge 79 中支持。

slot可以使用 name 为 slot 定义一个唯一标识,并且允许您在模板中定义占位符,当在标记中使用该元素时,该占位符可以填充所需的任何 HTML 标记片段。

例如:

我们想要为我们上面演示的<my-template>添加一个槽。

<p>
  <slot name="my-slot">默认为本</slot>
</p>
<my-template>
  <span slot="my-slot">你好,我是一个槽</span>
</my-template>

slot 中的默认内容,是在浏览器不支持 slot 属性或者是元素未定义相关插槽内容时进行的展示。

<template id="my-template">
  <style>
    p {
      color: yellow;
      background-color: red;
      padding: 10px;
    }
  </style>
  <p>My template <slot name="my-slot">默认为本</slot></p>
</template>
<my-template>
</my-template>

并且当我们在抒写时,不将 slot 写在 template 也是可以的。比如,slot写在一个常规的标签中,它仍然有效。

<p><slot name="my-slot">slot 写在一个正常的标签中。</slot></p>

slot 在一个常规的标签里,她仍然具有占位符的特性,就像在 shadow DOM 中一样,这样我们能避免如下图抒写一样需要先获取模板对象的 content 属性再使用它的麻烦。

并且这个特性在向一个 template 元素中添加槽时更加实用,因为你不会基于一个渲染的元素定义一个模式。

最后

Web Component 从原生层面实现组件化,可以使开发者开发、复用、扩展自定义组件,实现自定义标签,解决 Web 组件的重用和共享问题,并使 Web 生态保持持续的开放和统一。

作为开发者,我们知道「组件化」开发毅然成为前端主流开发方式,因为「组件化」开发在代码复用、提升团队效率方面有着无可比拟的优势。但这对于自定义标记结构来说通常不是那么容易 — 想想复杂的 HTML(以及相关的样式和脚本),有时您不得不写代码来呈现自定义 UI 控件,并且如果不小心的话,多次使用它们会使您的页面变得一团糟。

而 Web Components 旨在解决这些问题 — 它由三项主要技术(文中介绍了四项,有一项已经被废弃)组成,它们可以一起使用来创建封装功能的定制元素,可以在你喜欢的任何地方重用,不必担心代码冲突。

参考