Web Components系列文章(一) - 基本概念和用法

1,722 阅读6分钟

Web components其实就是可重用的组件,就像Vue或者React等前端框架支持组件化开发一样,只不过依赖浏览器厂商来实现这些标准,从而原生支持自定义组件。而不是在浏览器里有一个Vue或者React的运行时。因此是跨平台、与框架无关的可扩展组件。可以用来开发跨团队共享的UI组件,就可以不用限定consumer使用的框架类型和代码运行的平台。

分为以下三篇,会陆续更新:

可以先直接跳到 完整的Button组件例子,有个整体的代码结构印象。

Web components主要包括以下三个规范,浏览器实现这三个规范,而程序员调用规范规定的API:

Custom elements

即使用vanilla JS/HTML/CSS来创建自定义html标签,内部封装好逻辑和UI,就像其他内置的HTML标签一样可直接引用。同时,提供了生命周期钩子函数,以便在适当的时机进行调用。

用法

1. 注册自定义元素

// MyButton.js
window.customElements.define(tagName, constructor, options)
  • tagName:组件名称,采用kebab-case规则,且必须带-
  • constructor:在其中对组件进行具体定义。采用es6 class的方式定义,如果不需要对现有的html标签进行扩展则继承HTMLElement,以确保其拥有内置HTML标签的所有默认方法和属性:
// MyButton.js
class MyButton extends HTMLElement {
    constructor() {
        super()
        // do something
    }
}

customElements.define('my-button', MyButton)
  • options:可选,目前只有一个选项extends,当创建的元素需要继承某个已有的内置元素时。此时需要extends特定的标签类,例如HTMLButtonElement
// MyButton.js
// 可先暂时不用关心具体的实现,先了解整体用法,后面会具体讲如何定义一个自定义元素
class MyButton extends HTMLButtonElement {
    constructor() {
        super()
        // do something
    }
}
customElements.define('my-button', MyButton, {extends: 'button'})

2. 消费自定义元素

可直接像内置的HTML元素一样使用,在html模板中:

<!-- index.html -->
<!-- ... -->
<body>
    <my-button label="Click me"></my-button>
</body>

或者JS中使用:

document.createElement('my-button')
document.querySelector('my-button')
// ...

如果使用继承了内置元素的自定义组件,可以这么使用: 在html中使用:

<button is="my-button">click</button>

在js中使用:

document.createElement('button', {is: 'my-button'})

生命周期钩子

自定义组件提供了以下钩子,能够让我们在适当的时机进行相应操作,每个钩子就是一个函数: image.png

关于监听attributes的简要说明:

// 1. 该静态方法中返回需要观察的attributes名
static get observedAttributes() {return ['attr1', 'attr2']; }

// 2. 当观察的attributes发生变动时触发该钩子
attributeChangedCallback(attrName, oldValue, newValue) {
  // do something
  switch(attrName) {
      case 'attr1':
         //...
  }
}

补充说明:

  • 当用户初次使用标签并传入属性时,attributeChangedCallback就会被调用一次
  • attributeChangedCallback只能监听attributes,无法监听元素property的变化
  • 所有钩子都是同步的

关于attribute和property

简要说明一下两者的区别:

  • attribute: HTML元素上的属性,只接受字符串,例如下面的label就是一个attribute:
<my-button label="click me"></my-button>

要传递引用类型的数据或者绑定方法,需通过property:

  • property: 从JS层面来说每个html标签都是一个JS对象:
const myButton = document.querySelector('my-button');
myButton.label = 'Click Me!';

Reflecting properties to attributes

这个的意思是什么呢,就是当通过JS修改property后,最好将修改同步到attribute:

<!-- index.html -->
<my-button disabled></my-button>
// MyButton.js
get disabled() {
  return this.hasAttribute('disabled');
}

set disabled(val) {
  // Reflect the value of `disabled` as an attribute.
  if (val) {
    this.setAttribute('disabled', '');
  } else {
    this.removeAttribute('disabled');
  }
}

当通过JS修改property:

myButton.disabled = false

html变为:

<my-button disabled></my-button>

这么做的原因主要是可以保持DOM的呈现和JS State同步。

