深入了解web component(一)

477 阅读10分钟

image.png

引言

最近在实习过程中,有注意到公司的项目使用到了微前端,于是鄙人便去简单了解了一下微前端,发现目前现有的微前端框架中,single-spa自身并没有解决css样式隔离的问题,他主要关注了微前端应用的下载、挂载和生命周期管理等。而基于single-spa进一步封装的qiankun框架,他主要是通过Shadow Dom和命名空间的方式来实现了css样式隔离的问题,使得全局 CSS 不会影响子应用,子应用间的 CSS 不会互相影响,子应用的CSS不会影响主应用全局,主应用的全局 CSS 不会影响子应用。同样对于wujie这个微前端框架,他是通过iframe和webcomponent的方式来实现dom和css样式的隔离,同时也是利用了webcomponent实现了应用可以获得类似vuekeep-alive的能力.这也是wujie这个微前端框架的一个优势所在。

为此,我也是简单的去学习了一下web component的,以下是我的一个简单的学习总结吧

1. 什么是web component

Web Components 是一套不同的技术,允许你创建可复用的自定义元素(自定义元素、阴影DOM、HTML模板)并且扩展HTML。 主要包括:

  1. 自定义元素(Custom Elements):允许你定义属于自己的HTML标签,并定义它们的功能。这里所说的HTML标签就是我们常见的<p>标签,<div>标签等等。自定义元素标签就是指<hello-world>等这种本身不存在规范中的标签。
  2. 阴影DOM(Shadow DOM):允许你封装DOM subtree和CSS样式,隐藏其细节。用一个通俗的比喻来说,Shadow DOM 就像一个孩子的密码本。密码本的内容是隐藏的,不会暴露在父亲母亲那里。同样,Shadow DOM 也将其中的 DOM 结构和样式隐藏起来,不会影响外界的 DOM。
  3. HTML模板(HTML Templates):允许你定义不会直接渲染的HTML片段,之后可以通过JavaScript实例化。HTML模板就指的是<template>这个标签,它本身是不会直接渲染到页面上的,即使你在body中使用了这个<template>标签
<!DOCTYPE html>
<html lang="en">
<body>
  <template>
    <div>我不会渲染到页面上</div>
  </template>
</body>

    
  1. 导入(HTML Imports):允许你在一个文档中导入另一个文档的内容。具体含义就是使用ES6的import导入,然后显示调用customElements.define()方法注册,这里就不做过多解释了。

所以Web Components主要由上述4种技术组成,目的是实现封装性好、可重用的 UI 组件,可以在不同框架或项目中复用。

下面我们来介绍一下他们的具体使用;

2.使用

对于我们程序员来说👨🏻‍💻,学习一门语言都是从打印hello,world开始,(最后我只精通各种语言helloworld的打印😭呜呜呜)那我们现在就实现一个类似helloworld的效果,如下图。

image.png

第一步: 创建一个类或函数来指定 web 组件的功能

 //这里我们创建了一个HelloWorld的类,继承自HTMLElement。其实自定义元素分为两类,一类是独立的元素,
 //一类是继承基本的html元素的元素。这里我们是写的独立元素,所以需要继承自HTMLElement类。
 class Helloworld extends HTMLElement{
      constructor() {
        super();
        const shadow=this.attachShadow({mode: 'open'});
        //这里的mode:open,就相当于打开这个黑盒子。
        const div=document.createElement('div')
        div.innerHTML='Hello,World';
        div.className='hello';
        const style=document.createElement('style')
        style.innerHTML='
          .hello{
            color:red;
            font-size:32px;
          }
        ';
        //在这里有小伙伴可能会想,为什么不在上面直接定义一个style标签,
        //而在下面又自己创建style标签呢?
        //因为shadow dom相当于一个黑盒,外面的样式和脚本是不会影响到黑盒内部的
        
        shadow.appendChild(style)
        shadow.appendChild(div)
      }
    }

第二步: 使用 CustomElement.define()方法注册您的新自定义元素,并向其传递要定义的元素名称、指定元素功能的类、以及可选的其所继承自的元素.

window.customElements.define('hello-world', HelloWorld);

随后我们就可以直接在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>Document</title>
  <style>
    .hello{
      color: red;
      font-size: 32px;
    }
  </style>
