我的第一个 Web Component

796 阅读4分钟

前言

2021.8.9 vue发布了 3.2.0 release。其中的内容有

  • Features xxx
  • Bug Fixes xxx 在 features 中我看到了这么一个 defineCustomElement api。点开文档文档,发现这就是早有耳闻的 web component。那么我就先来和 web component 来一场快乐的初体验吧。

WebComponent

web component docs 简单翻阅翻阅文档,以下为我的简单概况。 WebComponent由以下三个部分组成

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

Custom elements

使用 customElements.define(name, constructor, options) 的 web api 的定义一个自定义的元素标签。

  • name 自定义元素名
  • constructor
  • options?
    • extends 指定继承的已创建的元素. 被用于创建自定义元素

如下使用:

class Dialog extends HTMLElement {
    constructor() {
        super();
        // ....
    }
};

customElements.define('tree-dialog', Dialog);

这样就生成了一个 <tree-dialog></tree-dialog> 的标签可以在html中使用了

Shadow DOM

shadow dom写过小程序的可能比较熟悉一些。小程序的自定义组件在Wxml中的表现就是将你的组件模板放到了这个 #shadow-root

image.png

以此来达到私有的作用。在构造器中使用 this.attachShadow 这个api就可以使用 Shadow DOM。

class Dialog extends HTMLElement {
    constructor() {
        super();
        const shadow = this.attachShadow({ mode: 'open' });
        const wrapper = document.createElement('div');
        shadow.appendChild(wrapper);
    }
};

customElements.define('tree-dialog', Dialog);

image.png

这样就给这个组件添加了一个 dom 结构。

HTML templates

templates 大家应该都不陌生,使用呢也非常的方便,在html 写下自己需要的html结构

<html>
    <template id="dialog-template">
        <style>
            .wrapper {
              ...
            }
            ...
        </style>
        <div class="wrapper">...</div>
    </template>
</html>

使用的时候呢,只要获取到这个文档碎片"clone" 到我们的 shadow dom中即可

class Dialog extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    const template = document.querySelector("#dialog-tempalate").content;
    shadow.appendChild(template.cloneNode(true))
  }
};

customElements.define('tree-dialog', Dialog);

image.png

当然这里面还要其他知识点,例如 slot。这里就不多赘诉啦。

实现一个 Dialog 组件

过完以上的文档以及例子来实现一个 dialog 组件应该来说是木得问题。直接开始上手。

class Dialog extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    // 创建一个容器
    const wrapper = document.createElement('div');
    // 加上样式
    wrapper.setAttribute('class', 'wrapper');
    // 创建一个内容
    const content = document.createElement('div');
    // 加上样式
    content.setAttribute('class', 'wrapper-content');

    const style = document.createElement('style');
    style.textContent = `// 样式 .....`;
    
    wrapper.appendChild(content);
    shadow.appendChild(wrapper);
    shadow.appendChild(style);
  }
};

customElements.define('tree-dialog', Dialog);

结构和样式没问题,现在开始写显示隐藏等这些逻辑。样式取自 neumorphismv image.png

文本提示信息呢,可以通过 dataset 来传入文本信息,如。

<tree-dialog data-text="Hello World"></tree-dialog>

而构造器中呢,只需要这样就可以将文本插入到dom中

// content 为文本的dom容器
// this 指组件本身
content.innerText = this.getAttribute('data-text');

image.png

翻阅文档,可以看到。这几个生命周期

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

那么就可以通过 attributeChangedCallback 这个生命周期函数来切换组件的显示和隐藏。先写好初始的状态,然后加上 active 类就可以显示,再通过 show 这个属性的变化来加 active 类,并且一开始也要加上 show 的判断。

class Dialog extends HTMLElement {
    constructor() {
        ...
        wrapper.setAttribute("class", `wrapper ${this.getAttribute('show') === 'true' ? 'active' : ''}`);
        ...
    }
    attributeChangedCallback() {
        const shadowRoot = this.shadowRoot;
        const wrapper = shadowRoot.querySelector('.wrapper');
        wrapper.setAttribute("class", `wrapper ${this.getAttribute('show') === 'true' ? 'active' : ''}`);
      }
}

但是此时 active 这个类并没有加到 wrapper 这个dom身上。通过 console 发现 attributeChangedCallback 并没有调用。哦ho,那就是姿势不正确了。查阅文档发现

image.png 正确的姿势应该是在静态方法 observedAttributes 中告知组件需要注意那些属性变化。

class Dialog extends HTMLElement {
    static get observedAttributes() { return ['show']; }
    constructor() {
       ...
    }
    attributeChangedCallback() {
        ...
      }
}

这样就可以啦。然后再往组件中加入一个点击事件,当点击背景蒙层的时候关闭这个 dialog ,同样是通过声明周期的函数实现。

class Dialog extends HTMLElement {
  ...
  connectedCallback() {
    this.addEventListener('click', wrapperClick, true)
  }
  disconnectedCallback() {
    this.removeEventListener('click', wrapperClick, true)
  }
}

function wrapperClick(component) {
  const target = component.target;
  const shadowRoot = target.shadowRoot;
  const wrapper = shadowRoot.querySelector('.wrapper');
  (component.path[0] === wrapper) && target.setAttribute("show", false);
}

最终效果

213.gif

这个demo只是简单实现一下。当然如果是dialog这种全屏弹窗的组件最好还是 append 到 body 尾部比较好。原因的话可以看一下下面这篇文章 position: fixed 一定是相对于浏览器窗口进行定位吗?

结尾

祝大家中秋快乐~