Web Component

766 阅读5分钟

背景

Web Components 是指一系列web的原生API,我们可以用其来创建封装自定义的、可复用的原生组件,并且可以像HTML元素标签一样使用他们。由于组件是原生JavaScript API开发的,所以可以跨端使用。

回顾web Components 历史,其从2011年提出Web Components概念,2013年 Chrome 和 Opera 联合提出了推出的 V0 版本的 Web Components 规范,到2016年Web Components 推进到了 V1 版本。

目的

目前大多数开源的组件库都是针对于某种前端框架开发的,如react的antd,vue的element UI等,它们是无法跨端使用导致要维护多套代码。因此为了解决此问题,可使用web Components开发Web原生组件统一管理。已知taro和哈喽前端框架quark是基于web Components开发的,qiankun微前端框架也用了web components的shadow dom等,越来越多的公司包括谷歌也在推动浏览器的Web Components原生组件。

三个核心API

web components 有三个核心API,为别为:

  1. Custom elements(自定义元素):总结官网解释是,使用 CustomElementRegistry.define() 方法注册新自定义元素,并挂载到HTML中使用。
  2. Shadow DOM(影子DOM):是对DOM的一层封装,它可以将自定义标签的结构、样式和行为隐藏起来,并将其附加到自定义元素上,使其与页面上的其他代码隔离,不会受到其他的样式等等混淆影响。
  3. HTML templates(HTML模板):使用template和 slot 元素来自定义的HTML模版,被复用到其他自定义元素中,vue的同学应该熟悉它。

Custom elements

可以创建两种类型的自定义元素:

  1. 自定义元素 CustomElementRegistry接口提供注册自定义元素和查询已注册元素的方法。要获取它的实例,请使用 window.customElements属性。简单来说就是可直接使用window的customElements属性来调取CustomElementRegistry的API。
<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>
    <script src="./index.js" defer></script>
</head>
<body>
    <p>第一个custom-element例子</p>
    <element-details></element-details>
</body>
</html>

CustomElementRegistry有4个API:define、get、upgrade、whenDefined

  • CustomElementRegistry.define() customElements.define(name, constructor, options)定义一个新的自定义元素,参数解释: name:自定义元素名,不能是单个单词,必须要有短横线,元素名必须小写 constructor:自定义元素构造器,用于定义元素行为的class类。 options:可选参数,一个包含 extends 属性的配置对象,是可选参数。它指定了所创建的元素继承自哪个内置元素,可以继承任何内置元素。

PS: 为了原生元素区分,自定义元素的命名规则必须包含短线,所有当渲染引擎解析一个带有短线的非原生元素时,会认为是一个"未定义的自定义元素",不会当作一个无效元素

  • CustomElementRegistry.get() 返回指定自定义元素的构造函数,如果未定义自定义元素,则返回undefined。一般用于获取自定义元素的累,或者判断是否定义了某元素。
// 判断
if (!customElements.get('element-details')) {
    customElements.define('element-details', ElementDetails);
}
// 获取自定义类
const ElementDetailsElement = customElements.get('element-details')
customElements.define('element-details-get', class extends ElementDetailsElement{
    constructor() {
        super()

        this.render();
    }
    render() {
        console.log('继承了element-details,并渲染了element-details-get')
    }
})
  • CustomElementRegistry.upgrade() CustomElementRegistry 接口的 upgrade() 方法将更新节点子树中所有包含shadow dom的自定义元素
const el = document.createElement("element-upgrade");
class ElementUpgrade extends HTMLElement {
    constructor() {
        super();
        const shadow = this.attachShadow({mode: 'open'});
        const text = document.createElement("span");
        text.textContent = 'this is CustomElementRegistry.upgrade demo!';
        text.style = 'color: red';
        // el.classList.add('title');
        shadow.append(text);
    }
    
}
customElements.define("element-upgrade", ElementUpgrade);

console.log(el instanceof ElementUpgrade); // not yet upgraded, 输出false

customElements.upgrade(el);
console.log(el instanceof ElementUpgrade);    // upgraded! 输出true
  • CustomElementRegistry.whenDefined() 返回当使用给定名称定义自定义元素时将会执行的 promise。(定义元素完成时的回调)
customElements.whenDefined('element-details').then(() => {
    console.log('element-details注册完成')
})

生命周期 构造函数中可以指定自定义元素的生命周期,将会在不同阶段调用。具体包括四个:

  • connectedCallback:当自定义元素第一次被插入文档 DOM 时被调用。
  • disconnectedCallback:当 custom element 从文档 DOM 中删除时,被调用。
  • adoptedCallback:当 custom element 被移动到新的文档时,被调用。
  • attributeChangedCallback: 当 custom element 增加、删除、修改自身属性时被调用,前提需定义static get observedAttributes()静态属性。