</head>
<body>
  <hello-world></hello-world>
  <script>
    class HelloWorld extends HTMLElement{
      constructor() {
        super();
        const shadow=this.attachShadow({mode: 'open'});
        const div=document.createElement('div');
        div.innerHTML='hello world';
        const style=document.createElement('style');
        style.innerHTML=`
          .hello{
            color: red;
            font-size: 32px;
          }
        `
        div.className = 'hello';
        shadow.appendChild(style);
        shadow.appendChild(div);
      }
    }
    customElements.define('hello-world', HelloWorld);
  </script>
</body>
</html>
    

这样就可以看到我们想要的效果啦😄!

有些小伙伴可能会有疑问,上面代码中const shadow=this.attachShadow({mode: 'open'});是什么意思?为什么mode的属性为open,而不是close呢?同时为什么要把创建的div标签和style标签通过appendChild的方式添加到shadow上?

下面我来一一解答!

1: const shadow=this.attachShadow({mode: 'open'});是什么意思?

我们先来看一张图:

image.png Shadow DOM 是一个 WEB 标准,它可以将隐藏的 DOM 树附加到常规 DOM 之中。用一个通俗的例子来理解 Shadow DOM:

Shadow DOM 就像一座楼房,楼房有公共区域和私人房间。公共区域是常规 DOM,任何人都可以访问,你的样式、行为都可能受外界影响。而私人房间就是 Shadow DOM 了,只有房间主人才能访问。在这个房间里:

  1. 你可以装饰你自己的家具(定义自己的 DOM 树),外面不能看到。
  2. 你可以把窗帘拉上(定义作用域仅内部的样式),外面的人看不到里面。
  3. 你在房间里跳舞,外面的人不会被影响(DOM 和样式都被封装在 Shadow DOM 内)。
  4. 但是房主仍然需要向房东交租(Shadow DOM 仍需要主 DOM 来挂载)。

这里,有一些 Shadow DOM 特有的术语需要我们了解:

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

掌握了这些概念,我们再回到刚才的问题

ele.attachShadow方法来将一个 shadow root 附加到任何一个元素上。它接受一个配置对象作为参数,该对象有一个 mode 属性,值可以是 open 或者 closed

    const shadow=this.attachShadow({mode: 'open'});
    const shadow=this.attachShadow({mode: 'closed'});

我们这里是将shadow root添加到了this身上,this指的就是这个类的实例对象

image.png

当然你也可以将shadow root添加到任何dom元素上,比如我们添加到一个类名为content的div上

image.png

open 表示可以通过页面内的 JavaScript 方法来获取 Shadow DOM. 例如可以通过let myShadowDom = myCustomElem.shadowRoot;来获取元素内部的shadow root 。如果你将一个 Shadow root 附加到一个 Custom element(自定义元素,比如<hello-world>) 上,并且将 mode 设置为 closed,那么就不可以从外部获取 Shadow DOM 了——myCustomElem.shadowRoot 将会返回 null

image.png 相信讲到这,上面的问题应该都迎刃而解了。

3.生命周期

定义在自定义元素的类定义中的特殊回调函数,影响其行为:

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

connectedCallback

使用:

我们接着延续之前的代码

    <!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>Document</title>
  <style>
    .hello {
      color: red;
      font-size: 32px;
    }
  </style>
</head>

<body>
  <hello-world></hello-world>
  <script>
    class HelloWorld extends HTMLElement {
      constructor() {
        super();
        console.log('constructor');
        const shadow = this.attachShadow({ mode: 'open' });
        const div = document.createElement('div');
        div.innerHTML = 'hello world';
        const style = document.createElement('style');
        style.innerHTML = `
          .hello{
            color: red;
            font-size: 32px;
          }
        `
        div.className = 'hello';
        shadow.appendChild(style);
        shadow.appendChild(div);
      }
      connectedCallback() {
        console.log('connected,被添加到dom tree上')
      }
    }
    customElements.define('hello-world', HelloWorld);
  </script>
</body>

</html>
    

image.png

disconnectedCallback

这个生命周期是当自定义元素与文档 DOM 断开连接时被调用,也就是自定义元素从文档DOM移除的时候调用。

接下来我们添加一个button按钮,当他被点击的时候,移除自定义元素

    
    <!DOCTYPE html>
<html lang="en">
<head>
 .....
</head>
<body>
  <button class="removeBtn">移除自定义元素</button>
  <hello-world></hello-world>
  <script>
    const removeBtn=document.querySelector('.removeBtn');
    const helloWorld=document.querySelector('hello-world');
    removeBtn.addEventListener('click',function(){
      helloWorld.parentNode.removeChild(helloWorld);
    })
    class HelloWorld extends HTMLElement {
      constructor() {
       //.....
      }
      connectedCallback() {
        console.log('connected,被添加到dom tree上')
      }
      disconnectedCallback() {
        console.log('disconnected,从dom tree上移除')
      }
    }
    customElements.define('hello-world', HelloWorld);
  </script>
