Web Components
组件化是前端工程化重要的一环,UI 和 交互(或逻辑)的复用极大的提高开发效率以及减少代码冗余。
目前开源的组件库都是特定于框架的,比如:基于 Vue 的 Element UI,基于 React 的 Ant Design 等。也就是说这些组件库无法做到跨框架使用。
Web Component 是一组 web 原生 API ,可以创建可复用的自定义元素 (custom elements)。组件完全是原生 JavaScript API 开发的,可以跨框架使用。此外,web components 并不是一个单一的规范,而是三个独立的 web 技术的集合。
- Custom elements(自定义元素)
 - Shadow DOM (影子元素)
 - HTML template (HTML 模版)
 
基本实现思路:
- 创建类或者函数来实现组件的逻辑、交互功能
 - 创建 Shadow DOM 并附加在自定义元素上,往 Shadow DOM 中添加要展示的元素
 - 通过 customElements.define 方法注册
 - 在页面中使用注册的自定义元素
 
Custom elements
customElements存在于 window 全局对象上,对自定义元素提供支持,包含四个 API:define、get、whenDefined、 upgrade
customElements.define
自定义元素通过 customElements.define 方法注册,包含三个参数:
- 自定义元素名,格式:短线连接的字符串
 - 自定义元素构造器
 - 可选的,含有 extends 属性的对象。指定所创建的元素继承自哪个内置元素,可以继承任何内置元素
 
可以创建两种类型的自定义元素:
1. 自主定制元素
独立元素,不会从内置 HTML 元素继承
class WebCTitle extends HTMLElement {
  constructor() {
    super()
    this.attachShadow({ mode: 'open' })
    this.style()
    this.render()
  }
  style() {
    const style = document.createElement('style')
    style.textContent = `
        .title {
            color: red;
            font-size: 20px;
        }
    `
    this.shadowRoot.appendChild(style)
  }
  render() {
    const template = document.createElement('template')
    template.innerHTML = `
        <div class="title">Hello!</div>
    `
    this.shadowRoot.appendChild(template.content.cloneNode(true))
  }
}
customElements.define('webc-title', WebCTitle)
Usage
<webc-title></webc-title>
2. 自定义内置元素
元素继承并扩展自内置 HTML 元素
class WebCTitle extends HTMLParagraphElement {
  // 逻辑同上
}
customElements.define('webc-title', WebCTitle, {
  extends: 'p'
})
Usage
<p is="webc-title"></p>
自定义元素标准需要满足的条件:
- 必须用短线连接
 - 元素名必须小写
 - 不要多次注册
 - 元素不能自闭合
 
customElements.get
获取自定义元素的类,一般用于扩展第三方组件库。比如:
const WebCProp = customElements.get('webc-prop')
customElements.define('webc-props-plus', class extends WebCProp{
   constructor() {
    super()
    this.click = () => {}
  }
})
whenDefined
一般是将 <script> 标签放在页面底部,为的是让浏览器渲染引擎先解析 DOM,然后再解析 JavaScript。
当渲染引擎读取到自定义元素的时候,并不知道它是什么元素(此时注册脚本还没执行),一般来说当渲染引擎碰到一个不认识的元素的时候,会认为这是一个无效的元素。但是,为了原生元素区分,自定义元素的命名规则必须包含短线,所有当渲染引擎解析一个带有短线的非原生元素时,会认为是一个未定义的自定义元素,不会当作一个无效元素。当执行到注册自定义元素的代码时,就会将之前未定义的元素标记为定义的元素。
定义的元素对应的伪类选择器就是 :defined,未定义的元素对应的伪类选择器就是 :not(:defined)
通过这个伪类选择器,可以在定义元素之前的空白时间内,设置自定义元素的加载样式。
whenDefine 是元素定义后触发的回调,通常用于异步注册组件的时候。
<style>
  /* :not() 匹配不符合条件的元素 */
  :not(:defined) {
    display: block
    width: 200px;
    height: 200px;
    color: red;
    border: 1px solid pink;
  }
  :defined {
    color: blue;
  }