在监听属性变化的回调可以直接根据property:

  // Only called for the disabled and open attributes due to observedAttributes
  attributeChangedCallback(name, oldValue, newValue) {
    // When the drawer is disabled, update keyboard/screen reader behavior.
    if (this.disabled) {
     
    } else {
      
    }
    // TODO: also react to the open attribute changing.
  }

注意:如果是引用类型的数据,则完全没有必要再同步到attribute上了。可以直接在setter中进行更新渲染等操作而不是通过attributeChangedCallback

兼容性

可见所有IE浏览器不支持,不过全球份额也只有0.X%。

pollyfill: webcomponentsjs

Shadow DOM

其主要作用是提供了CSS和DOM的隔离。通过一组JS API能创建一棵shadow tree,可插入到当前html节点中。Shadow tree内部的css与文档其他部分是完全隔离的,不会相互影响。因此内部也能用更通用的CSS选择器,而不必担心命名冲突,避免全局污染。

另外shadow tree中的元素通过document.querySelector无法被获取到,只能通过先拿到shadow tree的根节点shadowRoot,用shadowRoot.querySelector这种方式访问。

用法

1. 创建Shadow DOM Tree

const shadowRoot = element.attachShadow({mode: 'open'})

这个element可以是自定义组件:

// ...
   constructor() {
       super()
       this._shadowRoot = this.attachShadow({ mode: 'open' });
       this._shadowRoot.appendChild(someTemplateDom.content.cloneNode(true));
   }

也可以是其他内置的HTML标签。该方法在自定义元素内部挂载了一棵Shadow Tree,并且返回对根节点ShadowRoot的引用。shadowRoot继承了Node,因此拥有普通htmlNode节点的属性和方法。此外还有一些自己的一些属性和方法:MDN shadowRoot

PS: 并非所有标签下都能挂载Shadow DOM,这里给出了一个可以在下面挂载Shadow DOM的元素列表:MDN element.attachShadow()

mode用来控制外部是否能过获取到shadowRoot,从而对shadow tree内部进行节点访问。

  • open: 表示Shadow Tree之外的JS代码能访问到其内部的元素,即通过element.shadowRoot能获取到shadowRoot的引用。
  • closed:反之。当调用element.shadowRoot时返回null

2. 给ShadowRoot添加子节点

就像其他标签节点一样可以通过appendChild添加:

this._shadowRoot.appendChild(someTemplateDom.content.cloneNode(true));

浏览器中: image.png

兼容性

HTML template

用法

提供了组件模板以复用。另外还包含slot功能,可以给slot指定名字变成具名slot,用过Vue的同学会很熟悉了,就跟Vue里slot的用法一毛一样:

<template id="myTpl">
  <style>
      p {
          color: pink;
      }
  </style>
  <p class="name">Name</p>
  <slot></slot>
</template>

这段html不会被渲染但是能通过DOM API获取:

// ...
    constructor() {
        super()
        const templateContent = document.querySelector('#tpl-id').content

        // 由于模板需复用,在将模板内容添加到节点里时,需cloneNode
        const shadowRoot = this.attachShadow({mode: 'open'})
        shdowRoot.appendChild(templateContent.cloneNode(true))
    }

兼容性

image.png

关于样式

  • :host-context(<selector>) 当祖先元素中匹配到selector,则会应用对应的样式,常用于主题色:
<body class="darktheme">
    <my-comp></my-comp>
</body>
// my-comp.js
:host-context(.darktheme) {
    color: #fff;
    background: black;
}

在组件内部加好css varible hook,在组件外部定义的CSS varibles能可覆盖组件内定义的值:

<!-- index.html -->
my-comp {
    --main-text-color: #fff;
}

<!-- my-comp -->
:host {
    --main-text-color: #eee; // 如果组件外部没有定义,将采用该值。外部的my-comp标签权重大于:host
}
div {
    color: var(--main-text-color);
}

此时,需要组件开发者通过文档或者其他形式,告知使用者支持哪些变量。

完整的Dialog组件例子

下面是一个简单的自定义Dialog组件例子:

