💬 web components:封装定制的 HTML 元素

3,151 阅读11分钟

一、前言

在6.30日的稀土开发者大会中,有位讲师分享内容是Quark c工具,它是由哈啰平台前端团队开发的一套面向未来的无框架组件构建工具!它可以让你实现低成本 / 高效开发标准的 跨框架组件 或者 构建整个应用,底层基于浏览器原生 API: Web components。之前我没有听过这个概念,第一次听到瞬间眼前一亮。所以会后研究了一下关于web components的知识,并写成文章分享给大家。

一般来说各大前端框架都有各自的组件库生态,那么有没有可以跨端跨框架的组件库呢?当然有,比如之前我听说过OpenTiny,它是华为云技术团队开源不久的一个组件库,但是当时我并没有去思考探究它是如何实现跨端跨框架的。

前端技术迭代非常快,而且不可能被某一种框架统一市场。考虑到开发成本和维护成本,前端通用型组件势必是前端未来发展的主流趋势之一。

二、什么是 Web Components?

Web Component 是个概念词。它不是一个浏览器原生API,而是一组技术套件的总称,通过这套技术方案,可以创建封装功能的定制元素,即创建浏览器原生层面的自定义组件,它是可以跨端跨框架的,并且不必担心代码冲突。

浏览器兼容性可参考:Web Components 的浏览器兼容性

实现Web Component主要依赖以下三个技术:

  • Custom element(自定义标签元素) :一组 JavaScript API,允许您定义 custom elements 及其行为,然后可以在您的用户界面中按照需要使用它们。
  • Shadow DOM(影子 DOM) :一组 JavaScript API,用于将封装的“影子”DOM 树附加到元素(与主文档 DOM 分开呈现)并控制其关联的功能。通过这种方式,您可以保持元素的功能私有,这样它们就可以被脚本化和样式化,而不用担心与文档的其他部分发生冲突。
  • HTML template(HTML 模板):  <template> 和 <slot> 元素使您可以编写不在呈现页面中显示的标记模板。然后它们可以作为自定义元素结构的基础被多次重用。

三、Custom element 自定义元素

Custom element 是用来自定义标签的。使用它可以将复杂的页面功能、深层嵌套的标签定义一个自定义元素。

实现方法: CustomElementRegistry API

CustomElementRegistry 接口提供注册自定义元素和查询已注册元素的方法。它有以下4个方法:

  • CustomElementRegistry.define():定义一个新的自定义元素;
  • CustomElementRegistry.get():返回指定自定义元素的构造函数,如果未定义自定义元素,则返回undefined;
  • CustomElementRegistry.upgrade():更新节点子树中所有包含阴影的自定义元素,甚至在它们连接到主文档之前也是如此;
  • CustomElementRegistry.whenDefined():返回当使用给定名称定义自定义元素时将会执行的promise。(如果已经定义了这样一个自定义元素,那么立即执行返回的 promise。)

其中只需要重点关注 define() 方法就好,它可以创建两种类型的自定义元素:

  • 自主定制元素:独立元素; 它们不会从内置 HTML 元素继承。
  • 自定义内置元素:这些元素继承自 - 并扩展 - 内置 HTML 元素

自主定制元素:欢迎文字

以下代码我定义了一个欢迎文字的自定义标签,效果如下图:

image.png

  //调用自定义元素
  <welcome-text></welcome-text>

 class WelcomeText extends HTMLElement {
  constructor() {
    super();
    
    var wrapper = document.createElement('div');
    wrapper.setAttribute('class','text-bg');
    wrapper.innerText = 'Hello World'

    var style = document.createElement('style');
    style.textContent = '.text-bg {' +
                          'background-image: url(https://img0.baidu.com/it/u=2772029697,2839972417&fm=253&fmt=auto&app=138&f=JPEG?w=780&h=466);'+
                          'font-weight: bold;'+
                          'background-clip: text;'+
                          '-webkit-background-clip : text;'+
                          'color: transparent;'+
                          'font-size: 40px;'+
                          'width: fit-content;'+
                          '}'

    this.append(wrapper);
    this.append(style)
  }
}

// 定义新元素
customElements.define('popup-info', PopUpInfo);

自定义内置元素:计数器

自定义内置html元素只需要在调用define()方法定义元素时 extends 继承一下原生标签就行。调用时只需要在内置元素标签上通过is扩展属性绑定自定义的元素名就ok了。

以下代码我定义了一个统计字数的p标签,效果如下图:

image.png

//调用自定义元素,统计文章字数
<article>
    巅峰迎来虚伪的拥护,黄昏见证真正的信徒。<br>
    鸡棚都要塌了,你们还在内斗?<br>
    发起保鸡运动<br>
    我有一计可使鸡舍幽而复明。<br>
    这次我们不仅要重建鸡棚,还要让鸡哥变成凤凰。<br>
    安的鸡舍千万间,大庇天下鸡军俱欢颜。<br>
    受任于败鸡之际,奉命于危鸡之间。<br>
    跨过这长江天险,便是坤军铁壁。<br>
    <p is="word-count"></p>
  </article>