</style>
<when-define>loading...</when-define>
<script src="./whenDefine.js"></script>
class WhenDefine extends HTMLElement {
  constructor() {
    super()
    this.attachShadow({ mode: 'open' })
  }
  render() {
    const template = document.createElement('template')
    template.innerHTML = `<div>hello</div>`
    this.shadowRoot.appendChild(template.content.cloneNode(true))
  }
  connectedCallback() {
    this.render()
  }
}
setTimeout(() => {
  customElements.define('when-define', WhenDefine)
}, 3000)
customElements.whenDefined('when-define').then(() => {
  console.log('注册完成')
})
upgrade
upgrade 升级,如果在定义元素之前先通过 js 创建了元素,则元素实例并不是自定义元素类的实例,则通过 upgrade 将其升级:
 // 创建
  const webTitle = document.createElement('webc-title')
  // 定义并注册
  class WebTitle extends HTMLElement {}
  customElements.define('web-title', WebTitle)
  webTitle instanceof WebTitle // false
  // 升级元素
  customElements.upgrade(webTitle)
  webTitle instanceof WebTitle // true
生命周期
构造函数中可以指定自定义元素的生命周期,将会在不同阶段调用。具体包括四个:
- 
connectedCallback:当 custom element 首次被插入文档 DOM 时,被调用。 - 
disconnectedCallback:当 custom element 从文档 DOM 中删除时,被调用。 - 
adoptedCallback:当 custom element 被移动到新的文档时,被调用。 - 
attributeChangedCallback: 当 custom element 增加、删除、修改自身属性时,被调用。 
function updateStyle(ele) {
  const shadow = ele.shadowRoot
  shadow.querySelector('style').textContent = `
        .title {
            color: #fff;
            font-size: 24px;
            text-align: center;
        }
        .dv {
            width: ${ele.getAttribute('w')}px;
            height: ${ele.getAttribute('h')}px;
            background-color: ${ele.getAttribute('c')};
        }
    `
}
class WebCTitle extends HTMLElement {
  static get observedAttributes() {
    return ['w', 'h', 'c']
  }
  constructor() {
    super()
    this.attachShadow({ mode: 'open' })
    this.style()
    this.render()
  }
  style() {
    const style = document.createElement('style')
    this.shadowRoot.appendChild(style)
  }
  render() {
    const template = document.createElement('template')
    template.innerHTML = `
        <div class="title">
            <div class="dv">Hello!</div>
        </div>
    `
    this.shadowRoot.appendChild(template.content.cloneNode(true))
  }
  connectedCallback() {
    updateStyle(this)
  }
  disconnectedCallback() {
    console.log('从文档移除了')
  }
  adoptedCallback() {}
  attributeChangedCallback(name, oldValue, newValue) {
    console.log(name, oldValue, newValue)
    updateStyle(this)
  }
}
当元素属性变化后,要触发attributeChangedCallback()回调函数,必须通过 构造器的静态属性 observedAttributes() 的 get 函数来实现,函数返回一个数组,包含需要监听的属性名称:
static get observedAttributes() {
  return ['w', 'h', 'c']
}
slot
slot属性返回已插入元素所在的 Shadow DOM slot 的名称。
 connectedCallback() {
    console.log(this.querySelector('p').slot) // desc
  }
Shadow DOM
Shadow DOM 是很重要的 API,可以将结构、样式和行为隐藏起来,独立的将 DOM 附加到一个元素上与页面上的其他代码隔离。在微前端 qiankun 框架中就是通过 shadow DOM 来实现不同容器样式隔离的,还有 video 标签的按钮和控制器。
Shadow DOM 术语:
- 
Shadow host:一个常规 DOM 节点,Shadow DOM 会被附加到这个节点上
 - 
Shadow tree:Shadow DOM 内部的 DOM 树
 - 
Shadow boundary:Shadow DOM 结束的地方,也是常规 DOM 开始的地方
 - 
Shadow root: Shadow tree 的根节点
 
image.png
image.png
用法
Element.attachShadow
Element.attachShadow() 方法将一个 shadow root 附加到一个元素上。该方法接受一个对象参数,对象的属性为 mode,值为 open 或 closed。
- open:可以通过 js API Element.shadowRoot 属性获取到 Shadow DOM
 - closed:无法通过 js 获取 shadowRoot
 
