Web Components(Shadow DOM/自定义元素)入门

Web Components简介

作为开发者,我们都知道尽可能多的重用代码是一个好主意。这对于自定义标记结构来说通常不是那么容易 — 想想复杂的HTML(以及相关的样式和脚本),有时您不得不写代码来呈现自定义UI控件,并且如果您不小心的话,多次使用它们会使您的页面变得一团糟。

Web Components旨在解决这些问题 — 它由三项主要技术组成,它们可以一起使用来创建封装功能的定制元素,可以在你喜欢的任何地方重用,不必担心代码冲突。

  • Custom elements(自定义元素):

    一组JavaScript API,允许您定义custom elements及其行为,然后可以在您的用户界面中按照需要使用它们。

  • Shadow DOM(影子DOM):

    一组JavaScript API,用于将封装的“影子”DOM树附加到元素(与主文档DOM分开呈现)并控制其关联的功能。通过这种方式,您可以保持元素的功能私有,这样它们就可以被脚本化和样式化,而不用担心与文档的其他部分发生冲突。

  • HTML templates(HTML模板):

    <template><slot> 元素使您可以编写不在呈现页面中显示的标记模板。然后它们可以作为自定义元素结构的基础被多次重用。

影子DOM

Firefox(从版本 63 开始),Chrome,Opera 和 Safari 默认支持 Shadow DOM。基于 Chromium 的新 Edge 也支持 Shadow DOM;而旧 Edge 未能撑到支持此特性。

shadow DOM有以下特点:

  • shadow DOM与外部html是隔离的,外面用外面的样式,里面用里面的样式。
  • 通过DOM API查找DOM元素时,document只能查到外部的元素,shadow DOM内部元素需要通过shadow root元素来查找。

总结起来就是,shadow DOM可以把一部分html代码隔离起来,与外部完全不会互相干扰。

  • 跟iframe不同的地方在于,我们可以调用外部的方法,这样就不会出现弹窗局限于内部窗口的尴尬情况。

示例1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
<html>
<head>
<meta charset="utf-8" />
<title>shadow DOM</title>
</head>
<body>
<style>
body {
background-color: #f3f3f3;
}
.text {
color: red;
}
</style>
<div id="div1"><p class="text">这是外面页面的text类文字</p></div>

<div id="div2">
<p class="text">这是原本就在html上的dom元素,3秒后添加到shadow-host里</p>
</div>
<div id="shadow-host">
<p>这是shadow-host下的,与shadow-root平级的兄弟元素,将不会显示</p>
</div>

<script>
function addShadow() {
const shadowHost = document.querySelector("#shadow-host");
// 通过attachShadow创建一个shadow Root
const shadow = shadowHost.attachShadow({ mode: "open" });

const shadowDiv = document.createElement("div");
shadowDiv.setAttribute("class", "text");
shadowDiv.innerText = "shadow DOM内部的text类文字";

// 为shadow dom创建一个style标签,一开始这个style.isConnected为false,把他添加给shadow Root后 isConnected就为true了
const style = document.createElement("style");
console.log(style.isConnected);
style.textContent = `
.text {
color: green
}
`;
// 为shadow dom添加元素
shadow.appendChild(style);
console.log(style.isConnected);

shadow.appendChild(shadowDiv);

console.log(document.querySelectorAll(".text"));
console.log(shadow.querySelectorAll(".text"));

setTimeout(() => {
shadow.appendChild(document.querySelector("#div2"));
}, 3000);
}

setTimeout(() => {
addShadow();
}, 2000);
</script>
</body>
</html>

使用外部引用样式

1
2
3
4
5
6
// 将外部引用的样式添加到 Shadow DOM 上
const linkElem = document.createElement("link");
linkElem.setAttribute("rel", "stylesheet");
linkElem.setAttribute("href", "shadow.css");
// 将所创建的元素添加到 Shadow DOM 上
shadow.appendChild(linkElem);

注意

通过attachShadow创建一个shadow Root,那么shadow Root同级的元素依旧存在但是不会显示。

示例2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<html>
<head>
<meta charset="utf-8" />
<title>shadow DOM</title>
</head>
<body>
<div id="div1"><p class="text">这是外面页面的text类文字</p></div>

<div id="div2">
<p class="text">这是原本就在html上的dom元素,3秒后添加到shadow-host里</p>
<style>
.text {
color: red;
}
</style>
</div>
<div id="shadow-host"></div>

<script>
function addShadow() {
const shadowHost = document.querySelector("#shadow-host");
// 通过attachShadow创建一个shadow Root
const shadow = shadowHost.attachShadow({ mode: "open" });

const shadowDiv = document.createElement("div");
shadowDiv.setAttribute("class", "text");
shadowDiv.innerText = "shadow DOM内部的text类文字";

shadow.appendChild(shadowDiv);

console.log(document.querySelectorAll(".text"));
console.log(shadow.querySelectorAll(".text"));

setTimeout(() => {
shadow.appendChild(document.querySelector("#div2"));
}, 3000);
}

addShadow();
</script>
</body>
</html>

这样我们会发现

我们把div2中的元素添加到shadow root里,里面的.text样式也被添加了进去,并且外面的元素也不再受.text样式的影响

mode

可以使用 Element.attachShadow() 方法来将一个 shadow root 附加到任何一个元素上。它接受一个配置对象作为参数,该对象有一个 mode 属性,值可以是 open 或者 closed

1
2
let shadow = elementRef.attachShadow({mode: 'open'});
let shadow = elementRef.attachShadow({mode: 'closed'});

open 表示可以通过页面内的 JavaScript 方法来获取 Shadow DOM,例如使用 Element.shadowRoot 属性:

1
let myShadowDom = myCustomElem.shadowRoot;

如果你将一个 Shadow root 附加到一个 Custom element 上,并且将 mode 设置为 closed,那么就不可以从外部获取 Shadow DOM 了——myCustomElem.shadowRoot 将会返回 null。浏览器中的某些内置元素就是如此,例如<video>,包含了不可访问的 Shadow DOM。

自定义元素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title></title>
</head>
<body>
<z-div>123456</z-div>
<script>
class ZDiv extends HTMLElement {
constructor() {
// 必须首先调用 super 方法
super();
console.info(this.innerHTML);
}
}
customElements.define("z-div", ZDiv);
</script>
</body>
</html>

CustomElementRegistry.define() 方法用来注册一个 custom element,该方法接受以下参数:

  • 表示所创建的元素名称的符合 DOMString 标准的字符串。注意,custom element 的名称不能是单个单词,且其中必须要有短横线
  • 用于定义元素行为的
  • 可选参数,一个包含 extends 属性的配置对象,是可选参数。它指定了所创建的元素继承自哪个内置元素,可以继承任何内置元素。

或者这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title></title>
</head>
<body>
<p is="z-div">123</p>
<script>
class ZDiv extends HTMLParagraphElement {
constructor() {
// 必须首先调用 super 方法
super();
console.info(this);
}
}
customElements.define("z-div", ZDiv, { extends: "p" });
</script>
</body>
</html>

这种方式构造方法必须和继承的元素一致。

自定义元素结合影子DOM

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title></title>
</head>
<body>
<z-div text="123"></z-div>
<script defer>
class ZDiv extends HTMLElement {
constructor() {
// 必须首先调用 super 方法
super();
const shadow = this.attachShadow({ mode: "open" });

const shadowDiv = document.createElement("div");
shadowDiv.innerText = this.getAttribute("text");
shadow.appendChild(shadowDiv);
console.info(shadow);
}
}
customElements.define("z-div", ZDiv);
</script>
</body>
</html>

消息传递

不论是iframe或者是sahdow dom数据消息交互我们就都可以用postMessage。

但是iframe内外的window对象是不一样的,但是sahdow dom内外是一样的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title></title>
</head>
<body>
<z-div text="我是原信息"></z-div>
<button onclick="sendMsg()">发送事件</button>
<script defer>
class ZDiv extends HTMLElement {
constructor() {
// 必须首先调用 super 方法
super();
const shadow = this.attachShadow({ mode: "open" });

const shadowDiv = document.createElement("div");
shadowDiv.innerText = this.getAttribute("text");
shadow.appendChild(shadowDiv);

window.addEventListener(
"message",
function (event) {
shadowDiv.innerText =
event.origin + " 发来信息:\n " + JSON.stringify(event.data);
},
false
);
}
}
customElements.define("z-div", ZDiv);

function sendMsg() {
window.postMessage({
time: new Date().toLocaleString()
});
}
</script>
</body>
</html>

发送

1
otherWindow.postMessage(message, targetOrigin, [transfer]);
  • otherWindow

    其他窗口的一个引用,比如iframe的contentWindow属性、执行window.open返回的窗口对象、或者是命名过或数值索引的window.frames

  • message

    将要发送到其他 window的数据。它将会被结构化克隆算法序列化。这意味着你可以不受什么限制的将数据对象安全的传送给目标窗口而无需自己序列化。

  • targetOrigin

    通过窗口的origin属性来指定哪些窗口能接收到消息事件,其值可以是字符串”“(表示无限制)或者一个URI。在发送消息的时候,如果目标窗口的协议、主机地址或端口这三者的任意一项不匹配targetOrigin提供的值,那么消息就不会被发送;只有三者完全匹配,消息才会被发送。这个机制用来控制消息可以发送到哪些窗口;例如,当用postMessage传送密码时,这个参数就显得尤为重要,必须保证它的值与这条包含密码的信息的预期接受者的origin属性完全一致,来防止密码被恶意的第三方截获。**如果你明确的知道消息应该发送到哪个窗口,那么请始终提供一个有确切值的targetOrigin,而不是\。不提供确切的目标将导致数据泄露到任何对数据感兴趣的恶意站点。**

  • transfer 可选

    是一串和message 同时传递的 Transferable 对象. 这些对象的所有权将被转移给消息的接收方,而发送一方将不再保有所有权。

接收

接收时的message 的属性有:

  • data

    从其他 window 中传递过来的对象。

  • origin

    调用 postMessage 时消息发送方窗口的 origin . 这个字符串由 协议、“://“、域名、“ : 端口号”拼接而成。例如 “https://example.org (隐含端口 443)”、“http://example.net (隐含端口 80)”、“http://example.com:8080”。请注意,这个origin不能保证是该窗口的当前或未来origin,因为postMessage被调用后可能被导航到不同的位置。

  • source

    对发送消息的窗口对象的引用; 您可以使用此来在具有不同origin的两个窗口之间建立双向通信。

安全问题

如果您不希望从其他网站接收message,请不要为message事件添加任何事件侦听器。 这是一个完全万无一失的方式来避免安全问题。

如果您确实希望从其他网站接收message,请始终使用origin和source属性验证发件人的身份。 任何窗口都可以向任何其他窗口发送消息,并且您不能保证未知发件人不会发送恶意消息。 但是,验证身份后,您仍然应该始终验证接收到的消息的语法。 否则,您信任只发送受信任邮件的网站中的安全漏洞可能会在您的网站中打开跨网站脚本漏洞。

当您使用postMessage将数据发送到其他窗口时,始终指定精确的目标origin,而不是* 恶意网站可以在您不知情的情况下更改窗口的位置,因此它可以拦截使用postMessage发送的数据。