WebComponent
前端的发展总是离不开 HTML JavaScript CSS。在前端的发展中代码复用和模块化是绕不开的话题,而现在前端开发离不开的 Vue、React、Angular 都有一个核心观点就是组件化和组件复用,但是三个框架各自有自己的实现方式和不同的语法。在过去的原生 js 开发中要想实现一个可复用的组件可谓是相当复杂,但是自从 Web Component 标准出来后基于原生的组件化开发便不再是一个难题。
Web Component 基础
在 Web Component 中有三个基本的概念,这三个概念构成了整个 Web Component
- Custom element 自定义元素:一组 js api 允许开发者自定义元素和元素的行为
- Shadow Dom 影子元素:一组 js api 用于将开发者自定义的影子 DOM 添加到元素中。影子 DOM 可以插入到一般的文档 DOM 中同时也可以和基本的 DOM 有所区分,而开发者可以控制操控两者之间的关系,可以保持元素的功能私有,这样它们就可以被脚本化和样式化,而不用担心与文档的其他部分发生冲突
- HTML template 模板:template 和 slot 元素,通过他们可以编写一组不在页面呈现的标记模板,然后通过他们可以自定义元素结构和复用
如何实现
MDN 中介绍,实现一个 web component 的基本方法有以下几个步骤:
- 创建一个类指定 web 组件的功能
- 使用 CustomElementRegistry.define() 注册自定义元素,可以传递元素名称、指定元素的功能类等
- 如果需要可以使用 Element.attachShadow() 将一个 shadow DOM 添加到自定义元素上
- 如果需要使用 template slot 定义 HTML 模板,使用 DOM 方法克隆模板并添加到 shadow DOM 中
- 在页面使用自定义元素
一些概念
custom element
- customElementRegistry 自定义元素相关功能,其中 customElementRegistry.define() 用于注册新的自定义元素
- window.customElements 返回 customElementRegistry 对象的引用 一般实际是这样写的
js
window.customElements.define('custom-component-name', customComponent, { ...config });
- 生命周期函数(回调) 像 Vue React Angular 一样,web component 也是有生命周期函数的
- connectedCallback:元素首次被添加到文档时调用
- disconnectedCallback:元素移除文档时调用
- adoptedCallback:元素被移动移动到新文档时调用
- attributeChangedCallback:元素的属性被增加、移动、删除时被调用
js
class CustomComponent extends HtmlElement {
constructor() {
super();
}
connectedCallback() {
console.log('元素被添加到文档');
}
disconnectedCallback() {
console.log('元素被移除文档时调用');
}
adoptedCallback() {
console.log('元素被移动到新文档时调用');
}
attributeChangedCallback(name, oldValue, newValue) {
console.log(`${name} 属性改变了 旧值:${oldValue} 新值:${newValue}`);
}
}
window.customElements.define('custom-component', CustomComponent);
- is 属性,一个全局 HTML 属性,允许指定标准的 HTML 元素像定义的内置元素一样工作
js
// 一个 demo
class WordCount extends HTMLParagraphElement {
constructor() {
super();
}
// 自定义行为
}
customElements.define('word-count', WordCount, { extends: 'p' });
html
<p is="word-count"></p>
注意 只有当前文档中以及成功的定义该元素并且扩展了要应用的元素时才可以使用该属性 document.createElement 中也新增了 is 属性
js
document.createElement('p', { is: 'word-count' });
- css 伪类
- :defined 匹配任何已定义的元素,包括内置元素和使用 CustomElementRegistry.define() 定义的自定义元素。
- :host 选中 shadow DOM 的 shadow host,内容是其内部使用的 css,就是选中 shadow dom 的根节点。
- :host() 选择包含使用这段 CSS 的 Shadow DOM 的影子宿主(这样就可以从 Shadow DOM 中选择包括它的自定义元素)------但前提是该函数的参数与选择的阴影宿主相匹配 这是个实验性特性 使用方式类似于 Vue3 中的 :deep() 深度选择器
- :host-context() 选择内部使用了该 CSS 的影子 DOM(shadow DOM)的影子宿主(shadow host),因此你可以从其影子 DOM 内部选择自定义元素------但前提是作为函数参数的选择器与影子宿主的祖先在 DOM 层次结构中的位置匹配 比较难理解可以参考 MDN[developer.mozilla.org/zh-CN/docs/...]
shadow dom
在 web component 中的一个核心点就是封装,而 shadow dom 是实现封装的关键所在,利用 shadow dom 可以将一个隐藏的、独立的、完整的 dom 附加到一个元素上,shadow dom 的内容较多详细请参考 MDN[developer.mozilla.org/zh-CN/docs/...] shadow dom 本质也是一个 dom 对象,你可以像操作普通 dom 元素一样使用 js 操作 shadow dom
- ShadowRoot shadow dom 的根节点,他会与文档的主 DOM 分开渲染
- attachShadow() 指定一个自定义元素开启 shadow dom,返回一个对 ShadowRoot 的引用 个人理解就是通过这个 api 去创建一个根 shadow dom
js
class CustomElement extends HtmlElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
const div = document.createElement('div');
shadow.appendChild(div);
}
}
该 api 接收一个配置对象,该对象有一个 mode 属性可以是 open closed,其中 open 表示可以通过 js 去获取该 shadow dom ,如果将 mode 配置为 closed 那么外部则不能够获取该 shadow dom
js
// mode:open 返回值是 一个 shadow dom 对象 如果是 closed 返回值为 null
const shadow = customElement.shadowRoot;
- getRootNode Api 我们都知道在操作普通 dom 时,该 api 会返回上下文中的根节点,对于 shadow dom 来说如果该 shadow dom 可用,则会像普通 dom 一样返回与之关联的 shadowRoot,这个 api 多了一个配置对象
- composed:Boolean,表示是否跳过检索 shadow dom,默认是 false 表示不跳过
js
// composed 为 false 表示不跳过检查
const root = node.getRootNode({ composed: false });
- isConnected 返回一个布尔值表示节点是否连接(直接或间接)到上下文对象。在普通 DOM 的情况下为 Document 对象,或者在 shadow DOM 的情况下为 ShadowRoot
js
// 普通 dom 树
const commonEle = document.createElement('div');
console.log(commonEle.isConnected); //false
document.body.appendChild(commonEle);
console.log(commonEle.isConnected); //true
// shadow dom
const shadowEle = this.attachShadow({ mode: 'open' });
const someStyle = document.createElement('style');
console.log(someStyle.isConnected); // false
style.textContent = `.container{color:red}`;
shadowEle.appendChild(someStyle);
console.log(someStyle.isConnected); // true
-
Event.composed 和 Event.composedPath
-
Event.composed 返回一个布尔值,表示事件是否会通过 shadow dom 往外界传递(冒泡)到标准 dom,这需要一个前提,如果想让事件冒泡,那么该事件的 bubbles 必须为 true
-
Event.composedPath 返回事件的路径(侦听器将被调用的对象)。如果 shadow root 是使用 ShadowRoot.mode 为 closed 创建的,则不包括 shadow 树中的节点
:chestnut:
-
js
// 声明一个 mode 为 open 的 shadow dom
customElements.define(
'custom-ele-open',
class extends HTMLElement {
constructor() {
super();
const p = document.createElement('p');
p.textContent = this.getAttribute('text');
const shadow = this.attachShadow({ mode: 'open' }).appendChild(p);
}
}
);
// 声明一个 mode 为 closed 的 shadow dom
customElements.define(
'custom-ele-closed',
class extends HTMLElement {
constructor() {
super();
const p = document.createElement('p');
p.textContent = this.getAttribute('text');
const shadow = this.attachShadow({ mode: 'closed' }).appendChild(p);
}
}
);
html
<custom-ele-open text="这是一些消息"></custom-ele-open>
<custom-ele-closed text="这是一些消息"></custom-ele-closed>
js
document.querySelector('html').addEventListener('click', function (e) {
console.log(e.composed); //true
console.log(e.composedPath);
});
当分别点击两个元素时,composed 返回为 true,(因为 click 事件默认是可以冒泡传递的) 但是两个元素 click 事件的 composedPath 是不一样的 custom-ele-open: [ p, ShadowRoot, custom-ele-open, body, html, HTMLDocument mdn.github.io/web-compone..., Window ]
custom-ele-closed:[ custom-ele-closed, body, html, HTMLDocument mdn.github.io/web-compone..., Window ]
HTML template
-
<template>
是一种用于保存客户端内容机制,该内容在加载页面时不会呈现,但随后可能在运行时使用 JavaScript 实例化。<template>
上有一个 content 属性,返回 template 模板内容的一个文档片段 documentFragment
-
<slot>
在 web component 中这表示一个占位符,可以填充自己的标记,如果熟悉 Vue 的话这个就很容易理解了,这是一个插槽,可以传递 dom 结构- name 属性,该插槽的名字,在 Vue 中叫做具名插槽,会通过 name 匹配
-
element.slot element.assignSlot
- element.slot 返回已插入元素所在的 Shadow DOM slot 的名称 就是 slot 的 name
- element.assignSlot 返回已插入元素的 slot 引用 就是 slot 的内容
js
customElements.define(
'my-paragraph',
class extends HTMLElement {
constructor() {
super();
const template = document.getElementById('my-paragraph');
const templateContent = template.content;
this.attachShadow({ mode: 'open' }).appendChild(templateContent.cloneNode(true));
}
}
);
const slottedSpan = document.querySelector('my-paragraph span');
console.log(slottedSpan.assignedSlot); // slot 内容
console.log(slottedSpan.slot); // my-text
html
<template id="my-paragraph">
<style>
p {
color: white;
background-color: #666;
padding: 5px;
}
</style>
<p><slot name="my-text">My default text</slot></p>
</template>
<my-paragraph>
<span slot="my-text">Let's have some different text!</span>
</my-paragraph>
<my-paragraph>
<ul slot="my-text">
<li>Let's have some different text!</li>
<li>In a list!</li>
</ul>
</my-paragraph>
- ::slotted 匹配任何已经插入一个 slot 的内容 用法也类似于 ::deep()
css
::slotted(*) {
color: red;
}
::slotted(span) {
color: blue;
}