image.png
将 shadow DOM 附加到元素后,就可以对 shadow DOM 进行常规的 DOM 操作了。
注意:Shadow DOM 内部的样式不会影响到外部,可以做到样式隔离。
shadowRoot
shadowRoot 就是通过 elements.attachShadow() 附加在元素上的 shadow DOM 的根结点。它是 ShadowRoot 的实例,继承关系是 ShadowRoot --> DocumentFragment --> Node。所以具有 DOM 的常规属性和方法。专属属性包括:
shadowRoot.host
附加的宿主 DOM 元素
shadowRoot.innerHTML
shadowRoot 内部的 DOM 树
shadowRoot.mode
只读,值为 open OR closed
Templates & Slots
Templates
当某段模版重复使用时,可以通过 template 模版去定义,生成一个文档片段,并且不会显示在页面中。可以通过 js 获取,然后添加到 DOM 中。
<template id="tpl">
  <div class="title">
    <div class="dv">hello</div>
  </div>
</template>
const template = document.querySelector('#tpl')
console.dir(template.content)
修改 render 方法
render() {
    const template = document.querySelector('#tpl')
    this.shadowRoot.appendChild(template.content.cloneNode(true))
  }
image.png
template.content 为 DocumentFragment 的实例,该构造器继承自 Node。可以通过 cloneNode 方法拷贝文档片段,添加到 shadow DOM 中。这样的话其实可以把 style 也定义在 template 中。
<template id="tpl">
  <style>
    .title {
      color: #fff;
      font-size: 24px;
      text-align: center;
    }
    .dv {
      width: 100px;
      height: 100px;
      background-color: red;
    }
  </style>
  <div class="title">
    <div class="dv">hello</div>
  </div>
</template>
class WebCTitle extends HTMLElement {
  constructor() {
    super()
    this.attachShadow({ mode: 'open' })
    this.render()
  }
  render() {
    const template = document.querySelector('#tpl')
    this.shadowRoot.appendChild(template.content.cloneNode(true))
  }
}
Slots
template 适用于固定 html 片段,但是在某些场景下元素不灵活。web component 支持 slots,类似于 Vue 中的 slot。并且支持具名插槽。
<template id="tpl">
  <div class="title">
    <div class="dv">
      <slot>默认标题</slot>
      <slot name="desc">默认描述</slot>
    </div>
  </div>
</template>
<webc-title>
  <span>标题</span>
  <p slot="desc">描述信息</p>
</webc-title>
image.png
slotchange
如果 添加/删除 了插槽元素,浏览器将监视插槽并更新渲染。另外,由于不复制 light DOM 节点,而是仅在插槽中进行渲染,所以内部的变化是立即可见的。因此无需执行任何操作即可更新渲染。但如果组件想知道插槽的更改,那么可以用 slotchange 事件。
slotchange事件会在插槽第一次填充时触发,并且在插槽元素的 添加/删除/替换 操作(而不是其子元素)时触发,插槽是event.target
class WebCList extends HTMLElement {
  constructor() {
    super()
    this.attachShadow({ mode: 'open' })
    this.render()
  }
  render() {
    const template = document.createElement('template')
    template.innerHTML = `
        <div>
            <h1><slot name="title">默认标题</slot></h1>
            <ul>
                <slot name="item"></slot>
            </ul>
        </div>
    `
    this.shadowRoot.appendChild(template.content.cloneNode(true))
  }
  connectedCallback() {
    // 默认执行一次
    this.shadowRoot.firstElementChild
      .querySelector('ul')
      .addEventListener('slotchange', e => {
        // e.target 为 slot
        // console.dir(e.target)
        // console.dir(e.target.name)
        // slot.assignedNodes({flatten: true/false}) – 分配给插槽的 DOM 节点。默认情况下,flatten 选项为 false。如果显式地设置为 true,则它将更深入地查看扁平化 DOM ,如果嵌套了组件,则返回嵌套的插槽,如果未分配节点,则返回备用内容。
        // console.dir(e.target.assignedNodes())
        // 分配给插槽的 DOM 元素(与上面相同,但仅元素节点)
        console.log(e.target.assignedElements())
        console.log(e.target.assignedElements()[1].assignedSlot) // 返回节点的插槽
      })
  }
}
customElements.define('webc-list', WebCList)
<webc-list id="list">
  <h3 slot="title">TODO list</h3>
  <li slot="item">mike</li>
  <li slot="item">rose</li>
  <li slot="item">jack</li>