</body>
</html>

点击button按钮,便移除了自定义元素

image.png

attributeChangedCallback生命周期。

当自定义元素的一个属性被增加、移除或更改时被调用。

下面代码中我们给<hello-world>这个自定义标签添加了一个color属性,并添加了attributeChangedCallback生命周期。

 
        
    <!DOCTYPE html>
<html lang="en">
<head>
    <!--.....-->
</head>
<body>
  <button class="removeBtn">移除自定义元素</button>
  <hello-world color='orange'></hello-world>
  <script>
    const removeBtn=document.querySelector('.removeBtn');
    const helloWorld=document.querySelector('hello-world');
    removeBtn.addEventListener('click',function(){
      helloWorld.parentNode.removeChild(helloWorld);
    })
    class HelloWorld extends HTMLElement {
      constructor() {
       //.....
      }
      attributeChangedCallback(name, oldVal, newVal) {
        console.log('attribute,改变')
        if (name === 'color') {
          //因为我们是这个shadow dom内部的div,如果你使用document.querySelector('.hello');的方式去获取是获取不到的
          const div = this.shadowRoot.querySelector('.hello');
          div.style.color = newVal; //这里我们将字体颜色改为了橙色
        }
      }
      connectedCallback() {
        console.log('connected,被添加到dom tree上')
      }
      disconnectedCallback() {
        console.log('disconnected,从dom tree上移除')
      }
    }
    customElements.define('hello-world', HelloWorld);
  </script>
</body>
</html>
    

上述代码你运行之后,你会发现并没有打印console.log('attribute,改变'),这是因为我们缺少了监听, 添加如下代码

    static get observedAttributes() {
        return [
          'color',
        ]
      }

这是类的静态属性,用于监听自定义元素哪些属性的变化,他返回一个数组,若监听多个属性,直接写在数组内返回即可。

adoptedCallback

当自定义元素被移动到新文档时被调用。

这里具体的含义就是说,当自定义元素从一个页面被移动到另一个页面时触发,那么如何将自定义元素移动到另一个页面呢?我们可以通过iframe来简单模拟实现。

这里我们新建一个html文件,iframe.html

<!-- iframe.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>Document</title>
</head>
<body>
  
</body>
</html>

然后我们在页面中通过iframe的方式引入,并添加一个button,并添加adoptedCallback该生命周期函数,完整代码如下

    
    <!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>Document</title>
  <style>
    .hello {
      color: red;
      font-size: 32px;
    }
  </style>
</head>
<body>
  <button class="removeBtn">移除自定义元素</button>
  <button class="moveBtn">移动到新页面</button>
  <hello-world color="orange"></hello-world>
  <iframe src="./demo.html" width="300px" height="300px"></iframe>
  <script>
    const removeBtn=document.querySelector('.removeBtn');
    const helloWorld=document.querySelector('hello-world');
    const moveBtn=document.querySelector('.moveBtn')
  
    moveBtn.addEventListener('click',function(){
      const iframe=document.querySelector('iframe');
      iframe.contentWindow.document.body.appendChild(helloWorld)
    })

    removeBtn.addEventListener('click',function(){
      helloWorld.parentNode.removeChild(helloWorld);
    })
    class HelloWorld extends HTMLElement {
      constructor() {
        super();
        console.log('constructor');
        const shadow = this.attachShadow({ mode: 'open' });
        const div = document.createElement('div');
        div.innerHTML = 'hello world';
        const style = document.createElement('style');
        style.innerHTML = `
          .hello{
            color: red;
            font-size: 32px;
          }
        `
        div.className = 'hello';
        shadow.appendChild(style);
        shadow.appendChild(div);
      }
      adoptedCallback() { //注意这个生命周期不是在dom间移动触发,而是在不同的page间移动被触发
        console.log('adopted,被移动到不同的page')
      }
      static get observedAttributes() {
        return [
          'color',
        ]
      }
      attributeChangedCallback(name, oldVal, newVal) {
        console.log('attribute,改变')
        if (name === 'color') {
          //因为我们是这个shadow dom内部的div,如果你使用document.querySelector('.hello');的方式去获取是获取不到的
          const div = this.shadowRoot.querySelector('.hello');
          div.style.color = newVal; //这里我们将字体颜色改为了橙色
        }
      }
      connectedCallback() {
        console.log('connected,被添加到dom tree上')
      }
      disconnectedCallback() {
        console.log('disconnected,从dom tree上移除')
      }
    }
    customElements.define('hello-world', HelloWorld);
  </script>
