尤雨溪在创建 Vue 的时候大量参考了 Web Components 的语法,下面写个简单示例。
首先写个 Vue 组件 my-span.vue
:
my-span
这是很标准的 Vue 组件,不过非常遗憾的是 HTML 文件并不能有效的利用这个 vue 文件,如果想让它能够正确运行,还需要下载 node、webpack、vue-loader 将其打包,而且它只能在 Vue 的项目中使用,也就是必须依赖 Vue 的安装包。如果在 React、Angular 甚至 jQuery 项目中,这个组件就不能用了。
但是以前只需要将它稍稍修改一下,它就会变成 Web Components 文件,能够直接在浏览器中运行。
只需要修改 中的 JS 代码和文件后缀:
<template>
<span>my-spanspan>
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 页面中引入组件:
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Documenttitle>
<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)
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Documenttitle>
<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:
也可以提前在 HTML 页面中通过
原生标签写好 DOM 结构,然后在组件中通过 DOM API 获取模板内容,不过这样并没有将组件作为独立的模块分离出来。
如果想使用插槽,只需要将
添加进去:
const dom = document.createElement('span')
const style = document.createElement('style')
// dom.textContent = 'my-span'
dom.innerHTML = '默认内容 '
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 = `
默认内容
默认内容
`
style.textContent = `
span {
color: purple;
}
`
使用:
<my-span>
<h1>默认插槽h1>
<h2 slot="content">另一个插槽h2>
my-span>
Vue 新语法已经建议使用
v-slot
,而 Web Components 还是slot
。
Web Components 不是一门单一的技术,而是四门技术的组合,这四门技术分别是:
HTML Imports 就是上面示例中的 用来引入另一个 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。
下面是一个无法运行的示例:
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Documenttitle>
head>
<body>
<script type="module">
import A from './component.html'
script>
body>
html>
<template>
template>
<script type="module">
script>
目前还没有任何浏览器实现了这一提案,所以上例代码无法演示,而且这种写法目前来说争议非常大,因为在 标签里引入了一个 HTML 文件,之所以要这么做,是因为
通常就是用来引入到 JS 文件里使用的。
下面介绍 Web Components 中最重要的一项技术,同时也是所有浏览器都没有提出反对意见,一致通过的一项技术 —— Custom Elements(自定义元素)。
Shadow DOM 和 HTML templates( and slots) 目前主流浏览器也同样支持,通常都会应用于 Custom Elements,前者是用于封装独立于主文档的 DOM,后者类似 Vue 的 Slot,本文不作详解。
MDN:使用 custom elements
window 全局对象上有一个 customElements 提供自定义元素支持,它包含四个 API:
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Custom Elementstitle>
head>
<body>
<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 = '好久不见
'
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>
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Custom Elementstitle>
head>
<body>
<my-bubbles click>我扩展的 bubblesmy-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>
通常都会将 标签放在页面底部,为的是让浏览器渲染引擎先解析 DOM,然后再解析 JavaScript。
当渲染引擎读取到自定义元素的时候,并不知道它是什么元素(此时注册脚本还没执行),一般来说当渲染引擎碰到一个不认识的元素的时候,会认为这是一个无效的元素。
不过自定义元素的命名规则要求必须包含短横杠 -
,是为了和原生元素区分开,所有当渲染引擎看到一个不认识的元素,但是名称中带有横杠连字符,会将它认为是一个未定义的自定义元素,不会当作一个无效元素。当执行到注册自定义元素的代码时,就会将之前未定义的元素标记为定义的元素。
定义的元素对应的伪类选择器就是 :defined
,未定义的元素对应的伪类选择器就是 :not(:defined)
通过这个伪类选择器,可以在定义元素之前的空白时间内,设置自定义元素的加载样式。
whenDefine
是元素定义后触发的回调,通常用于异步注册组件的时候:
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Custom Elementstitle>
<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>Loadinglong-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
是升级的意思,如果在定义元素之前先使用 JS 创建了元素,则元素实例并不是继承的定义元素行为的类,可以使用 upgrade
将其升级为期望的样子:
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Custom Elementstitle>
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 | 说明 | Vue2 | Vue3 组合式 API |
---|---|---|---|---|
初始阶段 | constructor | 定义元素时被调用 | beforeCreate | 更像是 Vue3 的 setup |
- | - | created | - | |
- | - | beforeMount | beforeMount | |
挂载阶段 | connectedCallback | 当元素首次插入(连接)文档 DOM 时被调用 | mounted | mounted |
- | - | beforeDestroy | beforeUnmount | |
卸载阶段 | disconnectedCallback | 当元素从文档 DOM 中删除(取消连接)时被调用 | destroyed | unmounted |
- | adoptedCallback | 当元素被移动到新的文档时被调用 | - | - |
更新阶段 | attributeChangedCallback | 当元素增加、删除、修改自身属性时被调用(与 Vue 差别较大后面章节细说) | - | - |
connectedCallback
vs mounted
Vue 和 React 都是靠一个根元素(Root)来实现的,默认 Vue 里是一个 id
为 app
的元素,React 中是一个 id
为 root
的根元素:
<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)”,例如:
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>iframetitle>
head>
<body>
<h1>我来自 iframeh1>
body>
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
生命周期回调指的是元素被移动(剪切)到新的文档时被调用,正符合这个场景:
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>iframetitle>
head>
<body>
<life-cycle>我来自 iframelife-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>
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>
该示例可以查看不同生命周期函数触发的时机。
attribute
和 property
在介绍 Web Components 更新阶段的生命周期之前,需要先讲讲attribute
和 property
的区别。
两者的翻译结果都是**“属性”**,而且在应用上两者通常还具有映射关系,导致很多人都认为它们是同一个东西,但其实两者有很大的不同。
为了便于区分,下面将 attribute
称为**“特性”,将 property
称为“属性”**。
attribute
就是写在 HTML 标签上的属性,如下面 HTML 标签上的 id
和 class
:
<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.id
和 h2
元素上的 id
是一回事”的错觉。
其实不然,首先我们先让浏览器以 JS 的形式显示 dom
:
console.dir(dom)
// 或者
console.log('%O', dom) // 注意是 大写的英文O,不是数字0
这才是 dom
真实的样子,因为 JS 形式展示的属性实在太多了,所以浏览器帮我们显示成 HTML 标签的样子,方便我们查看重点。
所以只需要记住,attribute
就是写在 HTML 标签上的属性,property
就是 JavaScript 对象上的字段。
它们中的属性并不都像 id
一样在两边都一样,例如 attribute
的 class
在 property
中是 className
。
以及它们中的属性并不都像 id
那样具有双向绑定特征,具有双向绑定特征的只有本来就在 DOM 中内置的属性,如果给 DOM 添加一个自定义属性,它就不会映射到特性上:
dom.vue = 'VUE'
console.log(dom)
//
也可以在 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 的 attribute
与 property
的双向绑定功能。
而 Web Components 的生命周期函数 attributeChangedCallback
就是特性 attribute
改变之后的回调函数。
Vue 能够自动更新组件,而 Web Components 是手动更新的。
Vue 的更新阶段对应的生命周期是 beforeUpdate
和 updated
,在 Web Components 中没有与之对应的生命周期函数。
实际上也不是没有,而是没有直接在更新过程中的生命周期函数,但是有一个生命周期函数可以间接做到这一点,这就是 attributeChangedCallback
,翻译过来就是“当属性变化后的回调函数”。
那么它为什么不相当于 Vue 的 updated
呢?
首先思考在 Vue 和 React 中组件为什么会更新,不就是因为数据发生了变化么,这些 MVVM 框架主打口号就是**“数据驱动视图”**。
在不考虑 forceUpdate
这种强制更新的边界情况,可以认为:通常情况下,更新阶段几乎可以等同于数据变化的阶段。
Web Components 同理,想改变一个 DOM 的数据,一般就是改变它的 attribute
和 property
,attributeChangedCallback
就是 attribute
改变之后的回调函数。
它与 Vue 的 updated
不同,虽然有相似的地方,比如它们都是在数据改变后运行的生命周期函数,但不一样的地方是,在数据更新后,Vue 会自动更新视图,而 Web Components 则不会。
我们在 Vue 中写代码时,根本不需要关系视图如何更新,只需要改变数据即可,什么时机更新视图以及怎么更新,Vue 已经封装好了。
但 Web Components 毕竟只负责封装能在浏览器原生运行的组件,属于比较底层的技术,如果想做到像 Vue 那样,还需要我们自己进行封装。
而封装的内容就是 attributeChangedCallback
,与 Vue 不同的一点是,当特性变更时,你可以选择不更新视图,也可以选择在 attributeChangedCallback
中更新视图。
attributeChangedCallback
会在特性变化后触发,但是要监听特性的变化,还需要通过定义 observedAttributes()
的 get
函数,函数体内返回要监听的 attribute
,这是为了性能考虑,减少不必要的监听。
示例:
<life-cycle color="pink">Hello Worldlife-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 中 attributeChangedCallback
的 oldValule
参数放在了 newValue
参数的前面,而 Vue 的 watch
中是相反的,这是因为 Vue 框架底层自动处理了 newValue === oldValue
的场景,组件不会作任何变化,watch
回调不会触发,所以通常情况下 Vue 中不必关心 newValue
和 oldValue
的值是否相等,所以一般也很少去写 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
属性的监听和设置,并且实现了 attribute
和 property
的双向绑定。
之前说到定义自定义元素行为的类一定要继承自 HTMLElement,只有继承了它才能使用元素上的属性,如 onclick
、style
等,但其实继承它的子类同样也可以。
用 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 是所有元素的父类,它提供了元素的基础属性和方法,如 id
、 onclick
、onmouseover
、onkeydown
、onkeyup
等。
但是元素又分为两种,SVG 元素和 HTML 元素,它们提供的属性和方法又不一样,所以又会分为两个更加具体的类来扩展 Element
:SVGElement
和 HTMLElement
。
参考:Element | MDN
console.log(Object.getPrototypeOf(HTMLElement) === Element) // true
console.log(Object.getPrototypeOf(SVGElement) === Element) // true
不同的 HTML 元素的属性和方法也是不同的,例如 input
的 placeholder
在 div
元素上就没有。
所以说 HTMLElement
和 SVGElement
还能再往下细分,例如 HTMLElement
下还有 HTMLDivElement
、HTMLInputElement
、HTMLAnchorElement
等。
可以通过获取 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 元素多了很多属性和方法,例如 input
的 placeholder
,要想让继承自 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 = ''
}
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
属性指向自定义元素的标签名:
<input is="my-input" placeholder="输入内容" />
为什么不能直接使用
呢?
其实这是因为在 HTML 中有一部分标签是固定搭配,例如 ul > li
、table > tr > td
、dl > dt + dd
、select > option
,倒也不是不能在这些标签中写别的元素,但是写了别的就会失去原有的效果。
例如下例没有下拉选项:
<select>
<p value="a">Ap>
<p value="b">Bp>
select>
假如定义了一个
元素:
customElements.define(
'my-option',
class extends HTMLOptionElement {
constructor() {
super()
this.innerText = 'my-option'
}
},
{ extends: 'option' }
)
由于 HTML 规范要求 只识别
,下面的代码也不会如期生效:
<select>
<my-option value="a">Amy-option>
<my-option value="b">Bmy-option>
select>
为了既能保证 里包含的是
元素,又能扩展这个
标签并将其封装成组件,所以才会出现
is
这种写法。
<select>
<option is="my-option" value="a">Aoption>
<option is="my-option" value="b">Boption>
select>
但是 Safari 浏览器并没有实现 is
这个功能
Safari 浏览器对 Custom Elements 部分支持:“支持自定义元素,但是不支持自定义内置元素”。
不过已经有专门的 polyfill 来解决这个问题了,张鑫旭大神还为其扩展了浏览器区分的功能,详情参考:Safari不支持build-in自定义元素的兼容处理 « 张鑫旭
如果需要用 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)