class WordCount extends HTMLParagraphElement {
  constructor() {
    super();
    
    var wcParent = this.parentNode;
    function countWords(node){
      var text = node.innerText || node.textContent
      return text.length
    }

    var count = 'Words: ' + countWords(wcParent);
    var text = document.createElement('span');
    text.textContent = count;

    this.append(text)

    setInterval(function() {
      var count = '字数: ' + countWords(wcParent);
      text.textContent = count;
    }, 200)

  }
}

// 继承内置元素并扩展元素内容
customElements.define('word-count', WordCount, { extends: 'p' });

自定义元素的生命周期

在 custom element 的构造函数中,可以指定多个不同的回调函数,它们将会在元素的不同生命时期被调用:

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

四、Shadow DOM 封装/隔离 元素

上面我们利用Custom element实现了自定义元素,但是它是以一个嵌套标签的形式正常的插入到dom中的。要知道,组件最重要的一点就是解耦,内部属性、状态、样式、行为等要封装起来,与组件外部代码隔离,避免相互影响。

那怎么实现封装隔离我们自定义的元素呢?这时候就要用到 Shadow DOM 了,它可以将一个隐藏的、独立的 DOM 附加到一个元素上,可以理解为如果将一个元素附加了Shadow DOM,那么这个元素的内部结构将被封装隐藏。

你可能还不理解,我举个例子你就明白了,当你使用videos标签播放视频的时候,你检查元素会发现页面上只有一个 <video> 标签,那它是如何实现控制器的呢? 其实在<video>标签内部,包含了一系列的按钮和其他控制器,只是通过Shadow DOM将内部结构隐藏了。

如何验证一下呢?很简单,只需要在 Chrome浏览器控制台的设置里,打开显示用户代理Shadow Dom即可,你就能看到video标签内部完整的结构了,如下:

image.png

显示shadow dom

image.png

video标签内部结构

好了,了解到shadow dom是什么以后,那么究竟怎么用它来封装元素呢?

使用指南 >>>

使用 Element.attachShadow() 方法来将一个 shadow root 附加到任何一个元素上,这样 shadow root 内部的代码就会被隔离。语法如下:

var shadowroot = element.attachShadow({mode: 'open', delegatesFocus: true});

attachShadow()方法有两个参数,如下:

  • mode: 指定 Shadow DOM 是否允许js从外部访问 shadow root 节点。参数值有 'open'(允许)、'close'(拒绝访问)。访问方式为 Element.shadowRoot

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

为了加深理解,你应该了解以下四个概念:

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

了解 Shadow Dom 的知识后,我们怎么结合 Custom element 来封装一个自定义元素呢?

我们来改造一下上面我们自定义的欢迎文字组件。只需要两步:

  1. 在构造函数中,我们首先将 Shadow root 附加到 custom element 上:
// 创建 shadow root
var shadow = this.attachShadow({mode: 'open'});
  1. 将所有创建的元素添加到 Shadow root 上:
// 将所创建的元素添加到 Shadow DOM 上
shadow.appendChild(style);
shadow.appendChild(wrapper);

效果如下:

image.png

使用shadow dom封装前

image.png

使用shadow dom封装后

测试隔离效果:用shadow dom封装前,是可以在外部设置自定义元素内的样式的,封装后就不行了。

完整代码如下:

//调用自定义元素
  <welcome-text></welcome-text>
  
  
class WelcomeText extends HTMLElement {
  constructor() {
    super();
    // 第一步:创建 shadow root
    var shadow = this.attachShadow({mode: 'open'});

    var wrapper = document.createElement('div');
    wrapper.setAttribute('class','text-bg');
    wrapper.innerText = 'Hello World'

    var style = document.createElement('style');
    style.textContent = '.text-bg {' +
                          'background-image: url(https://img0.baidu.com/it/u=2772029697,2839972417&fm=253&fmt=auto&app=138&f=JPEG?w=780&h=466);'+
                          'font-weight: bold;'+
                          'background-clip: text;'+
                          '-webkit-background-clip : text;'+
                          'color: transparent;'+
                          'font-size: 40px;'+
                          'width: fit-content;'+
                          '}'
    // 第二步:将所创建的元素添加到 Shadow DOM 上
    shadow.append(wrapper);
    shadow.append(style)
  }
}

customElements.define('welcome-text', WelcomeText);

// ps:访问自定义元素的 shadow root
console.log(document.querySelector('welcome-text').shadowRoot);

五、template + slot 模版插槽

现在我们已经可以实现一个封装隔离的 “自定义组件“ 了。但是现在有两个问题:

  • 自定义的组件内容在js中通过操作dom的方式创建过于繁琐;
  • 组件内容固定,无法显示动态内容;

