当下,在前端开发中,组件化开发方式越来越受到开发者的喜爱,因为一个组件基本可以实现一个相对独立的功能,复用性很强。
在使用三大主流框架开发应用时,我们会不停地创建各种各样的组件来提高开发效率,甚至会应用一些成熟的 UI 组件库,但有一个问题不得不提,那就是使用框架创建的组件会依赖框架本身,无法在其他框架中使用,导致更换框架时也需要更换组件库,造成重复劳动,而 Web Components 可以很好地解决这一问题。
Web Components 允许开发者将 html 页面的功能封装为 custom elements(自定义元素,在 JavaScript 中是 DOM 对象,如 HTMLParagraphElement
;在 html 中是标签,如 <p>
),自定义元素和组件一样,可以拥有独立的功能,由于它是基于浏览器标准的,可以和普通标签一样随意使用,这篇文章就来聊一聊如何使用 Web Components 创建组件。
创建自定义元素
虽然使用 Web Components 可以创建自定义元素,但其毕竟是基于浏览器标准的,因此在创建时需要继承原生 DOM 对象,创建方法为:
customElements.define(name, constructor, options);
name
为自定义元素的名称,为了和原生元素区别开来,自定义元素的名称中必须含有短横线,如my-button
;constructor
为该自定义元素的构造器;options
接收一个可选的配置对象,目前仅有一个属性extends
,其值为原生元素的名称,用于指定继承哪一个原生 DOM 对象,如button
;当指定了extends
时,构造器必须继承对应 DOM 对象,否则,一律继承HTMLElement
对象;extends
对于以后如何使用该自定义元素会有影响,后文会做讲解。
从参数可以看出,创建自定义元素最重要的就是创建构造器。接下来,本文将通过介绍两种方法来创建构造器(纯 JavaScript 方式和 Web Components 方式),实现同一个自定义按钮元素 <my-button>
。
纯 JavaScript 方式
此方法就是完全使用 DOM 操作来创建构造器:
class MyButton extends HTMLElement {
constructor() {
super();
const btn = document.createElement('button');
btn.textContent = '自定义按钮';
btn.className = 'my-btn';
const style = document.createElement('style');
style.textContent = `
.my-btn{
display: inline-block;
line-height: 1;
white-space: nowrap;
cursor: pointer;
text-align: center;
box-sizing: border-box;
outline: none;
margin: 0;
transition: .1s;
font-weight: 500;
padding: 12px 20px;
font-size: 14px;
border-radius: 4px;
color: #fff;
background-color: #409eff;
border:1px solid #409eff;
}`;
this.appendChild(style);
this.appendChild(btn);
}
}
customElements.define('my-button', MyButton);
使用时和普通 button
标签一样:
<my-button></my-button>
在页面上将出现一个蓝色的按钮:
在调用 define
时,如果我们指定 extends
为 button
:
customElements.define('my-button', MyButton, {extends: 'button'});
则 MyButton
需要改为继承 HTMLButtonElement
:
class MyButton extends HTMLButtonElement {
//内部代码不变
}
使用时通过原生标签的 is
属性指定自定义元素名称:
<button is="my-button"></button>
得到的效果如下:
可以看到自定义元素多了一圈黑色的边框,这是因为,我们是在定义的 MyButton
构造器中添加的 button
元素,等同于给 button
又嵌了一个 button
。
指定了 extends
的自定义元素称为 customized built-in elements,而未指定 extends
的自定义元素称为 autonomous custom elements,注意两者的区别。
从构造器内部的代码可以看出,一旦自定义元素内容较多,写起来将非常吃力,尤其是在编辑样式时。
Web Components 方式
由于纯 JavaScript 方式创建自定义元素的这些问题,HTML5 提供了一些新的元素、属性和方法,便于更好地创建自定义元素,其中最重要的就是 template
元素。
template 元素
当页面上出现 template
元素时,其内容并不会被渲染出来,但可以使用常规的 DOM 方法访问,因此我们可以将 my-button
的内部 html 代码放到一个 template
元素中:
<template id="my-button">
<style>
.my-btn {
display: inline-block;
line-height: 1;
white-space: nowrap;
cursor: pointer;
text-align: center;
box-sizing: border-box;
outline: none;
margin: 0;
transition: .1s;
font-weight: 500;
padding: 12px 20px;
font-size: 14px;
border-radius: 4px;
color: #fff;
background-color: #409eff;
border: 1px solid #409eff;
}
</style>
<button class="my-btn">自定义按钮</button>
</template>
然后在在构造器中去使用:
class MyButton extends HTMLElement {
constructor() {
super();
const templateElm = document.getElementById('my-button');
const content = templateElm.content.cloneNode(true);
this.appendChild(content);
}
}
得到的效果和采用纯 JavaScript 方式创建构造器时是一样的,但这种写法肯定更加容易阅读与调试。
事实上,使用 template
元素来承载自定义元素的内部代码在解决了阅读与调试问题的同时,也带来了一个新的问题,那就是当编写一个独立的公共组件时,如何处理 template
元素?
答案是,目前并没有一个好的解决办法😂。
shadow DOM
通过前文的两种方式,我们就可以实现自定义元素,但仔细一看会发现,这和以前创建各种插件的方式并没有多大区别。不过,Web Components 提供了一个接口让它们有了区别,这个接口就是 shadow DOM。
shadow DOM,直译过来可以称作影子 DOM,它可以将一个隐藏的、独立的 DOM 附加到一个元素上,并将其内部的结构、样式和行为都隐藏起来, 除非开启了特定的属性,否则内外 DOM 完全隔离,互不影响。
回想一下我们使用的 video
、audio
元素,它们有自己的方法、样式,不会受到外部代码的影响,有了 shadow DOM,我们也可以实现这样的自定义元素。
使用 shadow DOM 的方法也很简单,调用元素的 attachShadow
方法即可:
class MyButton extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({mode: 'open'});
const templateElm = document.getElementById('my-button');
const content = templateElm.content.cloneNode(true);
shadow.appendChild(content);
}
}
得到的效果和未使用 shadow DOM 时一致。
attachShadow
接收一个参数对象,目前仅有一个属性 mode
,值为 open
或 closed
,当值为 closed
时外部将无法获取到自定义元素的 shadow DOM,从而实现了和 video
元素一样的效果。
要获取自定义元素的 shadow DOM 可以使用 shadowRoot
属性:
console.log(document.querySelector('my-button').shadowRoot);
得到:
如果 mode
值为 closed
,返回值将为 null
。获取到的 shadow DOM 和普通 DOM 元素在使用上没有任何区别。
提示
需要注意的是,并不是所有元素都有 attachSahdow
方法,这里可以查看所有可使用该方法的元素。
如果我们在外部的添加一个同名 class
:
.my-btn {
background-color: lawngreen;
}
按钮并不会受到影响,我们最担心的样式覆盖问题也彻底得到了解决。
参数传递
在使用上述的自定义元素时,如果我们尝试像使用普通 button
一样去修改 my-button
的文本内容:
<my-button>改变元素文本</my-button>
会发现并没有得到预期的效果:
这是因为在自定义元素的构造器内部,我们是将内部元素“append”到元素上的,所以文本还会渲染到内部 button
之前。这肯定是不满足需求的,用户在使用自定义元素时必然要对元素做一些修改。
那么如何让用户可以修改自定义元素呢?有三种方式可以做到:使用自定义属性、使用 slot
元素以及使用伪类。
使用自定义属性
删除 button
中原有的文本,在构造器内部,预定一些可以用于修改自定义元素的属性,并实现修改功能:
class MyButton extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({mode: 'open'});
const templateElm = document.getElementById('my-button');
const content = templateElm.content.cloneNode(true);
content.querySelector('button').innerText = this.getAttribute('name');
shadow.appendChild(content);
}
}
使用时,向这些属性传入需要的值:
<my-button name="新的按钮文本"></my-button>
得到的效果如下:
对于某些特殊的元素如 input
登,其本身就有 name
属性,在给自定义属性命名时应当考虑是否需要避免使用这些名称。
使用 slot 元素
一般来说,用户不仅想修改自定义元素的文本,也许还想在文本前(或后)添加一个图标(或随便什么元素),此时自定义属性就不那么好用了,不过 HTML5 提供了 slot
元素。
修改自定义元素的代码,在 template
中预留图标的位置:
<button class="my-btn"><slot></slot></button>
使用时:
<my-button name="新的按钮文本"><i class="fa fa-edit"></i></my-button>
得到的效果如下:
还可以通过 slot
元素的 name
定向插入内容,详情可以点此查看。
使用伪类
前两种方法都是在修改自定义元素的 html 内容,用户当然也希望能够修改元素的样式,通过自定义属性的方式可以实现修改样式,但需要经过 DOM 操作,因此 Web Components 提供了一些新的伪类可以做到不通过 DOM 操作。
本文并不准备讲解所有的伪类,将仅演示 :host
的用法,修改 template
中的 style
,使用 :host
伪类定义一个颜色变量,并在 .my-btn
中使用它:
<style>
:host {
--background-color: #409eff;
}
.my-btn {
display: inline-block;
line-height: 1;
white-space: nowrap;
cursor: pointer;
text-align: center;
box-sizing: border-box;
outline: none;
margin: 0;
transition: .1s;
font-weight: 500;
padding: 12px 20px;
font-size: 14px;
border-radius: 4px;
color: #fff;
background-color: var(--background-color);
border: 1px solid var(--background-color);
}
</style>
在外部样式中就可以通过修改变量值实现修改自定义元素的样式:
my-button {
--background-color: lawngreen;
}
得到的效果如下:
:host
伪类可以选中 shadow DOM 的宿主元素,在本例中即为 my-button
,因此当修改 my-button
中变量的值后,自定义元素内部的样式也就改变了。
生命周期
和依赖框架的组件库一样,Web Components 中也有生命周期,分别是:connectedCallback
、disconnectedCallback
、adoptedCallback
和 attributeChangedCallback
。
connectedCallback
,当自定义元素第一次被连接到文档 DOM 时调用;disconnectedCallback
,当自定义元素第一次与文档 DOM 断开连接时调用;adoptedCallback
,当自定义元素被移动到新文档时调用;attributeChangedCallback
,当自定义元素的一个属性被增加、移除或修改时调用。
前两个函数很好理解,也很好使用,但第三个和第四个就需要注意了。
第三个函数只有在自定义元素被移动到新文档时才会被调用,也就是说如果将自定义元素从一个元素移动到了另一个元素中,并不会触发该函数。
而第四个函数直接使用是无效的,它必须配合静态方法 observedAttributes
一起使用。
observedAttributes
方法内部就一条 return
语句,返回要监听的属性数组,只有该数组中的属性值改变时 attributeChangedCallback
才会调用:
class MyButton extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({mode: 'open'});
const templateElm = document.getElementById('my-button');
const content = templateElm.content.cloneNode(true);
content.querySelector('button').innerText = this.getAttribute('name');
shadow.appendChild(content);
}
static get observedAttributes() {
return ['name'];
}
connectedCallback() {
console.log('connected')
}
disconnectedCallback() {
console.log('disconnected')
}
adoptedCallback() {
console.log("I'm been moved")
}
attributeChangedCallback(attrName, oldVal, newVal) {
console.log(`the old value of ${attrName} is ${oldVal}, new value is ${newVal}`)
this.shadowRoot.querySelector('button').innerText = this.getAttribute('name');
}
}
事实上,在自定义元素初次被添加到文档中时,attributeChangedCallback
也会调用,因为 name
的初始值为 null
,所以 constructor
中这条语句也可以不要了:
content.querySelector('button').innerText = this.getAttribute('name');
在 attributeChangedCallback
中执行即可。
结语
对于使用过 Vue 的朋友来说,会发现文中的 template
、slot
看上去非常熟悉,这是因为 Vue 本身参考了这些标准。
相对于依赖框架的组件库来说, Web Components 有更高的通用性,不受框架限制,虽然还处于发展之中,但在兼容性要求不高的情况下已经可以使用了。