👉 Custom Elements -- 原生JS实现组件化 WebComponent 的基石

1,553 阅读7分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第10天,点击查看活动详情

前言

相信大家都用过目前流行的一些诸如VueReactSvelteSolid等前端框架,这些框架都有组件化的功能,让我们能够将代码的逻辑以及UI封装到组件中,提高复用性

那么你是否好奇过不使用框架的前提下,如何实现组件化呢?这就要用到原生 js 提供的 API -- CustomElements

CustomElements允许我们定义新的 html 标签,可以是扩展现有的 html 标签,又或者是创建一个新的可复用的组件,这也就意味着它是实现WebComponents的基石

浏览器给我们提供了像 html 这样优秀的工具去构建 web 应用,它是声明式的,通过语义化的方式将应用结构声明好,无论是阅读还是维护都有良好的开发体验,并且很容易上手使用

虽然 html 挺好,但是它的标签数量始终有限,不能很好地覆盖我们所有业务场景的需要,但是在如今的浏览器环境中,给我们提供了一种很好地方式去扩展新的 html 标签,那就是customElements这个 API,使用它就好像在告诉浏览器,当遇到我自定义的 html 标签时要如何处理

接下来就让我们来体验一下定义一个 CustomElement 吧!

定义一个 CustomElement

定义一个新的 html 标签的方式很简单

  1. 首先我们要定义一个类,这个类继承自HTMLElement,在类里面声明访问器属性即可让该属性成为 html 标签的 prop
  2. 定义好类之后只需要调用customElements.define('标签名', 类)即可完成一个新的 html 标签的定义,使用的时候直接像用普通标签那样使用即可
;(() => {
  class Foo extends HTMLElement {
    get bar() {
      return this.hasAttribute('bar')
    }

    set bar(val) {
      if (val) {
        this.setAttribute('bar', val)
      } else {
        this.removeAttribute('bar')
      }
    }

    constructor() {
      super()
      console.log(`custom element x-foo prop: bar = "${this.bar}"`)
    }
  }

  const init = () => {
    customElements.define('x-foo', Foo)
  }

  init()
})()

然后我们在html中使用一下这个 CustomElement

<x-foo bar></x-foo>

html中使用CustomElement.png

customElements.define()这个 API 有几点需要注意:

  1. 标签名一定要有短横线分隔,这样才能和让浏览器知道这个元素是自定义的而不是浏览器内置的,这意味着即便你的自定义元素标签名确实只有一个单词,那你也需要加上一些前缀然后用短横线分隔,如<x-foo>,否则会抛出DOMException异常
customElements.define('foo', Foo)

自定义元素的标签名不规范时抛出DOMException异常.png

  1. 同一个标签名不能重复注册多次,否则也会出现DOMException异常
customElements.define('x-foo', Foo)
customElements.define('x-foo', Foo)

同一个标签名注册多次时抛出DOMException异常.png

  1. 使用 CustomElement 时,标签不能够是self-closing的,也就是不能以<x-foo />这样的方式去使用,虽然以self-closing的方式去使用自定义元素标签时不会遇到报错,但是还是建议遵循 html 的规定去使用

CustomElements 的生命周期

类似VueReact等框架中的组件生命周期,CustomElements也有自己的生命周期,我们可以在一些生命周期钩子方法中做一些操作,这些生命周期钩子有一个专有名词 -- custom element reactions

  • constructor: 构造函数本身也可以看作是一个生命周期钩子,在元素创建后和更新后都会被调用,但我们更多地还是用它来完成state的初始化,配置事件监听器等操作
  • conncetedCallback: 当元素被插入到DOM中的时候会被调用,一般会用在这个钩子中完成一些setup操作,比如发起异步请求获取元素需要的数据进行初始化等,也就是说初始化的操作其实更多是在这个钩子中完成而不是在constructor
  • disconnectedCallback: 元素从DOM中移除时会被调用
  • attributeChangedCallback(attrName, oldVal, newVal): 当observed attribute发生变化时(添加、删除、更新)会执行该钩子,这个特性十分重要,它让自定义元素具有响应式的能力,但是要注意的是,只对observed attribute有效,也就是手动声明为“响应式”的属性变化时才会触发这个钩子,这个待会会详细讲
  • adoptedCallback: 当元素从一个document对象移动到另一个document对象时会触发该钩子,常见于使用document.adoptNode()iframe中的元素移动到另一个文档对象中的场景

