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)
// <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 的 attribute 与 property 的双向绑定功能。
而 Web Components 的生命周期函数attributeChangedCallback
就是特性 attribute
改变之后的回调函数。
Web Components 与 MVVM 框架更新阶段的区别
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 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 中 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
之前说到定义自定义元素行为的类一定要继承自 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
。
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 = '<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="输入内容" />
为什么不能直接使用 呢?
其实这是因为在 HTML 中有一部分标签是固定搭配,例如 ul > li、table > tr > td、dl > dt + dd、select > 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)