</webc-list>
2 秒后动态增加 li 元素
const list = document.querySelector('#list')
setTimeout(() => {
  const li = document.createElement('li')
  li.setAttribute('slot', 'item')
  li.textContent = 'mark'
  list.appendChild(li)
}, 2000)
Shadow DOM 样式
shadow DOM 可以包含 <style> 和 <link rel="stylesheet" href=""> 标签。在使用 link 时,样式表是 HTTP 缓存的,因此不会为使用同一模板的多个组件重新下载样式表。
:host
:host 选择器可以选择 shadow 宿主(包含 shadow 树的元素)
例如,我们正在创建 <webc-dialog> 元素,并且想让它居中。那么需要对 <webc-dialog> 元素本身设置样式。这就需要用 :host 设置
<template id="tpl">
  <style>
    /* 这些样式将从内部应用到 custom-dialog 元素上 */
    :host {
      position: fixed;
      left: 50%;
      top: 50%;
      transform: translate(-50%, -50%);
      display: inline-block;
      border: 1px solid red;
      padding: 10px;
    }
  </style>
  <slot></slot>
</template>
<script>
  const tpl = document.querySelector('#tpl')
  customElements.define(
    'webc-dialog',
    class extends HTMLElement {
      connectedCallback() {
        this.attachShadow({ mode: 'open' }).append(tpl.content.cloneNode(true))
      }
    }
  )
</script>
<webc-dialog> Hello! </webc-dialog>
:host(selector)
与 :host 相同,但仅在 shadow 宿主与 selector 匹配时才应用样式。
例如,当 <webc-dialog> 具有 centered 属性时才将其居中:
<webc-dialog centered> Hello! </webc-dialog>
:host-context(selector)
与 :host 相同,但仅当外部文档中的 shadow 宿主或它的任何祖先节点与 selector 匹配时才应用样式。
例如,:host-context(.dark-theme) 只有在 <webc-dialog> 或者 <webc-dialog> 的任何祖先节点上有 dark-theme 类时才匹配:
<body class="dark-theme">
  <!-- :host-context(.dark-theme) 只应用于 .dark-theme 内部的 webc-dialog-->
  <custom-dialog> hello </custom-dialog>
</body>
shadow 宿主
shadow 宿主( <webc-dialog> 本身)驻留在 light DOM 中,因此它受到文档 CSS 规则的影响。
如果在局部的 :host 和文档中都给一个属性设置样式,那么文档样式优先。
<style>
  webc-dialog {
    padding: 0;
  }
</style>
这样就可以在 :host 规则中设置组件的 “默认” 样式,然后在文档中覆盖它们。唯一的例外是当局部属性被标记 !important 时,对于这样的属性,局部样式优先。
Slot 内容样式
占槽元素来自 light DOM,所以使用文档样式。局部样式不会影响占槽内容。
在下面的例子中,按照文档样式,占槽的 <span> 是粗体,但是它不从局部样式中获取 background:
<style>
  span {
    font-weight: 600;
  }
</style>
<webc-card>
  <div slot="username">
    <span>Jack</span>
  </div>
</webc-card>
<script>
  customElements.define(
    'webc-card',
    class extends HTMLElement {
      connectedCallback() {
        this.attachShadow({ mode: 'open' })
        this.shadowRoot.innerHTML = `
          <style>
              span {
                  background: red;
              }
          </style>
          Name: <slot name="username"></slot>
        `
      }
    }
  )
</script>
结果是粗体,但背景不是红色。
如果想在组件中设置占槽元素的样式,有两种选择。
- 可以对 
<slot>本身进行样式化,并借助 CSS 继承: 
customElements.define(
  'webc-card',
  class extends HTMLElement {
    connectedCallback() {
      this.attachShadow({ mode: 'open' })
      this.shadowRoot.innerHTML = `
      <style>
          slot[name="username"] {
              font-weight: bold;
          }
      </style>
      Name: <slot name="username"></slot>
    `
    }
  }
)
- 
使用
::slotted(selector)伪类。它根据两个条件来匹配元素:
- 这是一个占槽元素,来自于 light DOM。插槽名并不重要,任何占槽元素都可以,但只能是元素本身,而不是它的子元素 。
 - 该元素与 