接下来我们就来通过一个简单的案例体验一下这几个钩子

;(() => {
  class Foo extends HTMLElement {
    constructor() {
      super()
      this.textContent = this.name ?? 'Foo'
    }

    connectedCallback() {
      // 完成一些 setup 操作 -- 以模拟异步请求接口举例
      setTimeout(() => {
        this.setAttribute('name', 'Bar')
      }, 2000)
    }

    disconnectedCallback() {
      console.log('元素从文档对象中移除...')
    }

    // 需要被监听的属性放在这里定义
    static get observedAttributes() {
      return ['name']
    }

    get name() {
      return this.getAttribute('name')
    }

    set name(val) {
      val !== null
        ? this.setAttribute('name', val)
        : this.removeAttribute('name')
    }

    attributeChangedCallback(attrName, oldVal, newVal) {
      switch (attrName) {
        case 'name':
          console.log(
            `监听到属性变化 -- ${attrName} changed from ${oldVal} to ${newVal}`,
          )

          this.textContent = newVal
          break
      }
    }
  }

  const oXFoo = document.querySelector('x-foo')
  const init = () => {
    customElements.define('x-foo', Foo)

    // 4 秒后移除元素触发 disconnectedCallback 钩子
    setTimeout(() => {
      oXFoo.remove()
    }, 4000)
  }

  init()
})()

效果如下:

体验custom element reactions.gif

这已经具备一定的响应式能力了,只要把渲染相关的逻辑封装起来,在响应式数据变化时统一调用封装好的渲染逻辑,就可以实现一个响应式的效果了,这可是不依赖于任何前端框架,纯 js 的特性哦!

可以通过码上掘金体验一下~

whenDefined -- 渐进式增强特性

我们已经学习了使用customElements.define()去定义一个自定义元素,但实际上这不意味着一定要通过定义类 + 注册的方式去使用自定义元素

可以在自定义元素被注册之前就使用它们,这是CustomElements的一个渐进式增强特性

比如现在假设我的自定义元素x-foo能够有多个子元素,并且我希望当这些子元素都被定义好之后才执行x-foo的定义流程,因为只有这样才能在x-foo中获取到这些子元素的定义信息

那么如何知道子元素是否被定义好了呢?这就需要使用到customElements.whenDefined()这个 API 了。它会返回一个Promise,并且在子元素定义的时候resolve

我们通过一个小案例来体验一下这个渐进式增强特性

<x-foo>
  <x-bar></x-bar>
  <x-bar></x-bar>
  <x-bar></x-bar>