const myDialogTpl = document.createElement('template');
myDialogTpl.innerHTML = `
    <style>
        :host {
            display: block;
        }
        .my-dialog {
            display: none;
        }
        :host([visible]) .my-dialog {
          display: block;
        }
        .mask {
            position: fixed;
            top: 0;
            left: 0;
            width: 100vw;
            height: 100vh;
            background: rgba(0, 0, 0, .4);
            z-index: 999;
        }
        .content {
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            width: 80%;
            z-index: 1000;
            background: #fff;
            border: 1px solid #a1a1a1;
            box-shadow: 0 2px 4px 0 rgba(0,0,0, 0.05), 0 2px 8px 0 rgba(161,161,161, 0.4);
        }
        .dialog-hd {
            position: relative;
            padding: 20px 16px 16px 16px;
            border-bottom: 1px solid #ddd;
        }
        .title {
            font-size: 18px;
            font-weight: 600;
            text-align: center;
        }
        .btn-close {
            position: absolute;
            right: 16px;
            top: 50%;
            transform: translateY(-50%);
            width: 20px;
            height: 20px;
            background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAMKADAAQAAAABAAAAMAAAAADbN2wMAAAB/ElEQVRoBe2Yv26DMBDGU6BD5kwwRTxA9+xdsmSr+hZ9mL5Etw6Zs0d9CcasMCBlSihfhSXLCdzZvkioOi8QfH++39lgx4uFNq2AVkAroBXQCjAqsNlsluv1el+W5SvDPMgEsZEDubgBnjiGCHg6nfa9LcSf0zTdVVV14PhybSD+crkgB8QfiqLYHY/HM+VPAjjiTTxRCEe8ycGCSI312DXLsu++b+v0P3dd97ZarX7quq6cPq+fI+IRo2zb9qVpmq+pgMlUJ/qSJPnsL/eGcokhhwAqxlj/hHi4nIfcY+5/z8kpBCsqUcg7IRWTBSANISUeutgAUhCS4r0BYiGkxQcBhEI8QnwwgC/Eo8RHAXAhYGetsPhpt+gF0esltjObe6q6g929vU20eMSOBkAQAgImbhMRj6AiAAjkASEmXhSACSEqHjnJvRCM5tx0CmF0POa/GUyxqRQ9AoR4sw2f52eUEo9tNko+y4WMI978b/axNXOMew2aQiGCQnw4EN4AMUJifMdgvAAkBEjEsGHYAJKJJWOxACQTmupJxSQBpBIZ4fZVIja5F7perx990ocsRPjMDmuFWfBsvuWQ2352c08C5Hn+3nu556BiW4EJiMOQ+0a0/YCcQjB2zkfFxNtCnOnEOhe1/cl7QMzxeJ0UrgZaAa2AVkAr8K8r8AsyeML+4ho6NwAAAABJRU5ErkJggg==') no-repeat center center;
            background-size: 100% 100%;
        }
        .dialog-bd {
            padding: 20px 16px;
            border-bottom: 1px solid #ddd;
        }
        .dialog-ft {
            display: flex;
        }
        .btn {
            display: block;
            flex: 1;
            height: 48px;
            line-height: 48px;
            text-align: center;
            font-size: 18px;
            background: none;
            border: 0;
        }
        .btn.hidden {
            display: none;
        }
        .btn-primary {
            color: #0088c6;
        }
        .btn-secondary {
            color: rgba(0, 0, 0, .6);
        }
    </style>
    <div class="my-dialog">
        <div class="mask"></div>
        <div class="content">
            <div class="dialog-hd">
                <div class="title"></div>
                <span class="btn-close"></span>
            </div>
            <div class="dialog-bd">
                <slot></slot>
            </div>
            <div class="dialog-ft">
                <button class="btn btn-secondary"></button>
                <button class="btn btn-primary"></button>
            </div>
        </div>
    </div>
`;
class MyDialog extends HTMLElement {
  constructor() {
    super();

    this._shadowRoot = this.attachShadow({ mode: 'open' });
    this._shadowRoot.appendChild(myDialogTpl.content.cloneNode(true));

    this.$dialog = this._shadowRoot.querySelector('.my-dialog');
    this.$title = this._shadowRoot.querySelector('.title');
    this.$confirmBtn = this._shadowRoot.querySelector('.btn-primary');
    this.$cancelBtn = this._shadowRoot.querySelector('.btn-secondary');
    this.$close = this._shadowRoot.querySelector('.btn-close');

    this.bindEvent();
  }

