前言
目前,我们开发项目都是基于 Vue 或 React 等框架来进行,不同的开发框架有着自己的开发规则,基于不同框架所开发的组件互相之间很难进行复用(vue&react),甚至不同版本的同一框架下开发的组件也存在差异导致无法复用(vue2&vue3),我们开发过程中所沉淀的组件通常只能在同版本框架下的项目之间进行复用。浏览器原生支持的 Web Components 的出现使我们能够摆脱框架的束缚来进行组件开发,真正做到了"Write once, run anywhere"。Web Components 是一个浏览器原生支持的组件化方案,其支持我们创建自定义可重用的元素,使用时不需加载任何额外的模块,其实我们一直在使用这项技术,input、video 和 audio 等就是原生的 Web Components,只是如今我们自己也可以使用这项技术去创造组件。Web Components 主要由三项技术组成:
- Custom Element:一组 JS API,允许定义 custom elements 及其行为;
- Shadow DOM:一组 JS API,用于将封装的影子 DOM 树附加到元素并控制关联的功能;
- HTML Template:
<template>
和<slot>
元素编写不在页面中显示的模板可以作为自定义元素基础被重用。
为了方便理解这些内容,我们通过一个简单的示例来手把手教你轻松入门 Web Components。我们想要开发一个 <info-card>
卡片组件,其支持展示自定义标题和描述文案,其中标题通过 title 属性传入,描述文案通过 desc 插槽传入,同时点击按钮能通过 custom-click 自定义事件来获取按钮的点击数,那么如何基于 Web Components 来开发这个组件呢?下面我们将通过 6 步来逐一讲解开发过程,并在开发完成后我们可以直接通过如下方式直接使用该组件:
html
<info-card title="This is title">
<span slot="desc">This is description! hello world!</span>
</info-card>
<script>
document.querySelector('info-card').addEventListener('onCustomClick', (evt) => { console.log(evt, evt.detail.count) });
</script>
Step1、创建并注册自定义元素
创建一个继承自 HTMLElement 的类用于声明自定义元素的功能,并使用 customElements.define() 方法来注册自定义元素。
tsx
class InfoCard extends HTMLElement {
constructor() {
super();
// 元素的功能代码写在这里
...
}
}
customElements.define('info-card', InfoCard); // 注册自定义标签
注册完成后我们在浏览器中通过<info-card></info-card>
标签即可直接使用,自定义元素根据是否继承自内建 HTML 元素可以分为两种:
- autonomous custom elements 不继承自内建的 HTML 元素,使用
customElements.define('info-card', InfoCard)
注册后通过<info-card>
或document.createElement("info-card")
使用; - customized built-in elements 继承自内建的 HTML 元素,使用
customElements.define('info-card', InfoCard, {extends: 'p'})
注册后通过<p is="info-card">
或document.createElement("p", { is: "info-card" })
使用。
注意,如果我们在一个项目中注册同名的自定义标签则浏览器会报错,因此为了防止出现这样的情况,我们可以在注册自定义标签时通过 CustomElement.get() 方法是否返回指定名字的自定义元素的构造函数来判断是否已经存在使用指定名称的自定义元素。
tsx
if (!customElements.get(tag)) {
customElements.define(tag, MyCustomElement);
}
// 还可以通过 whenDefined 来捕获重复定义的报错
customElements.whenDefined(tag).then(() => {
customElements.define(tag, MyCustomElement);
}).catch((err) => {
console.log(err); // 会捕获重复定义的报错
});
Step2、附加 Shadow DOM
创建 Shadow DOM 并附加到自定义元素上。顾名思义,Shadow DOM 是隐藏的,标签内部的HTML 结构会存在于 #shdaow-root 上,而不会在真实的 dom 树中出现,这样可以更好地封装和隔离自定义标签的样式和行为,避免与其他元素发生冲突。
tsx
class InfoCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' }); // 设置 open 表示可以通过页面内的 JavaScript 方法来获取 Shadow DOM
this.shadowRoot.innerHTML = 'Rendering from Shadow DOM';
}
}
customElements.define('info-card', InfoCard);
mode 属性如果设置为 open,也就是允许在外部通过document.querySelector("info-card").shadowRoot
来获取到 Shadow DOM;如果 mode 设置为 closed 则会返回值 null。
Step3、使用模版和插槽
通过 template 和 slot 将自定义内容插入到标记位置,并将其作为 shadow dom 的内容,使得组件具备模板/插槽的基本能力,能够更加灵活且更好地被复用。
html
<template id="info-card">
<style>...</style>
<div class="info-card-wrapper">
<div class="title">This is title</div>
<div class="desc">
<slot name="desc"></slot>
</div>
</div>
</template>
<script>
class InfoCard extends HTMLElement {
constructor() {
super();
const template = document.getElementById('info-card').content;
const shadow = this.attachShadow({ mode: 'open' });
shadow.appendChild(template.cloneNode(true));
}
}
customElements.define('info-card', InfoCard);
</script>
除了使用 template 模版之外,我们还可以自定义一个 render 函数,利用模版字符串来避免通过原生的方法使用大量 JS 来创建节点,从而达到复用和便捷的目的。
tsx
const render = (title) => {
return `
<style>...</style>
<div class="info-card-wrapper">
<div class="title">This is title</div>
<div class="desc"><slot name="desc"></slot></div>
</div>
`;
};
class InfoCard extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = render();
}
}
customElements.define('info-card', InfoCard);
Step4、设置 CSS 样式
设置 CSS 样式有着非常多的方式,例如通过 JS 直接设置 style 属性、模版中直接在 style 标签里设置样式、通过 CSSStyleSheet 新建样式并设置到 adoptedStyleSheets 上、引入外部 CSS 文件等等。对于外部文件的引入,官方推荐利用浏览器原生的 import 方法将 css 文件当作模块引入,然后将内容设置到 adoptedStyleSheets 上。
tsx
import styles from 'index.css';
class InfoCard extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
const styles2 = new CSSStyleSheet();
styles2.replaceSync(`
:host {
color: red;
}
`)
shadow.adoptedStyleSheets = [...this.shadowRoot.adoptedStyleSheets, styles, styles2];
// ...
}
}
customElements.define('info-card', InfoCard);
在上述代码中有使用:host
伪类,那么在选择器方面除此之外还有哪些在 web components 中常用的伪类与伪元素呢?具体示例详见 👉 codesandbox
- :host 作用于 shadow host;
- :host-context() 作用于 shadow host 仅当它是给定的选择器参数的后代;
- :host() 作用于 shadow host 仅当它与选择器参数匹配;
- :defined 作用于已定义的元素,常用于元素定义失败场景将其隐藏起来,避免页面错乱;
- ::slotted 作用于插槽元素,注意匹配只能是插槽元素本身,而不是它的子元素 。
- ......
⚠️ Shadow DOM 样式隔离是一把双刃剑,在我们享受样式隔离所带来的便利时,又面临着无法继承样式的问题,由于组件内不再继承 DOM tree 中的样式,因此我们需要重新定义或引用,例如重新引入外部使用的公共样式文件等。
Step5、响应式属性和状态
标题在通过 DOM title 属性传入,当属性值发生变换时需要同步更新视图,因此我们需要监听属性的变化。首先定义observedAttributes
get 函数指定需要监听 title ,然后每当 title 变化时,attributeChangedCallback()
回调函数会执行,最后添加一个属性 getter/setter 以提供对 title 属性访问,其通过getAttribute/setAttribute
来同步组件上的 title 属性。
按钮点击数为组件内部的状态,当我们在视图上显示按钮点击数时,我们也需要使其具备响应性,当内容变化时能够同步更新视图。首先创建组件的内部状态 _count
,并定义 count
属性的 getter 和 setter,用于访问和修改内部状态 _count
,当 count
属性被修改时,setter 方法会更新内部状态并调用相应方法来更新视图。
tsx
class InfoCard extends HTMLElement {
constructor() {
super();
// 设置内部状态
this._count = 0;
const template = document.getElementById('info-card').content;
const shadow = this.attachShadow({ mode: 'open' });
shadow.appendChild(template.cloneNode(true));
}
/**
* 内部状态 count 响应式
*/
get count() {
return this._count;
}
set count(value) {
this._count = value;
// ...值更新可以执行一些操作
}
/**
* 外部属性 title 响应式
*/
static get observedAttributes() {
return ['title'];
}
get title() {
return this.getAttribute('title');
}
set title(value) {
this.setAttribute('title', value);
}
connectedCallback() {
if (!this.title) {
this.title = 'default title';
}
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'title') {
// ...值更新可以执行一些操作
}
}
}
上述代码中使用了 connectedCallback 和 attributeChangedCallback 两个生命周期回调函数,web components 有哪些生命周期呢?web components 主要有 4 个生命周期回调函数:
- connectedCallback:当 custom element 首次被插入文档 DOM 时被调用。
- disconnectedCallback:当 custom element 从文档 DOM 中删除时被调用。
- adoptedCallback:当 custom element 被移动到新的文档时被调用。
- attributeChangedCallback: 当 custom element 增加、删除、修改自身属性时被调用。
💡 组件通信之父组件传值给子组件:内部状态(组件实例属性)同样也可以作为外部通信传值使用,直接在外部对其进行赋值变更即可。因此,Web Components 组件传值方式主要有两种,通过 DOM 属性传值和通过组件实例属性传值。具体示例详见 👉 codesandbox。通过 DOM 属性传值由于只支持字符串形式传递,因此对于复杂对象的传递需要通过JSON.parse
和JSON.stringify
来进行处理。
Step6、自定义事件
最后是添加自定义事件,Web Components 的自定义事件可以借助 CustomEvent 和 dispatchEvent 来实现(详见创建和触发 events),其中 dispatchEvent 会向一个指定的事件目标派发一个 Event,CustomEvent 可通过 detail 属性传递自定义数据。
tsx
class InfoCard extends HTMLElement {
constructor() {
super();
// ...
this.$btn.addEventListener('click', () => {
this.dispatchEvent(
new CustomEvent('onCustomClick', {
detail: this.count,
})
);
});
}
}
// info-card 上监听 onCustomClick 事件即可获取到
💡 组件通信之子组件调用父组件方法:除了上面这种"利用原生 CustomEvent 函数来创建自定义事件,然后在子组件实例上派发事件以及数据,同时在父组件上进行监听"的方法之外,还可以"直接获取父组件实例,然后直接调用父组件方法"。具体示例详见 👉 codesandbox
至此,相信大家对 Web Components 的使用有了一个简单的印象了,示例完整代码详见 👉 codesanbox 推荐阅读:Web Components 最佳实践!
🎉 更多关于 Web Components 的内容:优缺点、开发库、应用场景、当前现状 等内容请移步阅读 《Web Components 探索之旅,出发!》