这两个问题分别通过<template><slot> 这两个标签就可以解决:

利用 <template> 标签灵活填充 Web 组件的 shadow DOM 模板。

查看template标签兼容性

<template> 模板标签包裹的html内容不会直接显示在页面中,但是可以通过js获取它的引用,如下:

// 这时候template中的内容不会显示
<template id="my-paragraph">
  <p>My paragraph</p>
</template>

//但是可以通过js将它的内容插入到dom中。
let template = document.getElementById('my-paragraph');
let templateContent = template.content;
document.body.appendChild(templateContent);

了解 template 标签的特性之后,就可以实现向我们封装的自定义组件中插入我们的模版内容了。

这里我还是改造下上面的欢迎文字组件为例,改造步骤为:

  1. 定义template模版;
  2. 获取到template元素的内容;
  3. 使用 Node.cloneNode() 方法把模板内容拷贝到阴影的根结点上

效果如下:

image.png

完整代码如下:

//调用自定义元素
<welcome-text></welcome-text>

//用template模版很轻松的设计自定义元素内部的结构
<template id="my-paragraph">
    <style>
      p {
        background-image: url(https://img0.baidu.com/it/u=2772029697,2839972417&fm=253&fmt=auto&app=138&f=JPEG?w=780&h=466);
        font-weight: bold;
        background-clip: text;
        -webkit-background-clip : text;
        color: transparent;
        font-size: 40px;
        width: fit-content;
      }
    </style>
    <p>Hello Abin</p>
</template>
  
//自定义元素
class WelcomeText extends HTMLElement {
  constructor() {
    super();
    // 创建 shadow root
    var shadow = this.attachShadow({mode: 'open'});

    //使用template模版内容填充到自定义元素中
    let template = document.getElementById("my-paragraph");
    let templateContent = template.content;

    //使用 Node.cloneNode() 方法把模板内容拷贝到阴影的根结点上
    shadow.append(templateContent.cloneNode(true));
  }
}

customElements.define('welcome-text', WelcomeText);

利用 <slot> 标签向 Web 组件中插入内容。

查看slot标签兼容性

使用方法有点像vue里的具名插槽。只需要在template模版中添加一个插槽,就可以像这个槽内插入内容了,如下:

<template id="my-paragraph">
  <p><slot name="my-text">默认内容,可以是html代码块</slot></p>
</template>

插入方式如下(如果未定义插入的内容或者浏览器不支持slot属性,则会显示默认内容):

<my-paragraph>
  <span slot="my-text">插入的内容,可以是html代码块</span>
</my-paragraph>

这里还是改造下上面的欢迎文字组件,效果如下:

image.png

<welcome-text>
    <span slot="my-name">Abin</span>
</welcome-text>

<template id="my-paragraph">
<style>
  p {
    background-image: url(https://img0.baidu.com/it/u=2772029697,2839972417&fm=253&fmt=auto&app=138&f=JPEG?w=780&h=466);
    font-weight: bold;
    background-clip: text;
    -webkit-background-clip : text;
    color: transparent;
    font-size: 40px;
    width: fit-content;
  }
</style>
<p>Hello <slot name="my-name">World</slot></p>
</template>
  
//自定义组件部分省略了,没有改动

六、总结

实现 web component 的基本方法通常如下所示:

  1. 创建一个类或函数来指定 web 组件的功能;
  2. 使用 CustomElementRegistry.define() 方法注册新自定义元素,并向其传递要定义的元素名称、指定元素功能的类、以及可选的其所继承自的元素;
  3. 如果需要的话,使用 Element.attachShadow() 方法将一个 shadow DOM 附加到自定义元素上。使用通常的 DOM 方法向 shadow DOM 中添加子元素、事件监听器等等。
  4. 如果需要的话,使用 <template> 和 <slot> 定义一个 HTML 模板。再次使用常规 DOM 方法克隆模板并将其附加到您的 shadow DOM 中。
  5. 然后就可以像使用常规 HTML 元素那样,在页面任何位置使用自定义元素了。

关于Quark C的使用,过几天空闲了写几个组件再整理篇文章。我看到个Quark C的优秀案例,特效挺有意思的,大家可以看下:
页面地址github

我是喜欢归纳总结前端相关知识的前端阿彬,尽力持续输出原创优质文章,欢迎点赞关注😘

表情包2.webp

往期文章
# ☕ 通过和vue语法逐一比对,快速上手前端框架黑马svelte
# 🧙‍♀️css魔法:伪元素content ➕ css函数
# 玩转css逐帧动画,纯css让哥哥动起来💃
# 🕸2023 前端 SEO 无死角解读
# 我给自己搭建的前端导航网站,你们都别用🤪
# 2023 最新最细 vite+vue3+ts 多页面项目架构,建议收藏备用!
# 浅谈 强制缓存/协商缓存 怎么用?
# 2023 前端性能优化清单