</x-foo>
;(() => {
  class Foo extends HTMLElement {
    constructor() {
      super()
      this.textContent = this.name ?? 'Foo'
    }

    connectedCallback() {
      console.log('setup x-foo...')
      this.setAttribute('name', 'setup Foo')
    }

    disconnectedCallback() {
      console.log('元素从文档对象中移除...')
    }

    // 需要被监听的属性放在这里定义
    static get observedAttributes() {
      return ['name']
    }

    get name() {
      return this.getAttribute('name')
    }

    set name(val) {
      val !== null
        ? this.setAttribute('name', val)
        : this.removeAttribute('name')
    }

    attributeChangedCallback(attrName, oldVal, newVal) {
      switch (attrName) {
        case 'name':
          console.log(
            `Foo 属性变化 -- ${attrName} changed from ${oldVal} to ${newVal}`,
          )

          this.textContent = newVal
          break
      }
    }
  }

  class Bar extends HTMLElement {
    constructor() {
      super()
      this.textContent = this.name ?? 'Bar'
    }

    connectedCallback() {
      console.log('setup x-bar...')
      this.setAttribute('name', 'setup Bar')
    }

    get name() {
      return this.getAttribute('name')
    }

    set name(val) {
      val !== null
        ? this.setAttribute('name', val)
        : this.removeAttribute('name')
    }

    static get observedAttributes() {
      return ['name']
    }

    attributeChangedCallback(attrName, oldVal, newVal) {
      switch (attrName) {
        case 'name':
          console.log(
            `Bar 属性变化 -- ${attrName} changed from ${oldVal} to ${newVal}`,
          )

          this.textContent = newVal
          break
      }
    }
  }

  // 获取所有未定义的自定义元素 x-bar
  const oUndefinedXBars = document.querySelectorAll('x-bar:not(:defined)')

  const init = () => {
    const promises = Array.from(oUndefinedXBars).map(oXBar => {
      return customElements.whenDefined(oXBar.localName)
    })

    Promise.all(promises).then(() => {
      console.log('x-bar 已定义 开始定义 x-foo...')
      // 所有 x-bar 定义好后才去定义 x-foo
      customElements.define('x-foo', Foo)
    })

    // 2 秒后定义所有的 x-bar
    setTimeout(() => {
      customElements.define('x-bar', Bar)
    }, 2000)
  }

  init()
})()

这个案例的场景就是,x-foo中有三个x-bar,但它们一开始是没有定义的,我希望x-foo的定义推迟到x-bar定义完成之后执行,而不是页面一加载就立刻定义x-foo

在主流程中我将x-bar的定义延迟两秒执行,也就是说最开始的 2 秒内,x-bar虽然存在于html中,但是它们此时都是未定义的元素,因此可以通过x-bar:not(:defined)选择器去选中它们,并通过customElements.whenDefined()将它们转成一个promise数组

并通过Promise.all注册一个微任务,当所有的x-bar都定义完毕后才会执行这个微任务,在这个微任务中定义我们的x-foo,最终效果如下:

CustomElements渐进增强.gif

使用 Shadow DOM 创建元素

在了解Shadow DOM之前,我们先来了解一下如何定义自定义元素的默认内容

在前面讲生命周期钩子的时候,我们了解到可以通过connectedCallback钩子定义元素添加到DOM时的行为,我们可以在这里面编写初始状态下元素的html从而实现默认内容的效果

;(() => {
  class Foo extends HTMLElement {
    constructor() {
      super()
    }

    connectedCallback() {
      // 元素默认内容
      this.innerHTML = `<p>foo default content</p>`
    }
  }

  const init = () => {
    customElements.define('x-foo', Foo)
  }

  init()
})()

使用innerHTML定义元素默认内容.png

实际上这种通过innerHTML的方式定义元素内容并不太好,因为如果用户在使用的时候,往<x-foo>中插入了一些元素,就像下面这样

<x-foo>
  <p>foo</p>
  <p>bar</p>
</x-foo>

但是connectedCallback中通过innerHTML的方式直接将内容修改为了<p>foo default content</p>,这就导致用户在使用我们的自定义元素的时候会出现意料之外的情况,这个时候就要用到我们这节要说的Shadow DOM

Shadow DOM为自定义元素提供了一种能力,能够将元素的内容、样式以及提供插槽机制,也就是将用户使用自定义元素时传入的子元素放到插槽中,类似于Vue的插槽(实际上Vue的插槽的设计灵感是来自于这里的)

这样干讲可能不太好理解,我们仍然是通过一个例子来看看它是怎么一回事,仍然是用我们的<x-foo>来举例