  get dialogTitle() {
    return this.getAttribute('dialogTitle');
  }
  // Reflecting a property to an attribute
  set dialogTitle(value) {
    this.setAttribute('dialogTitle', value);
  }
  get visible() {
    return this.hasAttribute('visible');
  }
  set visible(value) {
    if (value) {
      this.setAttribute('visible', '');
    } else {
      this.removeAttribute('visible')
    }
  }

  get hideConfirmBtn() {
    return this.hasAttribute('hideConfirmBtn');
  }
  set hideConfirmBtn(value) {
    value
      ? this.setAttribute('hideConfirmBtn', value)
      : this.removeAttribute('hideConfirmBtn');
  }

  get confirmText() {
    return this.getAttribute('confirmText') || 'Confirm';
  }
  set confirmText(value) {
    this.setAttribute('confirmText', value);
  }

  get hideCancelBtn() {
    return this.hasAttribute('hideCancelBtn');
  }
  set hideCancelBtn(value) {
    value
      ? this.setAttribute('hideCancelBtn')
      : this.removeAttribute('hideCancelBtn');
  }

  get cancelText() {
    return this.getAttribute('cancelText') || 'Cancel';
  }
  set cancelText(value) {
    this.setAttribute('cancelText', value);
  }

  static get observedAttributes() {
    return [
      'title',
      'visible',
      'hideConfirmBtn',
      'confirmText',
      'hideCancelBtn',
      'cancelText',
    ];
  }

  attributeChangedCallback(name, oldValue, newValue) {
    this.render();
  }

  render() {
    this.$title.innerHTML = this.dialogTitle;

    this.$confirmBtn.innerHTML = this.confirmText;
    this.hideConfirmBtn
      ? this.$confirmBtn.classList.add('hidden')
      : this.$confirmBtn.classList.remove('hidden');

    this.$cancelBtn.innerHTML = this.cancelText;
    this.hideCancelBtn
      ? this.$cancelBtn.classList.add('hidden')
      : this.$cancelBtn.classList.remove('hidden');
  }

  bindEvent() {
    this.$close.addEventListener('click', () => {
      this.dispatchEvent(new CustomEvent('close'));
    });

    this.$confirmBtn.addEventListener('click', () => {
      this.dispatchEvent(new CustomEvent('confirm', {}));
      this.dispatchEvent(new CustomEvent('close'));
    });

    this.$cancelBtn.addEventListener('click', () => {
      this.dispatchEvent(new CustomEvent('cancel'));
      this.dispatchEvent(new CustomEvent('close'));
    });
  }
}

window.customElements.define('my-dialog', MyDialog);

在html中的使用:

<body>
    <my-button label="Open Dialog"></my-button>
    <my-dialog dialogTitle="Welcome">
            <p class="dialog-para">Hello world!</p>
    </my-dialog>

    <script src="components/MyButton.js"></script>
    <script src="components/MyDialog.js"></script>
    <script>
        const myDialog = document.querySelector('my-dialog')
        const myButton = document.querySelector('my-button')

        myButton.addEventListener('clicked', (event) => {
            console.log('event', event)
            myDialog.visible = true
        })

        myDialog.addEventListener('confirm', () => {
            console.log('Trigger Dialog Confirm')
        })
        myDialog.addEventListener('cancel', () => {
            console.log('Trigger Dialog Cancel')
        })
        myDialog.addEventListener('close', () => {
            myDialog.visible = false
        })

</body>

其中:host表示自定义组件本身。可通过:host(selector)进行进一步匹配,例如:

:host(:hover)
:host([disabled])

git传送门:my-web-component 。这里上传了更完整的代码,还包括Button和Dropdown组件,其中Dropdown组件给出了如何处理引用类型的数据。

参考