selector匹配。 
 
在例子中,::slotted(div) 正好选择了 <div slot="username"> ,但是没有选择它的子元素:
<webc-card>
  <div slot="username">
    <div>jack</div>
  </div>
</webc-card>
<script>
  customElements.define(
    'webc-card',
    class extends HTMLElement {
      connectedCallback() {
        this.attachShadow({ mode: 'open' })
        this.shadowRoot.innerHTML = `
          <style>
              ::slotted(div) {
                  border: 1px solid red;
              }
          </style>
          Name: <slot name="username"></slot>
        `
      }
    }
  )
</script>
CSS 变量
通过给自定义元素中定义 CSS 变量,在 shadow DOM 中的 style 中使用 CSS 变量,这种方式用在开发三方组件库时,可以做到样式自定义。
事件
冒泡
Shadow DOM 内部触发的事件大都可以向上冒泡。如果是一个 slot 元素,并且事件发生在内部某个地方,那么会冒泡到 <slot> 并继续向上。
event.composedPath
使用 event.composedPath() 获得原始事件目标的完整路径及所有 shadow 元素。比如:
customElements.define(
  'webc-dialog',
  class extends HTMLElement {
    connectedCallback() {
      this.attachShadow({ mode: 'open' }).append(tpl.content.cloneNode(true))
      this.shadowRoot.querySelector('slot').addEventListener('click', e => {
        console.log(e.composedPath())
      })
    }
  }
)
image.png
对于 span 上的点击事件,调用 event.composedPath()会返回一个数组,[span, slot, div, document-fragment, webc-dialog, body, html, document, Window]
event.composed
大多数事件能成功冒泡到 shadow DOM 边界。很少有事件不能冒泡到 shadow DOM 边界。
由事件对象的 composed 属性控制。如果 composed 是 true,那么事件就能穿过边界。否则它仅能在 shadow DOM 内部捕获。
自定义事件
组件中事件的处理以及向组件外部暴露事件通过自定义事件来处理。
当 dispatch 自定义事件需要设置 bubbles 和 composed 为 true 使的事件冒泡。
比如 shadow 内部触发了某个事件,需要暴露出 API 以供用户使用,则通过 dispath 事件,并可以传递参数。
CustomEvent
构造器,参数一是自定义事件类型,参数二对象,detail 为传递的参数。
customElements.define(
  'webc-dialog',
  class extends HTMLElement {
    connectedCallback() {
      this.attachShadow({ mode: 'open' }).append(tpl.content.cloneNode(true))
      this.shadowRoot.querySelector('slot').addEventListener('click', e => {
        this.dispatchEvent(
          new CustomEvent('tab-click', {
            bubbles: true,
            composed: true,
            detail: 123
          })
        )
      })
    }
  }
)
Usage
<webc-dialog id="dialog"> <span> Hello! </span> </webc-dialog>
<script>
  document.querySelector('#dialog').addEventListener('tab-click', e => {
    // ...
  })