<!-- ... -->
<body>
    <custom-square w="100" h="100" c="blue"></custom-square>
    <button class="remove">remove</button>
</body>
<!-- ... -->
class Square extends HTMLElement {
    // 定义了静态属性observedAttributes监听属性,才能触发attributeChangedCallback回调函数
    static get observedAttributes() {
        return ['w', 'h', 'c'];
    }
    
    constructor() {
        super();
        // 创建div-shadow方式
        const shadow = this.attachShadow({mode: 'open'});
        const div = document.createElement('div');
        shadow.appendChild(div);
        // 样式
        this.style();       
    }
    
    connectedCallback() {
        console.log('custom element 首次被插入文档 DOM ');
        updateStyle(this);
    }

    disconnectedCallback() {
        console.log('custom element 从文档 DOM 中删除');
    }

    adoptedCallback() {
        console.log('custom element 被移动到新的文档');
    }

    attributeChangedCallback(name, oldValue, newValue) {
        console.log('custom element 增加、删除、修改自身属性', name, oldValue, newValue);
    }

    style() {
        const style = document.createElement('style')
    
        this.shadowRoot.appendChild(style)
    }
}
function updateStyle(elem) {
    const shadow = elem.shadowRoot;
    shadow.querySelector('style').textContent = `
    div {
        width: ${elem.getAttribute('w')}px;
        height: ${elem.getAttribute('h')}px;
        background-color: ${elem.getAttribute('c')};
    }
    `;
}
customElements.define('custom-square', Square)


const remove = document.querySelector('.remove');
const squareEle = document.getElementsByTagName('custom-square')[0];

remove.onclick = function() {
    // Remove the square
    document.body.removeChild(squareEle);
}
  1. 自定义内置元素的扩展
  • is 全局 HTML 属性:允许您指定一个标准 HTML 元素应该表现得像一个已注册的自定义内置元素。
  • Document.createElement() 方法的“is”选项:允许您创建一个标准 HTML 元素的实例,表现得像一个给定的已注册的自定义内置元素。
<!-- ... -->
<body>
    <built-in-element>built-in-element方式失效了</built-in-element>
    <p is="built-in-element"></p>
</body>
<!-- ... -->

Shadow DOM

Shadow DOM是对DOM的一个封装。可以将标记结构、样式和行为隐藏起来,并与页面上的其他代码相隔离,保证不同的部分不会混在一起,可使代码更加干净、整洁。

与 shadow DOM 有关的 Element API:

  1. Element.attachShadow() 方法用于创建Shadow DOM,即创建一个 shadowRoot 附加到指定Element上。attachShadow接收一个对象参数,属性 mode值为 open 或 closed。
<style>
    p {
        color: blueviolet;
    }
</style>

<p>不会影响shadow样式</p>
<script>
    const header = document.createElement('header');
    const shadowRoot = header.attachShadow({mode: 'closed'});// header.shadowRoot === null
    // const shadowRoot = header.attachShadow({mode: 'open'});// header.shadowRoot === shadowRoot   	#document-fragment
    shadowRoot.innerHTML = '<p>Hello Shadow DOM</p>';
    document.querySelector('body').append(header);// shadowRoot.host === header
</script>

image

修改shadow dom内部样式:

image

  1. shadowRoot shadowRoot 是通过 elements.attachShadow() 附加在元素上的一个 shadow DOM ,shadowRoot重要API有:
  • shadowRoot.host 被附加shadow dom的宿主 DOM 元素,shadowRoot1.host === shadowHost1
  • shadowRoot.innerHTML shadowRoot 内部的 DOM 树
  • shadowRoot.mode 只读,值为 open OR closed open:可以通过 js API Element.shadowRoot 属性获取到 Shadow DOM closed:无法通过 js 获取 shadowRoot

Element.shadowRoot 属性mode: 'closed'时返回附加给特定元素的 shadow root,mode: 'closed'时返回 null。

CSS 伪类

与自定义元素特别相关的伪类:

  • :defined:匹配任何已定义的元素,包括内置元素和使用 CustomElementRegistry.define() 定义的自定义元素。
  • :host:为自定义元素define-element添加CSS样式, 样式定义在 shadow DOM 的 shadow host的style内。
  • :host():与:host作用一致,但只有括号所包含shadow host自定义元素才起作用。
  • :host-context(): 与:host()作用一致, 但只有shadow host自定义元素的父级匹配:host-context()括号内的配置才生效。
<define-element text="这是一个defind的demo">loading</define-element>
<define-element text="这是一个:host()的demo" custom-align="right">loading</define-element>
<define-element text="这是二个:host()的demo" class="color">loading</define-element>
<div class="host-content-wrap">
    <define-element text="这一个:host-context()的demo">loading</define-element>
