Web Components学习(3)-深入理解自定义元素

attribute property

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

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

为了便于区分,下面将 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.idh2 元素上的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 的更新阶段对应的生命周期是 beforeUpdateupdated,在 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 中 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 元素的属性和方法也是不同的,例如 input 的 placeholder 在 div 元素上就没有。

所以说 HTMLElement SVGElement 还能再往下细分,例如 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 元素多了很多属性和方法,例如 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)

在这里插入图片描述

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值