WebComponents的概念
Web Components 是一组技术,旨在使开发者能够创建可重用、独立于框架的自定义组件。它包括以下四个主要技术: Custom Elements(自定义元素):
-
Custom Elements 允许开发者定义自己的 HTML 元素,例如 。 通过继承 HTMLElement 类,可以定义自定义元素的行为和生命周期方法。 可以在 HTML 中使用这些自定义元素,并通过 JavaScript 进行操作。 Shadow DOM(影子 DOM):
-
Shadow DOM 提供了一种将元素的样式和行为封装在隔离的 DOM 树中的机制。 可以在自定义元素中使用 Shadow DOM,以避免样式和脚本的全局污染。 影子 DOM 的内容对外部文档是不可见的。
-
HTML Templates(HTML 模板): HTML 模板是一种在文档中定义的不会被渲染的 HTML 片段。 可以在模板中定义组件的结构,然后通过 JavaScript 克隆和激活模板。 模板的使用使得组件的结构可以在不被渲染的情况下进行定义和操纵。
Custom Elements
customElementRegistry.define(); 参数说明:
- name:元素的名称。必须以小写字母开头,包含一个连字符,并符合规范中有效名称的定义中列出的一些其他规则。
- constructor:自定义元素的构造函数。
- options:仅对于自定义内置元素,这是一个包含单个属性 extends 的对象,该属性是一个字符串,命名了要扩展的内置元素。
有两种类型的自定义元素:
- 定义内置元素(Customized built-in element)继承自标准的 HTML 元素,例如 HTMLImageElement 或 HTMLParagraphElement。它们的实现定义了标准元素的行为。
- 独立自定义元素(Autonomous custom element)继承自 HTML 元素基类 HTMLElement。你必须从头开始实现它们的行为。
定义内置元素:
html
<button is="hello-button">Click me</button>
js
// 这个按钮在被点击的时候说 "hello"
class HelloButton extends HTMLButtonElement {
constructor() {
super();
this.addEventListener('click', () => alert("Hello!"));
}
}
customElements.define('hello-button', HelloButton, {extends: 'button'});
自定义元素生命周期回调包括:
- connectedCallback():每当元素添加到文档中时调用。规范建议开发人员尽可能在此回调中实现自定义元素的设定,而不是在构造函数中实现。
- disconnectedCallback():每当元素从文档中移除时调用。
- adoptedCallback():每当元素被移动到新文档中时调用。
- attributeChangedCallback():在属性更改、添加、移除或替换时调用。
创建自定义元素
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Custom Element Example</title>
</head>
<body>
<my-custom-element></my-custom-element>
<script>
class MyCustomElement extends HTMLElement {
constructor() {
super();
console.log('Custom element created');
}
connectedCallback() {
console.log('Custom element connected to the document');
this.render();
}
disconnectedCallback() {
console.log('Custom element disconnected from the document');
}
// name 发生变化的属性
// oldValue 旧值
// newValue 新值
attributeChangedCallback(name, oldValue, newValue) {
console.log(`属性 ${name} 已由 ${oldValue} 变更为 ${newValue}。`);
}
render() {
this.innerHTML = `
<p>Hello, World!</p>
`
}
}
customElements.define('my-custom-element', MyCustomElement);
</script>
</body>
</html>
自定义元素生命周期回调包括:
- connectedCallback():每当元素添加到文档中时调用。规范建议开发人员尽可能在此回调中实现自定义元素的设定,而不是在构造函数中实现。
- disconnectedCallback():每当元素从文档中移除时调用。
- adoptedCallback():每当元素被移动到新文档中时调用。
- attributeChangedCallback():在属性更改、添加、移除或替换时调用
Shadow DOM
Shadow DOM(影子 DOM)是 Web Components 技术的一部分,用于将元素的样式和行为封装在一个隔离的 DOM 树中。这种封装使得元素的样式和脚本不会影响到文档的其他部分,从而避免了全局作用域的污染和样式冲突。
- 影子宿主(Shadow host): 影子 DOM 附加到的常规 DOM 节点。
- 影子树(Shadow tree): 影子 DOM 内部的 DOM 树。
- 影子边界(Shadow boundary): 影子 DOM 终止,常规 DOM 开始的地方。
- 影子根(Shadow root): 影子树的根节点。
一个 DOM 元素可以有以下两类 DOM 子树:
- Light tree(光明树) ------ 一个常规 DOM 子树,由 HTML 子元素组成。我们在之前章节看到的所有子树都是「光明的」。
- Shadow tree(影子树) ------ 一个隐藏的 DOM 子树,不在 HTML 中反映,无法被察觉。
创建 Shadow DOM 需要调用宿主上的 attachShadow(),传入 { mode: "open" },这时页面中的 JavaScript 可以通过影子宿主的 shadowRoot 属性访问影子 DOM 的内部。
html
<!DOCTYPE html>
<body>
<style>
/* 文档样式对 #elem 内的 shadow tree 无作用 (1) */
p {
color: red;
}
</style>
<div id="elem"></div>
<script>
const el = document.querySelector("#elem"); // 宿主
el.attachShadow({mode: "open"}); // 返回 shadowRoot
// shadow tree 有自己的样式 (2)
el.shadowRoot.innerHTML = `
<style> p { font-weight: bold; } </style>
<p>Hello, John!</p>
`;
// <p> 只对 shadow tree 里面的查询可见 (3)
alert(document.querySelectorAll("p").length); // 0
alert(el.shadowRoot.querySelectorAll("p").length); // 1
</script>
</body>
在影子 DOM 内应用样式
有两种不同的方法来在影子 DOM 树中应用样式:
- 编程式,通过构建一个 CSSStyleSheet 对象并将其附加到影子根。
- 声明式,通过在一个
<template>
元素的声明中添加一个<style>
元素。
编程式样式步骤:
- 创建一个空的 CSSStyleSheet 对象
- 使用 CSSStyleSheet.replace() 或 CSSStyleSheet.replaceSync() 设置其内容
- 通过将其赋给 ShadowRoot.adoptedStyleSheets 来添加到影子根
在 CSSStyleSheet 中定义的规则将局限在影子 DOM 树的内部,以及我们将其分配到的任何其它 DOM 树。当你当样式内容较多,或者需要与其他当自定义组件共享样式当时候,你可以考虑使用这种方。
js
// ... 省略部分代码 el
const sheet = new CSSStyleSheet();
sheet.replaceSync("p { color: red; border: 2px dotted black;}");
el.shadowRoot.adoptedStyleSheets = [sheet];
声明式样式步骤:
- 将一个
<style>
元素包含在用于定义 web 组件的<template>
元素中 - 将
<template>
中的内容插入到 shadowRoot
html
<template id="my-element">
<style>
span {
color: red;
border: 2px dotted black;
}
</style>
<span>I'm in the shadow DOM</span>
</template>
<script>
// 省略部分代码 el
// 克隆模板内容
const template = document.querySelector("#my-element");
const clone = document.importNode(template.content, true);
el.shadowRoot.appendChild(clone)
</script>
另外还有使用 css 选择器的方式来设置样式,具体请参考 css域。
template 与 slot
template 标签
内建的 <template>
元素用来存储 HTML 模板。浏览器将忽略它的内容,仅检查语法的有效性,但是我们可以在 JavaScript 中访问和使用它来创建其他元素。
模板(template)的 content 属性可看作DocumentFragment ------ 一种特殊的 DOM 节点。我们可以将其视为普通的DOM节点,除了它有一个特殊属性:将其插入某个位置时,会被插入的则是其子节点。
template 的使用方式上面的案例中已经展示过了,接下来我们要使用 slot (插槽)增加灵活度。
slot 插槽
- 具名插槽: 在 shadow DOM 中, 定义了一个"插入点",一个带有 slot="X" 的元素被渲染的地方。然后浏览器执行"组合":它从 light DOM 中获取元素并且渲染到 shadow DOM 中的对应插槽中。最后,正是我们想要的 ------ 一个能被填充数据的通用组件。
- 默认插槽: shadow DOM 中第一个没有名字的 是一个默认插槽。它从 light DOM 中获取没有放置在其他位置的所有节点。
- 插槽后备内容: 如果我们在一个 内部放点什么,它将成为后备内容。如果 light DOM 中没有相应填充物的话浏览器就展示它。
html
<!doctype html>
<body>
<script>
customElements.define('user-card', class extends HTMLElement {
connectedCallback() {
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = `
<div>Name:
<!-- 具名插槽 -->
<slot name="username">
<!-- 插槽后备内容 -->
Anonymous
</slot>
</div>
<div>Birthday:
<slot name="birthday"></slot>
</div>
<!-- 默认插槽 -->
<slot></slot>
`;
}
});
</script>
<user-card>
<div>default slot</div>
<!-- 注释下面一行的插槽内容,查看插槽的后备内容 -->
<span slot="username">John Smith</span>
<span slot="birthday">01.01.2001</span>
</user-card>
</body>
更新插槽
**如果 添加/删除了插槽元素,浏览器将监视插槽并更新渲染。**如果组件想知道插槽的更改,那么可以用 slotchange 事件。
html
<!doctype html>
<body>
<custom-menu id="menu">
<!-- 在初始化时:slotchange: title 立即触发, 因为来自 light DOM 的 slot="title" 进入了相应的插槽。 -->
<span slot="title">Candy menu</span>
</custom-menu>
<script>
customElements.define('custom-menu', class extends HTMLElement {
connectedCallback() {
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = `<div class="menu">
<slot name="title"></slot>
<ul><slot name="item"></slot></ul>
</div>`;
// shadowRoot 不能有事件,所以使用 first child
// 如果组件想知道插槽的更改,那么可以用 slotchange 事件
this.shadowRoot.firstElementChild.addEventListener('slotchange',
e => alert("slotchange: " + e.target.name)
);
}
});
const menu = document.querySelector('custom-menu');
setTimeout(() => {
// 1 秒后:slotchange: item 触发, 当一个新的 <li slot="item"> 被添加。
menu.insertAdjacentHTML('beforeEnd', '<li slot="item">Lollipop</li>')
}, 1000);
setTimeout(() => {
menu.querySelector('[slot="tissssssssssssssssssstle"]').innerHTML = "New menu";
}, 2000);
</script>
</body>
插槽APIs
如果 shadow 树有 {mode: 'open'} ,那么我们可以找出哪个元素被放进一个插槽,反之亦然,哪个插槽分配了给这个元素:
- node.assignedSlot: 属性是在使用 Shadow DOM 的情况下,用于获取包含当前节点的 Shadow DOM 插槽(Slot)的属性。在 Shadow DOM 中,插槽是一种机制,用于将子元素分配到 Shadow DOM 中的特定位置。每个插槽都有一个对应的 HTMLSlotElement 对象。 当你有多个插槽时,assignedSlot 属性可以帮助你确定当前节点属于哪个插槽。
- slot.assignedNodes({flatten: true/false}): 返回分配给插槽的 DOM 节点。默认情况下,flatten 选项为 false。如果显式地设置为 true,则它将更深入地查看扁平化 DOM ,如果嵌套了组件,则返回嵌套的插槽,如果未分配节点,则返回备用内容。
- slot.assignedElements({flatten: true/false}): -- 返回分配给插槽的 DOM 元素。
html
<!doctype html>
<body>
<custom-menu id="menu">
<span slot="title">Candy menu</span>
<li slot="item">Lollipop</li>
<li slot="item">Fruit Toast</li>
</custom-menu>
<script>
customElements.define('custom-menu', class extends HTMLElement {
items = []
connectedCallback() {
// 需要shadow 树有 {mode: 'open'}
/*
node.assignedSlot 属性是在使用 Shadow DOM 的情况下,用于获取包含当前节点的 Shadow DOM 插槽(Slot)的属性。
这个属性返回一个表示节点所属插槽的 HTMLSlotElement 对象。
在 Shadow DOM 中,插槽是一种机制,用于将子元素分配到 Shadow DOM 中的特定位置。
每个插槽都有一个对应的 HTMLSlotElement 对象。
当你有多个插槽时,assignedSlot 属性可以帮助你确定当前节点属于哪个插槽。
*/
/*
slot.assignedNodes({flatten: true/false}) -- 返回分配给插槽的 DOM 节点。
默认情况下,flatten 选项为 false。如果显式地设置为 true,则它将更深入地查看扁平化 DOM ,如果嵌套了组件,则返回嵌套的插槽,
如果未分配节点,则返回备用内容。
*/
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = `<div class="menu">
<slot name="title"></slot>
<ul><slot name="item"></slot></ul>
</div>`;
// 插槽能被添加/删除/代替
this.shadowRoot.firstElementChild.addEventListener('slotchange', e => {
let slot = e.target;
const assignedNodes = slot.assignedNodes();
assignedNodes.forEach(node => {
console.log(node.textContent); // dom 的 文本内容
console.log('Assigned to slot:', node.assignedSlot); // HTMLSlotElement 对象
});
if (slot.name == 'item') {
// slot.assignedElements({flatten: true/false}) -- 返回分配给插槽的 DOM 元素
this.items = slot.assignedElements().map(elem => elem.textContent);
alert("Items: " + this.items);
}
});
}
});
const menu = document.getElementById('menu');
// items 在 1 秒后更新
setTimeout(() => {
menu.insertAdjacentHTML('beforeEnd', '<li slot="item">Cup Cake</li>')
}, 1000);
</script>
</body>