</body>
</html>
    

4.扩展

自定义元素:

主要分为:独立的元素继承基本的html元素的元素

1.独立元素: 它不继承其他内建的 HTML 元素。你可以直接把它们写成 HTML 标签的形式,来在页面上使用。 比如我们上面定义的<hello-world>元素 例如 <popup-info>,或者是document.createElement("popup-info")这样。

class PopupInfo extends HTMLElement {}

2:继承元素: 在创建时,你必须指定所需扩展的元素(正如上面例子所示),使用时,需要先写出基本的元素标签,并通过 is 属性指定 custom element(自定义元素) 的名称。例如<p is="word-count">, 或者 document.createElement("p", { is: "word-count" })。

class PopupInfo extends HTMLUListElement {} 这表示继承自ul标签

template和slot

到这里我们可能会想,如果我这个自定义标签(<hello-world>)里面不止一个div 或者还有其它html标签,我们难道都需要通过document.createElemen来创建吗 答案肯定不是的。到这里就需要引出template了。 <template><slot> 元素使您可以编写不在呈现页面中显示的标记模板。然后它们可以作为 自定义元素结构的基础被多次重用。 这句话的意思就是,你在body里面写的template标签是不会被渲染到页面上的,它仅仅会作为 我们自定义元素的一个模板而已。

那么如何使用呢?我们还是实现上面的效果,只不过我们不自己创建div了

    <template>
      <div class="hello">hello component</div>
    </template>
    class HelloWorld extends HTMLElement{
      constructor() {
        super();
        const shadow=this.attachShadow({mode:'open'})
        const temolateDom=document.getElementById('hello')
        const cloneContent=temolateDom.content.cloneContent(true)
        shadow.appendChild(cloneContent)
        const style=document.createElement('style');
        style.innerHTML=`
          .hello{
            color: red;
            font-size: 32px;
          }
        `
        div.className = 'hello';
        shadow.appendChild(style);
      }
    }
    

看到这里是不是觉得跟vue很像,没错,我也这么觉得。接下来的slot你会觉得更像

尽管到这一步已经挺好了,但是元素仍旧不是很灵活。我们只能在里面放一点文本,甚至没有普通的 p 标签管用!我们使用 <slot> 让它能在单个实例中通过声明式的语法展示不同的文本。浏览器对这个功能的支持比<template>少,在Chrome 53, Opera 40, Safari 10,火狐 59 和 Edge 79 中支持。

slot有一个name属性,提供一个唯一标识。它相当于一个占位符。当你在标签中使用到它时,该占位符(slot)可以填充所需的任何 HTML 标记片段。

  <!DOCTYPE html>
<html lang="en">
<body>
  <hello-world>
    <div slot="name-slot">呜呜呜</div>
  </hello-world>
  <template id="hello">
    <div class="hello">hello component</div>
    <slot name="name-slot">default content</slot>
  </template>
  <script>
    class HelloWorld extends HTMLElement {
      constructor() {
        super();
        const shadow = this.attachShadow({ mode: 'open' })
        const temolateDom = document.getElementById('hello')
        const cloneContent = temolateDom.content.cloneNode(true)
        shadow.appendChild(cloneContent)

        const style = document.createElement('style');
        style.innerHTML = `
          .hello{
            color: red;
            font-size: 32px;
          }
        `
        shadow.appendChild(style);
      }
    }
    customElements.define('hello-world', HelloWorld);
  </script>
</body>

</html>  
    
   

我们在使用自定义元素的时候,通过<div slot="name-slot">呜呜呜</div>,slot属性来指定我们使用template中的哪个slot,如果没有使用slot,那么slot默认会展示default content

总结:

1:template:html新标签,可重用,不会渲染到dom tree上,被用于定义webcomponent的html结构:

2:slot: html新标签,灵活动态的为webcomponent插入child,基本被限定在template中使用

总结

以上就是我近期对于web component的理解,这篇文章主要讲述web component的基本使用和基本概念,后续还会继续更新webcomponent在框架微前端中如何使用,以及web component的综合案例。如有不对的地方,恳请批评指正。