Web组件API
最近学完 Vue 后发现它封装的 DOM 实在是太强大了。使用 Es6 Moudule 或 CommonJs Module 实现封装 Js 模块化比较简单。但是想要封装 DOM 真没那么简单。看了红宝书 JavaScript API 中的 Web组件 API,发现里面的内容跟 Vue 用到的方法很像,所以我就先看一下做一些基础知识储备,等哪天有能力了再模仿Vue自己封装一个 DOM,(感觉有点天真,哈哈哈 ~~)
知识库
- template模板
- 影子 DOM
- 自定义标签
一、template 模板
在 Web 组件之前,一直缺少基于 HTML 解析构建 DOM 子树,然后在需要时再把这个子树渲染出 来的机制。一种间接方案是使用 innerHTML 把标记字符串转换为 DOM 元素,但这种方式存在严重的 安全隐患。另一种间接方案是使用 document.createElement()构建每个元素,然后逐个把它们添加 到孤儿根节点(不是添加到 DOM),但这样做特别麻烦,完全与标记无关。 相反,更好的方式是提前在页面中写出特殊标记,让浏览器自动将其解析为 DOM 子树,但跳过渲染,这正是 HTML 模板的核心思想,而 teamplate 标签正式为这个目的而生的。
特点
template 里面的内容会被存放在document-fragment 且不会被渲染出来
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div class="context">
</div>
<template id="template">
<div class='user'>
<div class="name">小明</div>
<div class="info">爱学习</div>
</div>
</template>
<script>
const user = document.getElementById("template").content;
console.log(user);
</script>
</body>
</html>
让 template 里面所有节点转移在 dom 节点上
-
剪切方式(转移后 template 所有节点清空) <script>
const userFragment = document.getElementById("template").content;
document.getElementById("context").appendChild(userFragment);
</script>
- 复制方式 (转移后 template 所有节点还在)
const userFragment = document.getElementById("template").content;
document.getElementById('context').append(document.importNode(userFragment, true))
二、影子DOM
影子 DOM (shadow DOM),通过它可以将一个完整的DOM树作为节点添加到父DOM树。这样可以实现DOM封装,意味着 CSS 样式和 CSS 选择父可以限制在 影子DOM 子树而不是整个顶级 DOM 树中。
使用影子 DOM
向 DOM 父节点插入了三个独立的 DOM 子节点
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<script>
for (let color of ['red', 'green', 'blue']) {
const div = document.createElement('div');
document.body.appendChild(div);
const shadowDOM = div.attachShadow({ mode: 'open' });
shadowDOM.innerHTML = `
<p>Make me ${color}</p>
<style>
p {
color: ${color};
}
</style>
`;
}
</script>
</body>
</html>
影子 DOM 槽位
默认槽位
<slot> 标签指示浏览器在哪里放置原来的HTML
为一个DOM节点添加影子DOM后,由于影子DOM的显示优先级高于宿主DOM的内容,宿主DOM的内容就不会被显示,而使用DOM槽位可以映射DOM宿主的内容到影子DOM槽位里面
document.body.innerHTML = `
<div id="foo">
<p>Foo</p>
</div>
`;
document.querySelector('div')
.attachShadow({ mode: 'open' })
.innerHTML = `<div id="bar">
<slot></slot>
<div>`
使用槽位改写红绿蓝三色树
for (let color of ['red', 'green', 'blue']) {
const divElement = document.createElement('div');
divElement.innerText = `Make me ${color}`;
document.body.appendChild(divElement)
divElement
.attachShadow({ mode: 'open' })
.innerHTML = `
<p><slot></slot></p>
<style>
p {
color: ${color};
}
</style>
`;
}
命名槽位(named slot)实现多个投射
通过匹配的 slot/name 属性对实现的。带有 slot="foo"属性的元素会被投射到带有 name="foo"的上。
document.body.innerHTML = `
<div>
<p slot="foo">Foo</p>
<p slot="bar">Bar</p>
</div>
`;
document.querySelector('div')
.attachShadow({ mode: 'open' })
.innerHTML = `
<slot name="bar"></slot>
<slot name="foo"></slot>
`;
事件重定向
如果影子 DOM 中发生了浏览器事件(如 click),那么浏览器需要一种方式以让父 DOM 处理事件。 不过,实现也必须考虑影子 DOM 的边界。为此,事件会逃出影子 DOM 并经过事件重定向(event retarget) 在外部被处理,效果有点像是事件冒泡。
document.body.innerHTML = `
<div οnclick="console.log('Handled outside:', event.target)">Bar</div>
`;
document.querySelector('div')
.attachShadow({ mode: 'open' })
.innerHTML = `
<slot></slot>
<button οnclick="console.log('Handled inside:', event.target)">Foo</button>
`;
点击影子DOM,先触发 影子DOM处理函数,再触发宿主事件
点击宿主DIV,只触发了宿主事件
三、自定义元素
自定义元素为 HTML 元素引入了面向对象编程的风格。基于这种风格,可以创 建自定义的、复杂的和可重用的元素,而且只要使用简单的 HTML 标签或属性就可以创建相应的实例。
创建自定义元素
自定义元素的威力源自类定义。例如,可以通过调用自定义元素的构造函数来控制这个类在 DOM 中每个实例的行为
class FooElement extends HTMLElement {
constructor() {
super();
console.log('x-foo')
}
}
customElements.define('x-foo', FooElement);
document.body.innerHTML = `
<x-foo>x-foo</x-foo>
<x-foo>x-foo</x-foo>
<x-foo>x-foo</x-foo>
`;
如果自定义元素继承了一个元素类,那么可以使用 is 属性和 extends 选项将标签指定为该自定义 元素的实例
class FooElement extends HTMLDivElement {
constructor() {
super();
console.log('x-foo')
}
}
customElements.define('x-foo', FooElement, { extends: 'div' });
document.body.innerHTML = `
<div is="x-foo">x-foo</div>
<div is="x-foo">x-foo</div>
<div is="x-foo">x-foo</div>
`;
添加 Web 组件内容
因为每次将自定义元素添加到 DOM 中都会调用其类构造函数,所以很容易自动给自定义元素添加 子 DOM 内容。虽然不能在构造函数中添加子 DOM(会抛出 DOMException),但可以为自定义元素添 加影子 DOM 并将内容添加到这个影子 DOM 中
class FooElement extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<p>I'm inside a custom element!</p>
`;
}
}
customElements.define('x-foo', FooElement);
document.body.innerHTML += `<x-foo></x-foo`;
使用 teamplate 优化
为避免字符串模板和 innerHTML 不干净,可以使用 HTML 模板和 document.createElement() 重构这个例子
const template = document.querySelector('#x-foo-tpl');
class FooElement extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.appendChild(template.content.cloneNode(true));
}
}
customElements.define('x-foo', FooElement);
document.body.innerHTML += `<x-foo></x-foo`;
自定义元素生命周期方法
constructor(): 在创建元素实例或将已有 DOM 元素升级为自定义元素时调用。
connectedCallback():在每次将这个自定义元素实例添加到 DOM 中时调用
disconnectedCallback():在每次将这个自定义元素实例从 DOM 中移除时调用
attributeChangedCallback():在每次可观察属性的值发生变化时调用。在元素实例初始化 时,初始值的定义也算一次变化。
adoptedCallback():在通过 document.adoptNode()将这个自定义元素实例移动到新文档 对象时调用。
class FooElement extends HTMLElement {
constructor() {
super();
console.log('ctor');
}
connectedCallback() {
console.log('connected');
}
disconnectedCallback() {
console.log('disconnected');
}
}
customElements.define('x-foo', FooElement);
const fooElement = document.createElement('x-foo');
document.body.appendChild(fooElement);
document.body.removeChild(fooElement);
反射自定义元素属性
自定义元素既是 DOM 实体又是 JavaScript 对象,因此两者之间应该同步变化。换句话说,对 DOM 的修改应该反映到 JavaScript 对象,反之亦然。要从 JavaScript 对象反射到 DOM,常见的方式是使用获 取函数和设置函数。下面的例子演示了在 JavaScript 对象和 DOM 之间反射 bar 属性的过程
document.body.innerHTML = `<x-foo></x-foo>`;
class FooElement extends HTMLElement {
constructor() {
super();
this.bar = true;
}
get bar() {
return this.getAttribute('bar');
}
set bar(value) {
this.setAttribute('bar', value)
}
}
customElements.define('x-foo', FooElement);
console.log(document.body.innerHTML);
另一个方向的反射(从 DOM 到 JavaScript 对象)需要给相应的属性添加监听器。为此,可以使用 observedAttributes()获取函数让自定义元素的属性值每次改变时都调用 attributeChangedCallback()
class FooElement extends HTMLElement {
static get observedAttributes() {
return ['bar'];
}
get bar() {
return this.getAttribute('bar');
}
set bar(value) {
this.setAttribute('bar', value)
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
console.log(`${oldValue} -> ${newValue}`);
this[name] = newValue;
}
}
}
customElements.define('x-foo', FooElement);
document.body.innerHTML = `<x-foo bar="false"></x-foo>`;
document.querySelector('x-foo').setAttribute('bar', true);
|