class Foo extends HTMLElement {
  constructor() {
    super()

    // 定义模板
    const template = document.createElement('template')
    template.innerHTML = `
        <style>
          p {
            color: white;
            background-color: #282a36;
          }
        </style>
        <p>Shadow DOM content</p>
        <!-- 提供给外界使用的插槽 -->
        <slot></slot>
      `

    // 为元素附加一个 Shadow DOM -- mode: 'open' 表示允许外界通过 shadowRoot 的方式获取到 Shadow DOM 对象
    const shadowRoot = this.attachShadow({ mode: 'open' })
    shadowRoot.appendChild(template.content.cloneNode(true))
  }
}

这里我们通过attachShadow为自定义元素关联一个Shadow DOM,并且Shadow DOM中的内容主要是从template这个元素中获取,这样我们就只需要关心template的内容编写即可,并且通过slot插槽机制保证外界的子元素不会被我们的Shadow DOM覆盖,目前的效果如下

使用ShadowDOM.png

扩展原生按钮元素 -- 添加点击时的水波涟漪特效

实际上Custom Elements还有一个强大的功能,就是允许我们扩展html内置元素,这里我以扩展原生button,添加一个安卓系统上的水波涟漪特效为例,感受下Custom Elements的强大之处

前面我们的自定义元素都是继承自HTMLElement的,这次由于我们要扩展的是button元素,所以这次我们的自定义元素继承的是HTMLButtonElement

;(() => {
  class RippleButton extends HTMLButtonElement {
    constructor() {
      super()

      this.style.position = 'relative'
      this.style.overflow = 'hidden'

      // 点击时出现水波涟漪特效
      this.addEventListener('click', e =>
        this.renderRipple(e.offsetX, e.offsetY),
      )
    }

    /**
     *
     * @param {number} x 鼠标点击按钮时在按钮中的水平坐标
     * @param {number} y 鼠标点击按钮时在按钮中的垂直坐标
     */
    renderRipple(x, y) {
      // 水波涟漪特效遮罩
      const oButtonOverlay = document.createElement('div')

      oButtonOverlay.style.position = 'absolute'
      oButtonOverlay.style.width = '100%'
      oButtonOverlay.style.height = '100%'
      oButtonOverlay.style.borderRadius = '50%'
      oButtonOverlay.style.transition = 'all 0.5s ease-out'
      oButtonOverlay.style.background = 'rgba(0, 0, 0, 0.2)'
      oButtonOverlay.style.transform = 'scale(0)'

      // top 和 left 的修改依赖于 clientWidth 和 clientHeight
      // 直接修改时无法获取到最新的元素宽高,因此放到宏任务中执行
      // transform 也放到这里面是为了触发 transition
      requestAnimationFrame(() => {
        oButtonOverlay.style.top = `${y - oButtonOverlay.clientHeight / 2}px`
        oButtonOverlay.style.left = `${x - oButtonOverlay.clientWidth / 2}px`
        oButtonOverlay.style.transform = 'scale(3)'
      })

      // 动画结束后移除遮罩元素
      oButtonOverlay.addEventListener('transitionend', () =>
        oButtonOverlay.remove(),
      )

      this.appendChild(oButtonOverlay)
    }
  }

  const init = () => {
    customElements.define('ripple-button', RippleButton, { extends: 'button' })
  }

  init()
})()

那么怎么使用这个自定义的按钮元素呢?需要通过is属性去指定

<button is="ripple-button">Ripple Button</button>

效果图如下:

自定义扩展按钮元素.gif

而如果是要在js层面创建我们的自定义按钮元素,有两种方式:

  1. 通过document.createElement()的第二个参数指定
const oRippleButton = document.createElement('button', { is: 'ripple-button' })
  1. 通过new操作符
const oRippleButton = new RippleButton()

以上就是关于CustomElements的一些基本特性和实践体验啦,它是实现原生组件化Web Component的一块重要基石,理解它对我们掌握原生组件化有很大帮助,希望读者们能够好好消化~