Web Components 学习 02 Web Components 历史、深入理解 Custom Elements

Web Components 对 Vue 的影响

尤雨溪在创建 Vue 的时候大量参考了 Web Components 的语法,下面写个简单示例。

首先写个 Vue 组件 my-span.vue

<!-- my-span.vue -->
<template>
  <span>my-span</span>
</template>

<script>
export default {

}
</script>

<style>
span {
  color: purple;
}
</style>

这是很标准的 Vue 组件,不过非常遗憾的是 HTML 文件并不能有效的利用这个 vue 文件,如果想让它能够正确运行,还需要下载 node、webpack、vue-loader 将其打包,而且它只能在 Vue 的项目中使用,也就是必须依赖 Vue 的安装包。如果在 React、Angular 甚至 jQuery 项目中,这个组件就不能用了。

但是以前只需要将它稍稍修改一下,它就会变成 Web Components 文件,能够直接在浏览器中运行。

只需要修改 <script> 中的 JS 代码和文件后缀:

<!-- my-span.html -->
<template>
  <span>my-span</span>
</template>

<script>
// 获取 DOM 元素
const dom = document.currentScript.ownerDocument.querySelector('template').content

// 有点像 React 定义组件的写法
class MySpan extends HTMLElement {
  constructor() {
    super()
    this.attachShadow({ mode: 'open' }).appendChild(dom)
  }
}

// 注册组件
customElements.define('my-span', MySpan)
</script>

<style>
span {
  color: purple;
}
</style>

使用 HTML Imports 在 HTML 页面中引入组件:

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Document</title>
  <!-- HTML Imports -->
  <link rel="import" href="my-span.html">
</head>
<body>
  <my-span></my-span>
</body>
</html>

但是现在 HTML Imports 已废弃(被 ES Modules 的代替),所以不能使用这种方式了。

如果还想要以独立模块的方式引入,那么就要通过 JS 生成 HTML 和 CSS:

// my-span.js
class MySpan extends HTMLElement {
  constructor() {
    super()
    this.render()
  }

  // 生成 HTML 和 CSS
  render() {
    const shadow = this.attachShadow({ mode: 'open' })

    const dom = document.createElement('span')
    const style = document.createElement('style')
    dom.textContent = 'my-span'
    style.textContent = `
      span {
        color: purple;
      }
    `

    shadow.appendChild(style)
    shadow.appendChild(dom)
  }
}

// 注册组件
customElements.define('my-span', MySpan)

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Document</title>
  <!-- HTML Imports(已废弃) -->
  <!-- <link rel="import" href="my-span.html"> -->

  <!-- ES Modules -->
  <script type="module" src="my-span.js"></script>
</head>
<body>
  <my-span></my-span>
</body>
</html>