</script>
注意
大部分内建事件的 composed 是 true,有些内建事件是 false。比如:
- 
mouseenter,mouseleave - 
load,unload,abort,error selectslotchange
处理数据的方式
Attributes
Attributes 是和HTML相关的概念,是我们定义HTML元素(即HTML标记)特征的方式,同样适用于 web components。
对于一下元素的, width、height、src、alt是attr。
<img src="./xx.png" alt="hello" width="200" height="200" />
当渲染引擎解析 HTML 代码以创建 DOM 对象时,它会识别标准属性并从中创建 DOM 属性。但这只限于标准属性中,而不是自定义属性。此外,并非所有元素的标准属性都相同。例如,id是所有元素通用的标准属性,而alt属性只是img上的。
对于不自动映射的自定义属性,采用一下方法进行操作:
element.hasAttributes(): 元素是否至少具有一个属性
element.hasAttribute(name): 元素是否具有某个属性
element.setAttribute(name, value):给元素设置属性
element.getAttribute(name):返回名为name属性的值,如果不存在则返回 null
element.getAttributeNames():返回元素属性组成的数组
element.toggleAttribute(name):切换给定元素的某个布尔值属性的状态(如果属性不存在则添加属性,属性存在则移除属性)
<webc-list title="hello"></custom-list>
<script>
class WebcList extends HTMLElement {
  constructor() {
    super();
    console.log(this.getAttribute("title"));
  }
}
customElements.define("webc-list", WebcList);
</script>
构造器方法内部的 this 即自定义元素的实例。
使用类的静态 getter 方法 observedAttributes 列出要监听的属性,当属性变化后会触发生命周期attributeChangedCallback 函数。
优点:直观、简单
Properties
Properties是与 JavaScript 相关的概念。它们是 DOM Node 接口的一部分。
prop可以是任何类型的值,并且属性名区分大小写。
prop 反映射 到attr
对于标准attr,attr 会和 prop 建立映射关系。 prop 是 HTML attr的JavaScript的表示。就意味着当一个prop被修改时,attr也会改变,反之亦然。
对于自定义元素,需要在定义自定义元素时明确地执行此操作。
通过为我们希望将其值映射到其同名attr的prop定义 getter 和 setter 方法来做到这一点。
class {
  set color(value){
    this.setAttribute('color', value)
  }
  get color(){
    this.getAttribute('color')
  }
}
image.png
假设我们有一个自定义元素,其类有一个名为的属性color,我们希望反映它的同名属性。给出这种情况,代码如下:
 <img src="aa.jpg"/>
setTimeout(() => {
  document.querySelector('img').src="bb.jpg"
},1500)
以上示例,img的src会在1.5s后改变。
浏览器引擎会为标准的 HTML attr 创建 prop,但不会为自定义元素创建。
使用 Attributes 适用于传递字符串。否则无论传递什么类型的数据都会被转成字符串,比如数字、函数、对象、数组等。
传递对象需要通过 JSON.stringify 序列化成字符串,在组件中通过 JSON.parse 反序列化成对象。这种方式很不合理,当对象很大时,不仅繁琐而且 DOM 结构冗余。
既然 DOM 也是对象,那么是不是可以给其任意的添加删除属性?当然可以。
const dialog = document.querySelector('#dialog')
dialog.list = [1, 2, 3]
console.log(dialog.list)
这种方式不仅可以传递对象,甚至可以传递任何类型的数据。
优点:可以处理任何数据类型。更适合处理复杂数据类型
自定义事件
就是以上提到的自定义事件
event bus
这种方式不再是监听自定义元素的事件,而是定义了一个全局的事件总线,这样就可以在任何地方使用。类似于 Node 中的 EventEmitter 模块,不同的是让 DOM 管理事件。
class EventBus {
  constructor() {
    this._bus = document.createElement('div')
  }
  regisger(event, callback) {
    this._bus.addEventListener(event, callback)
  }
  remove(event, callback) {
    this._bus.removeEventListener(event, callback)
  }
  fire(event, detail = {}) {
    this._bus.dispatchEvent(new CustomEvent(event, detail))
  }
}
const bus = new EventBus()
bus.regisger('clicked', () => {
  console.log(123)
})
bus.fire('clicked', { value: 23 })
优势:适合组件之间的通信
总结
web components 的诸多好处:
封装
封装是 web components最重要的特性和好处。封装确保我们的代码与组件所属页面中已经存在的任何框架或功能的任何其他元素隔离,从而避免冲突和不可控的行为。
可重用性
封装和 ESM 实现了可重用性。使得开发者能够编写可在许多站点和平台上使用的可重用组件。
灵活性
可以通过多种方式自定义 Web 组件。例如,使用attributes/properties自定义行为,使用slot自定义内容,使用 CSS 变量自定义样式。提供了很大的灵活性,使得一个原始组件可以有很多不同的形态。
表现
web components 为以前只能使用第三方库提供的某些功能提供了标准规范。这样可以免除外部依赖。同时意味着可以减少我们的代码和捆绑包的复杂性和大小,从而缩短加载页面时间。
兼容性
现代浏览器都有很好的兼容。
对于旧版浏览器中不可用的功能,可以使用 polyfill,例如 https://www.webcomponents.org