</div>

templates and slots

  1. templates 可以编写不在呈现页面中显示的标记模板。然后它们可以作为自定义元素结构的基础被多次重用。
  • 被使用前不会被渲染。
  • 被使用前对页面其他部分没有影响,脚本不会运行,图像不会加载,音频不会播放。

template.content 为 DocumentFragment 的实例,该构造器继承自 Node。可以通过 cloneNode 方法拷贝文档片段,添加到 shadow DOM 中。这样的话其实可以把 style 也定义在 template 中。

<body>
    <p>会影响外部样式</p>
    <template id="my-paragraph">
        <style>
            p{color: red;}
        </style>
        <p>My paragraph</p>
    </template>
    <my-paragraph></my-paragraph>
    <script>
        customElements.define('my-paragraph',
            class extends HTMLElement {
                constructor() {
                    super();
                    let template = document.getElementById('my-paragraph');
                    let templateContent = template.content.cloneNode(true);

                    this.appendChild(templateContent);
                }
        })
    </script>
</body>

image

  1. slots web component 中的一个占位符(插槽),你可以填充自己的标记,这样你就可以创建单独的 DOM 树并将它们呈现在一起
  • slot样式: Slot template内局部样式无法应用于slot插槽内容,可使用slot[name="my-text"] 或者::slotted()解决
<body>
    <style>
        p{color: blueviolet;}
    </style>
    
    
    <p>会影响外部样式</p>
    <template id="my-paragraph">
        <style>
            p{color: red;}
        </style>
        <p>My paragraph</p>
        <slot name="my-text">My default text</slot>
    </template>
    
    
    <my-paragraph>
        <p slot="my-text">slot text</p>
    </my-paragraph>
    
    
    <script>
        customElements.define('my-paragraph',
            class extends HTMLElement {
                constructor() {
                    super();
                    let template = document.getElementById('my-paragraph');
                    let templateContent = template.content.cloneNode(true);
                    this.attachShadow({mode: 'open'}).appendChild(templateContent);
                }
        })
    </script>
</body>

image

实现一个计数器组件

<body>
    <template id="button-counter">
        <style>
            p{color: blueviolet;}
        </style>
        
        <span id="counter"></span>
        <button id="button"></button>
    </template>
    <button-counter counter="0" label="增加1"></button-counter>
    <script>
        document.querySelector('button-counter').buttonCounter=(val)=>{}
        
        // document.querySelector('button-counter').addEventListener('test', (val)=>{
        //     console.log(val.detail.counter);
        // })
        
        class ButtonCounter extends HTMLElement {
            constructor() {
                super();
                
                let shadowRoot = this.attachShadow({mode: 'open'});
                let el = document.querySelector('#button-counter').content.cloneNode(true);
                shadowRoot.appendChild(el);
                
                this.$counter = shadowRoot.querySelector('#counter');
                this.$button = shadowRoot.querySelector('#button');
                this.$button.addEventListener('click', () => {
                    this.counter++;
                    this.buttonCounter(this.counter);
                    
                    // this.dispatchEvent(
                    //     new CustomEvent('test', {
                    //         detail: {counter: this.counter},
                    //     })
                    // );
                });
            }
            get label() {
                return this.getAttribute('label');
            }
            set label(value) {
                this.setAttribute('label', value);
            }
            
            get counter() {
                return this.getAttribute('counter');
            }
            set counter(value) {
                this.setAttribute('counter', value);
            }
            
            static get observedAttributes() {
                return ['label', 'counter'];
            }
            attributeChangedCallback(name, oldVal, newVal) {
                console.log('attributeChangedCallback');
                this.render();
            }
            
            render() {
                this.$button.innerHTML = this.label;
                this.$counter.innerHTML = `You clicked me ${this.counter} times`;
            }
            
            connectedCallback() {
                console.log('connectedCallback');
            }
            
            disconnectedCallback() {
                console.log('disconnectedCallback');
            }
            adoptedCallback() {
                console.log('adoptedCallback');
            }
        }
        customElements.define('button-counter', ButtonCounter)
    </script>
</body>

image

兼容性

浏览器兼容

通过以上的操作,我们发现,用原生的Web Component API封装组件操作繁杂,需要处理大量的监听和DOM的渲染等等,也不符合我们现在的开发方式。因此,Stencil 应运而生

Stencil

Stencil 由 Ionic 核心团队推出, 为了解决Web Components繁杂的原生操作而生,Stencil支持 Typescript、JSX、虚拟 DOM 等,可以通过React的方式封装自定义组件,还提供了很多Web Components的语法糖和生命周期,文档完整,开箱即用。

地址:stenciljs.com/docs/introd…

MDN

Web Components:developer.mozilla.org/zh-CN/docs/…

官网demo:github.com/mdn/web-com…