注意:使用 ES Modules 和 HTML Imports 一样,都要开启一个 Web 服务,直接打开 HTML 文件,会以文件协议(file://)的方式打开,控制台会报跨域错误。可以使用 vscode 的 Live Server 插件打开 HTML。或者不使用 ES Modules:<script src="my-span.js"></script>

也可以提前在 HTML 页面中通过 <template> 原生标签写好 DOM 结构,然后在组件中通过 DOM API 获取模板内容,不过这样并没有将组件作为独立的模块分离出来。

如果想使用插槽,只需要将 <slot /> 添加进去:

const dom = document.createElement('span')
const style = document.createElement('style')
// dom.textContent = 'my-span'
dom.innerHTML = '<slot>默认内容</slot>'
style.textContent = `
  span {
    color: purple;
  }
`

使用上也和 Vue 一样:

<!-- 默认内容 -->
<my-span></my-span>
<!-- 自定义内容 -->
<my-span>自定义内容</my-span>

如果想使用具名插槽(多个插槽):

const dom = document.createElement('span')
const style = document.createElement('style')
// dom.textContent = 'my-span'
dom.innerHTML = `
  <slot>默认内容</slot>
  <slot name="content">默认内容</slot>
`
style.textContent = `
  span {
    color: purple;
  }
`

使用:

<my-span>
  <h1>默认插槽</h1>
  <h2 slot="content">另一个插槽</h2>
</my-span>

Vue 新语法已经建议使用 v-slot,而 Web Components 还是 slot

Web Components 的历史

Web Components 不是一门单一的技术,而是四门技术的组合,这四门技术分别是:

  • HTML Imports
  • HTML templates
  • Custom Elements
  • Shadow DOM

HTML Imports 就是上面示例中的 <link ref="import" href="my-span.html"> 用来引入另一个 HTML 文件。

可惜 HTML Imports 已经被废弃,如果想正常使用 HTML Imports 代码查看效果,可以安装低版本浏览器,例如 Chrome 79:

在这里插入图片描述

通过上面的示例可以看到 HTML Imports 很好用,为什么会被废弃?这就要讲讲 Web Components 的前世今生了。

很多人都认为 Google 是一个比 IE 还“遵纪守法”的好公民,因为它一直遵守 W3C、ECMA 的标准,才可以得以干掉 IE 成为浏览器市场的占有率之王。

其实 Google 可不老实,它经常会倒逼标准的形成。比方说 Google 自己实现了一个 CSS 属性,那时候 W3C 并没有发布标准,于是它就在自己自创的属性前加个前缀 -webkit-,这代表只是它自己浏览器的实验性属性,并没有破坏标准私自发布属性。

可是开发者觉得这个属性真的很好用,可以实现很酷炫的效果,其他浏览器的厂商有的是觉得这个属性确实很不错,而有的是感觉到了压力,总而言之,所有浏览器厂商最终都实现了这个属性,为了表示自己也没有破坏标准私自发布属性,大家都默默地在这个属性前面加上自己浏览器内核的前缀,像 -moz--ms--o-

虽然这个属性没有在 W3C 等机构成为标准,但它已经成为了**“事实标准”**,世界各地的开发者们也都已经用这个属性实现出来成千上万个网站了,最终也不得不把它标为标准。于是 Google 最终自己研究出来的属性就这样成为了标准。大家再也不用写那么烦人的前缀了。Web Components 也正是基于这样的一种情况下诞生的。

话说在 2011 年的时候 Google 就推出了 Web Components 的概念,当时前端还没有**“模块化”的概念,甚至都没有“前端”**的概念,这个时期 Google 就已经敏锐的察觉到前端需要组件化,但最开始他们也只是提出了这个概念,并没有开发出真正能用的前端组件化。

2015 年 Web Components 终于能用了,所以网上开始有人介绍 Web Components,这也是为什么网上大部分 Web Components 文章都是 2015 - 2016 写的(内容还包括 HTML Imports)。

那么为什么这么多年过去了,Web Components 还没有火起来呢?

因为 Google 的做法引起了其他浏览器厂商的不满,凭什么这么重要的新功能就你一家说了算,平时你实现的 CSS3 属性啥的,我们睁一只眼闭一只眼也就算了,可 Web Components 是非常重大的一项功能,API 长什么样子都是你自己定,我们不同意。于是 Web Components 的第一版,也就是 V0,就只有 Google 自己实现了。

Google 也意识到,虽然自己目前市场占有率全球称霸,但只要其它浏览器不支持还是不会有人用,毕竟大家都要考虑兼容性的问题,不可能只考虑用 Google 内核的用户上网才能够看到效果,其它浏览器就不管。

所以 Google 决定,和其它主流浏览器厂商一起讨论一下,在讨论中大家就产生了激烈的分歧,比如苹果系统的浏览器 Safari 觉得 Shadow DOM 应该始终保持封闭以保证独立性,而 Google 则认为要始终保持开放,让用户能够访问到,不然 Web Components 组件库在用户的眼中始终是一个无法窥视内部构造的黑盒;还有火狐浏览器觉得马上要出 ES6 了,HTML Imports 不用实现,先看看 ES6 的模块化怎么样,感觉它也能代替 HTML Imports 的功能。

于是根据各个浏览器厂商的不满,又修订了第二个版本:V1,这正是目前使用的版本。就在各大浏览器厂商不断扯皮的过程中,三大框架崛起了:Angular、React、Vue,它们都有组件化的功能,于是其它浏览器厂商实现 Web Components 的动力就有点不足了,本来实现起来就挺复杂的,现在更不想实现了。

但是几年过后,大家发现浏览器真的需要一个原生的组件化技术,开发者们也一直都在询问到底为什么就是不实现 Web Components。

基于种种压力之下,Safari 在 2017 年实现了 Web Components,当然只实现了一部分,因为他们至今都不是很认同 Google 的 is 属性(类似 Vue 的 is 属性),所以他们就是不实现,反正 Web Components 还没有成为标准(2017 年),这也不算不遵守规范。

而火狐则是在 2018 年实现的 Web Components。

微软的 Edge 浏览器现在改用 Google 的内核了,IE 就不要提了。

Opera 也早就改用 Google 内核了。

至此所有浏览器都实现了 Web Components,不过它终究还是来的太晚了点,三大框架早已瓜分了市场,形成了三足鼎立的局面。但之后随着时间的推移,三大框架有可能会用 Web Components 去实现自己底层的组件化系统。

而 Vue CLI 早就实现了能将 Vue 组件编译成 Web Components 的功能。

而且一些组件库为了能够跨框架运行,也是采用了 Web Components 来实现,比如 Taro 3 为了能够让写不同框架的的人都能用上组件,特意采用了 Web Components 来实现的基础组件。

既然 HTML Imports 已经废弃,这里也不再学习它的具体用法。但可以介绍一下它的下一代技术 HTML Modules

下面是一个无法运行的示例:

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Document</title>
</head>
<body>
  <script type="module">
    import A from './component.html'
  </script>
</body>
</html>

<!-- component.html -->
<template>

</template>

<script type="module">

</script>

目前还没有任何浏览器实现了这一提案,所以上例代码无法演示,而且这种写法目前来说争议非常大,因为在 <script> 标签里引入了一个 HTML 文件,之所以要这么做,是因为 <template> 通常就是用来引入到 JS 文件里使用的。

下面介绍 Web Components 中最重要的一项技术,同时也是所有浏览器都没有提出反对意见,一致通过的一项技术 —— Custom Elements(自定义元素)

Shadow DOMHTML templates( and slots) 目前主流浏览器也同样支持,通常都会应用于 Custom Elements,前者是用于封装独立于主文档的 DOM,后者类似 Vue 的 Slot,本文不作详解。

Custom Elements 自定义元素

MDN:使用 custom elements

基础使用

window 全局对象上有一个 customElements 提供自定义元素支持,它包含四个 API:

  • define:注册/定义自定义元素
  • get:获取自定义元素的构造函数
  • whenDefined:
  • upgrade:

define 示例

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Custom Elements</title>
  </head>
  <body>
    <!-- 使用元素 -->
    <long-time-no-see></long-time-no-see>
    <!-- 不能写成自闭和标签,浏览器会当作起始标签包裹后面的全部内容 -->
    <!-- <long-time-no-see /> -->

    <script>
      // 注册组件(自定义元素)
      window.customElements.define(
        // 参数1:元素名,必须包含一个短横线,以区分原生元素
        'long-time-no-see',

        // 参数2:用于定义元素行为的类(类似 React 中的类组件),必须继承自 HTMLElement
        class extends HTMLElement {
          constructor() {
            super()

            // custome elements 类中的 this 指向组件本身
            console.log(this)

            this.innerHTML = '<h1>好久不见</h1>'
            this.onclick = () => alert('你还好吗')
          }
        }
      )

      // 多次注册相同名称的组件会报错:
      // the name "long-time-no-see" has already been used with this registry
      // window.customElements.define('long-time-no-see', class extends HTMLElement {})

      // 获取自定义元素的构造函数
      console.log(customElements.get('long-time-no-see'))
      // 如果获取的是一个并没有被定义的元素,则返回 undefined
      // 使用场景1:用于判断组件是否已被注册过
      // 使用场景2:扩展第三方组件
      console.log(customElements.get('long-time-no-see1'))
    </script>
  </body>
</html>

get 示例

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Custom Elements</title>
  </head>
  <body>
    <!-- 扩展第三方组件 -->
    <my-bubbles click>我扩展的 bubbles</my-bubbles>
    <script type="module">
      import { FcBubbles } from 'https://unpkg.com/fancy-components'

      // 注册组件
      new FcBubbles()

      // 获取第三方组件的构造函数,用于继承扩展
      const FcBubblesConstructor = customElements.get('fc-bubbles')
      customElements.define(
        'my-bubbles',
        class extends FcBubblesConstructor {
          constructor() {
            super()
            this.onclick = () => console.log('自定义点击事件')
          }
        }
      )
    </script>
  </body>
</html>

whenDefined 示例

通常都会将 <script> 标签放在页面底部,为的是让浏览器渲染引擎先解析 DOM,然后再解析 JavaScript。

当渲染引擎读取到自定义元素的时候,并不知道它是什么元素(此时注册脚本还没执行),一般来说当渲染引擎碰到一个不认识的元素的时候,会认为这是一个无效的元素。

不过自定义元素的命名规则要求必须包含短横杠 -,是为了和原生元素区分开,所有当渲染引擎看到一个不认识的元素,但是名称中带有横杠连字符,会将它认为是一个未定义的自定义元素,不会当作一个无效元素。当执行到注册自定义元素的代码时,就会将之前未定义的元素标记为定义的元素。

定义的元素对应的伪类选择器就是 :defined未定义的元素对应的伪类选择器就是 :not(:defined)

通过这个伪类选择器,可以在定义元素之前的空白时间内,设置自定义元素的加载样式。

whenDefine 是元素定义后触发的回调,通常用于异步注册组件的时候:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Custom Elements</title>
    <style>
      :not(:defined) {
        width: 120px;
        height: 60px;
        background: gray linear-gradient(-60deg, transparent, transparent 20%, white 40%, transparent 60%) 0 / 300%;
        border-radius: 15px;
        animation: loading 2s infinite;
        display: grid;
        place-items: center;
      }
      @keyframes loading {
        to {
          background-position: 300% 0;
        }
      }
    </style>
  </head>
  <body>
    <long-time-no-see>Loading</long-time-no-see>

    <script>
      // 模拟 JS 代码执行延迟
      setTimeout(() => {
        customElements.define(
          'long-time-no-see',
          class extends HTMLElement {}
        )
      }, 3000)

      // 返回一个 Promise
      customElements
        .whenDefined('long-time-no-see')
        .then(() => {
          document.querySelector('long-time-no-see').innerHTML = '好久不见'
        })
        .catch(err => console.log(err))
    </script>
  </body>
</html>

upgrade 示例

upgrade 是升级的意思,如果在定义元素之前先使用 JS 创建了元素,则元素实例并不是继承的定义元素行为的类,可以使用 upgrade 将其升级为期望的样子:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Custom Elements</title>
  </head>
  <body>
    <script>
      // 先使用 JS 创建自定义元素
      const el = document.createElement('vue-react')

      // 再注册自定义元素
      class VueReact extends HTMLElement {}
      customElements.define('vue-react', VueReact)

      // 返回 false
      console.log(el instanceof VueReact)

      // 升级元素
      customElements.upgrade(el)

      // 返回 true
      console.log(el instanceof VueReact)
    </script>
  </body>
</html>

生命周期

我们经常会在组件的初始阶段设置监听器,在组件的挂载阶段获取 DOM 元素,在组件的更新阶段发送一些 ajax 请求,在组件的卸载阶段做一些清理操作(例如移除定时器)。

Web Components 的生命周期比 Vue 和 React 的都要少。

下面通过 Vue 的生命周期来对比 Web Components 的生命周期:

生命周期Web Components说明Vue2Vue3 组合式 API
初始阶段constructor定义元素时被调用beforeCreate更像是 Vue3 的 setup
--created-
--beforeMountbeforeMount
挂载阶段connectedCallback当元素首次插入(连接)文档 DOM 时被调用mountedmounted
--beforeDestroybeforeUnmount
卸载阶段disconnectedCallback当元素从文档 DOM 中删除(取消连接)时被调用destroyedunmounted
-adoptedCallback当元素被移动到新的文档时被调用--
更新阶段attributeChangedCallback当元素增加、删除、修改自身属性时被调用(与 Vue 差别较大后面章节细说)--

connectedCallback vs mounted

Vue 和 React 都是靠一个根元素(Root)来实现的,默认 Vue 里是一个 idapp 的元素,React 中是一个 idroot 的根元素:

<div id="app"></div>

一开始 DOM 都是空的,是靠 JavaScript 动态生成的 DOM,然后再往里填充:

const component = document.createElement('h1')

它需要挂载到 HTML 页面上才能显示:

const root = document.getElementById('app')
root.append(component)

所以这个过程叫挂载,而对应的生命周期命名为 mounted

而自定义元素组件通常是先在 HTML 中编写组件,浏览器会先解析到它:

<life-cycle></life-cycle>

然后浏览器继续解析到定义它的 JS 代码时,就会将其与 JS 定义的元素(构造函数中的 this)进行连接:

customElements.define('life-cycle', class extends HTMLElement {})

这个连接的过程和 Vue、React 挂载的过程有很大的区别,所以它叫 connectedCallback

adoptedCallback 示例

adopt 是收养的意思,DOM API document.adoptNode可以剪切文档(包括另一个文档)中的节点,可以通过它将其它文档上的节点剪切到当前文档中使用,这个过程可以成为“收养(adopt)”,例如:

<!-- iframe.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>iframe</title>
</head>
<body>
  <h1>我来自 iframe</h1>
</body>
</html>

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
  </head>
  <body>
    <iframe src="./iframe.html"></iframe>
    <script>
      const iframe = document.querySelector('iframe')

      iframe.onload = () => {
        const dom = iframe.contentDocument.querySelector('h1')
        // 剪切元素
        const adoptDom = document.adoptNode(dom)
        // 添加到当前文档
        document.body.append(adoptDom)
      }
    </script>
  </body>
</html>

注意:要开启一个 web 服务访问页面,否则获取不到 iframe 的内容。并且要访问 iframe 的内容还要符合同源要求。

而 Web Components 的 adoptedCallback 生命周期回调指的是元素被移动(剪切)到新的文档时被调用,正符合这个场景:

<!-- iframe.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>iframe</title>
  </head>
  <body>
    <life-cycle>我来自 iframe</life-cycle>
    <script>
      customElements.define(
        'life-cycle',
        class extends HTMLElement {
          constructor() {
            super()
            // 相当于 Vue3 的 setup
            console.log('constructor')
          }
          connectedCallback() {
            // 相当于 Vue 的 mounted
            console.log('connected')
          }
          disconnectedCallback() {
            // 相当于 Vue 的 unmounted
            console.log('disconnected')
          }
          adoptedCallback() {
            console.log('adopted')
          }
        }
      )
    </script>
  </body>
</html>

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
  </head>
  <body>
    <iframe src="./iframe.html"></iframe>
    <script>
      const iframe = document.querySelector('iframe')

      iframe.onload = () => {
        const dom = iframe.contentDocument.querySelector('life-cycle')
        // 剪切元素
        const adoptDom = document.adoptNode(dom)
        // 添加到当前文档
        document.body.append(adoptDom)
      }
    </script>
  </body>
</html>

该示例可以查看不同生命周期函数触发的时机。

深入理解自定义元素

attributeproperty

在介绍 Web Components 更新阶段的生命周期之前,需要先讲讲attributeproperty的区别。

两者的翻译结果都是**“属性”**,而且在应用上两者通常还具有映射关系,导致很多人都认为它们是同一个东西,但其实两者有很大的不同。

为了便于区分,下面将 attribute 称为**“特性”,将 property 称为“属性”**。

attribute 就是写在 HTML 标签上的属性,如下面 HTML 标签上的 idclass

<h2 id="a" class="b"></h2>

property 就是 JavaScript 对象上的字段,例如下面通过 JS 对象获取的 DOM 对象上的属性:

const dom = document.querySelector('h2')
console.log(dom.id)
console.log(dom)

之所以容易将两者当作一个东西,是因为 console.log(dom) 的结果,dom 是用 JS 获取的,它应该是个 JS 对象,但浏览器会将它显示成 HTML 元素:

在这里插入图片描述

如果修改它的 id 属性:

const dom = document.querySelector('h2')
console.log(dom.id)
console.log(dom)
dom.id = 'A'

控制台打印显示的 HTML 元素的 id 也会同步修改,但实际上 JS 修改的 id 属性并不是 HTML 元素的 id 属性,只不过它们的名字都叫 id,并且它们具有类似 Vue 的双向绑定功能,所以给我们营造了一种 “dom.idh2 元素上的 id 是一回事”的错觉。

其实不然,首先我们先让浏览器以 JS 的形式显示 dom

console.dir(dom)
// 或者
console.log('%O', dom) // 注意是 大写的英文O,不是数字0

这才是 dom 真实的样子,因为 JS 形式展示的属性实在太多了,所以浏览器帮我们显示成 HTML 标签的样子,方便我们查看重点。

所以只需要记住,attribute 就是写在 HTML 标签上的属性,property 就是 JavaScript 对象上的字段

它们中的属性并不都像 id 一样在两边都一样,例如 attributeclassproperty 中是 className

以及它们中的属性并不都像 id 那样具有双向绑定特征,具有双向绑定特征的只有本来就在 DOM 中内置的属性,如果给 DOM 添加一个自定义属性,它就不会映射到特性上:

dom.vue = 'VUE'
console.log(dom)
// <h2 id="a" class="b"></h2>

也可以在 HTML 标签上添加一个自定义的 attribute

<h2 id="a" class="b" react="React"></h2>

property 上也不会映射:

console.log(dom.react) // undefined

通过 JS 对象方式直接获取的属性是 property,如果想要获取 attribute 特性,可以通过专门的 API - getAttribute

console.log(dom.getAttribute('react')) // React

同理,想设置 HTML 标签的特性,可以通过 setAttribute

dom.setAttribute('vue', 'Vue')
console.log(dom.vue) // undefined

不过由于 vue 本身不是 DOM 的内置属性,所以也不会映射到 property 上,如果需要,可以手动设置 dom.vue = 'Vue'

我们通常都是修改 HTML 标签里的 attribute 或者通过修改 JS 对象里的 property 来更新数据的。而且为了让组件用起来更方便,我们需要自己模仿 DOM 的 attributeproperty 的双向绑定功能。

而 Web Components 的生命周期函数 attributeChangedCallback 就是特性 attribute 改变之后的回调函数。

Web Components 与 MVVM 框架更新阶段的区别

Vue 能够自动更新组件,而 Web Components 是手动更新的。

Vue 的更新阶段对应的生命周期是 beforeUpdateupdated,在 Web Components 中没有与之对应的生命周期函数。

实际上也不是没有,而是没有直接在更新过程中的生命周期函数,但是有一个生命周期函数可以间接做到这一点,这就是 attributeChangedCallback,翻译过来就是“当属性变化后的回调函数”。

那么它为什么不相当于 Vue 的 updated 呢?

首先思考在 Vue 和 React 中组件为什么会更新,不就是因为数据发生了变化么,这些 MVVM 框架主打口号就是**“数据驱动视图”**。

在不考虑 forceUpdate这种强制更新的边界情况,可以认为:通常情况下,更新阶段几乎可以等同于数据变化的阶段

Web Components 同理,想改变一个 DOM 的数据,一般就是改变它的 attributepropertyattributeChangedCallback 就是 attribute 改变之后的回调函数。

它与 Vue 的 updated 不同,虽然有相似的地方,比如它们都是在数据改变后运行的生命周期函数,但不一样的地方是,在数据更新后,Vue 会自动更新视图,而 Web Components 则不会。

我们在 Vue 中写代码时,根本不需要关系视图如何更新,只需要改变数据即可,什么时机更新视图以及怎么更新,Vue 已经封装好了。

但 Web Components 毕竟只负责封装能在浏览器原生运行的组件,属于比较底层的技术,如果想做到像 Vue 那样,还需要我们自己进行封装。

而封装的内容就是 attributeChangedCallback,与 Vue 不同的一点是,当特性变更时,你可以选择不更新视图,也可以选择在 attributeChangedCallback 中更新视图。

特性篇

attributeChangedCallback 会在特性变化后触发,但是要监听特性的变化,还需要通过定义 observedAttributes()get 函数,函数体内返回要监听的 attribute,这是为了性能考虑,减少不必要的监听。

示例:

<life-cycle color="pink">Hello World</life-cycle>
customElements.define('life-cycle', class extends HTMLElement {
  // 相当于 Vue 的 data
  static get observedAttributes () {
    return ['color']
  }
  // 或者
  // static observedAttributes = ['color']

  attributeChangedCallback(name, oldValue, newValue) {
    // 相当于 Vue 的 watch
    console.log('attributeChanged')
    if (oldValue === newValue) return
    console.log(name, newValue)
    if (name === 'color') {
      this.style.color = newValue
    }
  }
})

const dom = document.querySelector('life-cycle')
setTimeout(() => {
  dom.setAttribute('color', 'blue')
}, 1000)

虽然 Vue 没有一个和 attributeChangedCallback 特别像的生命周期函数,但却有一个功能和它非常相似,那就是 watch,例如:

data() {
  return {
    color: 'pink'
  }
},
watch: {
  color(newVaue, oldValue) {
    this.$refs.dom.style.color = newValue
  }
}

有一个细节是 Web Components 中 attributeChangedCallbackoldValule 参数放在了 newValue 参数的前面,而 Vue 的 watch 中是相反的,这是因为 Vue 框架底层自动处理了 newValue === oldValue 的场景,组件不会作任何变化,watch 回调不会触发,所以通常情况下 Vue 中不必关心 newValueoldValue 的值是否相等,所以一般也很少去写 oldValue 的形参。

但是 Web Components 则不一样,即使 newValue === oldValue 的情况下,也会触发 attributeChangedCallback

setInterval(() => {
  dom.setAttribute('color', 'blue')
}, 1000)

属性篇

同样是**“属性”**更新,Web Components 只有 attributeChangedCallback 并没有 propertyChangedCallback,因为 property 是 JS 对象上的属性,可以直接使用 getter/setter 来监听对象属性的变化。

customElements.define(
  'life-cycle',
  class extends HTMLElement {
    get color() {
      return this.getAttribute('color')
    }
    set color(value) {
      this.setAttribute('color', value)
    }

    // 相当于 Vue 的 data
    static get observedAttributes() {
      return ['color']
    }
    attributeChangedCallback(name, oldValue, newValue) {
      // 相当于 Vue 的 watch
      console.log('attributeChanged')
      if (oldValue === newValue) return
      console.log(name, newValue)
      if (name === 'color') {
        this.style.color = newValue
      }
    }
  }
)
const dom = document.querySelector('life-cycle')
setTimeout(() => {
  dom.setAttribute('color', 'blue')
  console.log(dom.color)
}, 1000)
console.log(dom.color)

这就实现了 property 属性的监听和设置,并且实现了 attributeproperty 的双向绑定。

继承篇

自定义元素为什么要继承 HTMLElement

之前说到定义自定义元素行为的类一定要继承自 HTMLElement,只有继承了它才能使用元素上的属性,如 onclickstyle等,但其实继承它的子类同样也可以。

用 JS 获取的 DOM,都是 Element,它们都是 Element 的实例:

<div id="div"></div>
<script>
  const div = document.getElementById('div')
  console.log(div instanceof Element) // true
</script>

那么为什么定义自定义元素的时候继承的是 HTMLElement 而不是 Element,而且编辑器给出的提示也是 HTMLElement

在这里插入图片描述

原因是 Element 是所有元素的父类,它提供了元素的基础属性和方法,如 idonclickonmouseoveronkeydownonkeyup等。

但是元素又分为两种,SVG 元素和 HTML 元素,它们提供的属性和方法又不一样,所以又会分为两个更加具体的类来扩展 ElementSVGElement HTMLElement

参考:Element | MDN

console.log(Object.getPrototypeOf(HTMLElement) === Element) // true
console.log(Object.getPrototypeOf(SVGElement) === Element) // true

不同的 HTML 元素的属性和方法也是不同的,例如 inputplaceholderdiv 元素上就没有。

所以说 HTMLElementSVGElement 还能再往下细分,例如 HTMLElement 下还有 HTMLDivElementHTMLInputElementHTMLAnchorElement 等。

可以通过获取 DOM 对象的构造函数,查看元素属于哪个类:

console.log(document.createElement('button').constructor)
// ƒ HTMLButtonElement() { [native code] }

注意 document.createElement 是创建 HTML 元素的方法,不能用来创建 SVG 元素:

console.log(document.createElement('svg').constructor)
// ƒ HTMLUnknownElement() { [native code] }

创建 SVG 元素的方法:

// NS:namespace 命名空间
// 第一个参数是命名空间,SVG 元素的命名空间是 http://www.w3.org/2000/svg
console.log(document.createElementNS('http://www.w3.org/2000/svg', 'svg').constructor)
// ƒ SVGSVGElement() { [native code] }

console.log(document.createElementNS('http://www.w3.org/2000/svg', 'circle').constructor)
// ƒ SVGCircleElement() { [native code] }

继承内置元素

因为 HTMLElement 拥有我们日常用到的 HTML 元素的绝大部分属性和方法,便于我们用 this.xxx 来调用 HTML 元素的属性和方法,所以自定义元素要继承 HTMLElement。

但毕竟有些元素比较特殊,它们比普通的 HTML 元素多了很多属性和方法,例如 inputplaceholder,要想让继承自 HTMLElement 的自定义元素也拥有 placeholder 就要编写大量代码:

<my-input placeholder="请输入内容"></my-input>
<script>
  customElements.define(
    'my-input',
    class extends HTMLElement {
      static observedAttributes = ['placeholder']

      get placeholder () {
        return this.querySelector('input').getAttribute('placeholder')
      }

      set placeholder (value) {
        return this.querySelector('input').setAttribute('placeholder', value)
      }

      constructor() {
        super()
        this.innerHTML = '<input />'
      }

      attributeChangedCallback(name, oldValue, newValue) {
        if (oldValue === newValue) return

        this.placeholder = newValue
      }

    }
  )
</script>

如果要为这些非内置属性都编写类似的代码,实在是太多了太麻烦了。那是否可以让自定义元素直接继承自内置元素: HTMLInputElement

customElements.define(
  'my-input',
  class extends HTMLInputElement {
    ...
  }
)

结果控制台报错:

Uncaught TypeError: Illegal constructor: autonomous custom elements must extend HTMLElement
# 自定义元素必须继承自 HTMLElement

这还要用到 customElements.define() 的第三个参数,这个参数是个对象,且只有一个字段 extends,它的值就是要继承的元素的标签名:

customElements.define(
  'my-input',
  class extends HTMLInputElement {
    // 删除其它多余的代码
    constructor() {
      super()
      this.disabled = true
    }
  },
  { extends: 'input' }
)

不过使用了继承参数,在使用自定义元素的时候就不能直接使用自定义元素的标签名了,只能使用继承元素的标签名,然后用 is 属性指向自定义元素的标签名:

<!-- <my-input placeholder="请输入内容"></my-input> -->
<input is="my-input" placeholder="输入内容" />

为什么不能直接使用 <my-input>呢?

其实这是因为在 HTML 中有一部分标签是固定搭配,例如 ul > litable > tr > tddl > dt + ddselect > option,倒也不是不能在这些标签中写别的元素,但是写了别的就会失去原有的效果。

例如下例没有下拉选项:

<select>
  <p value="a">A</p>
  <p value="b">B</p>
</select>

假如定义了一个 <my-option> 元素:

customElements.define(
  'my-option',
  class extends HTMLOptionElement {
    constructor() {
      super()
      this.innerText = 'my-option'
    }
  },
  { extends: 'option' }
)

由于 HTML 规范要求 <select> 只识别 <option>,下面的代码也不会如期生效:

<select>
  <my-option value="a">A</my-option>
  <my-option value="b">B</my-option>
</select>

为了既能保证 <select> 里包含的是 <option> 元素,又能扩展这个 <option> 标签并将其封装成组件,所以才会出现 is 这种写法。

<select>
  <option is="my-option" value="a">A</option>
  <option is="my-option" value="b">B</option>
</select>

但是 Safari 浏览器并没有实现 is 这个功能

在这里插入图片描述

Safari 浏览器对 Custom Elements 部分支持:“支持自定义元素,但是不支持自定义内置元素”。

不过已经有专门的 polyfill 来解决这个问题了,张鑫旭大神还为其扩展了浏览器区分的功能,详情参考:Safari不支持build-in自定义元素的兼容处理 « 张鑫旭

JS 创建自定义元素

如果需要用 JS 手动创建元素,createElement() 方法支持第二个参数,也是一个对象,也只包含一个字段 is,用于指定自定义元素的标签名。

const myOptionDom = document.createElement('option', { is: 'my-option' })
document.querySelector('select').append(myOptionDom)

虽然 Elements 面板上看不到 is 属性,但它已经是一个自定义元素了:

在这里插入图片描述

除此之外还有另一种方式,直接通过实例化定义元素行为的类的实例来创建元素:

class MyOption extends HTMLOptionElement {
  constructor() {
    super()
    this.innerText = 'my-option'
  }
}
customElements.define('my-option', MyOption, { extends: 'option' })

const myOptionDom = new MyOption()
document.querySelector('select').append(myOptionDom)
  • 1
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值