Web Components:使用 Shadow DOM
Web Components 不仅仅是自定义元素。Shadow DOM、HTML 模板和自定义元素各自扮演着重要角色。本文中,Russell Beswick 展示了 Shadow DOM 在整体架构中的位置,解释了其重要性、适用场景及有效应用方法。
常见做法是将 Web Components 直接与框架组件进行比较。但大多数示例实际上特定于自定义元素,这只是 Web Components 的一部分。人们容易忘记 Web Components 实际上是一组可独立使用的 Web 平台 API:
换句话说,可以不使用 Shadow DOM 或 HTML 模板创建自定义元素,但结合这些功能可以增强稳定性、可重用性、可维护性和安全性。它们是同一功能集的组成部分,可以单独或一起使用。
为什么存在 Shadow DOM
大多数现代 Web 应用由来自不同提供商的库和组件组成。在传统(或“轻量”)DOM 中,样式和脚本很容易相互泄漏或冲突。如果使用框架,可能相信所有内容都已编写为无缝协作,但仍需努力确保所有元素具有唯一 ID,并且 CSS 规则尽可能具体地限定范围。这可能导致代码过于冗长,既增加应用加载时间,又降低可维护性。
<!-- div soup -->
<div id="my-custom-app-framework-landingpage-header" class="my-custom-app-framework-foo">
<div><div><div><div><div><div>etc...</div></div></div></div></div></div>
</div>
复制代码
Shadow DOM 通过提供隔离每个组件的方法来解决这些问题。<video>
和<details>
元素是默认使用 Shadow DOM 防止全局样式或脚本干扰的本机 HTML 元素的好例子。利用驱动本机浏览器组件的这种隐藏能力,是 Web Components 与框架对应物的真正区别。
可托管 Shadow Root 的元素
最常见的是,影子根与自定义元素关联。但它们也可以与任何 HTMLUnknownElement 一起使用,并且许多标准元素也支持它们,包括:
<aside>
<blockquote>
<body>
<div>
<footer>
<h1>
到 <h6>
<header>
<main>
<nav>
<p>
<section>
<span>
每个元素只能有一个影子根。一些元素,包括<input>
和<select>
,已经有一个内置的影子根,无法通过脚本访问。可以通过在开发者工具中启用“显示用户代理 Shadow DOM”设置来检查它们,该设置默认“关闭”。
创建 Shadow Root
在利用 Shadow DOM 的好处之前,首先需要在元素上建立影子根。这可以通过命令式或声明式实例化。
命令式实例化
要使用 JavaScript 创建影子根,请在元素上使用attachShadow({ mode })
。模式可以是open
(允许通过element.shadowRoot
访问)或closed
(对外部脚本隐藏影子根)。
const host = document.createElement('div');
const shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = '<p>Hello from the Shadow DOM!</p>';
document.body.appendChild(host);
复制代码
在此示例中,我们建立了一个开放的影子根。这意味着元素的内容可以从外部访问,我们可以像查询任何其他 DOM 节点一样查询它:
host.shadowRoot.querySelector('p'); // 选择段落元素
复制代码
如果我们想完全防止外部脚本访问我们的内部结构,可以将模式设置为closed
。这导致元素的shadowRoot
属性返回null
。我们仍然可以从创建它的作用域中的影子引用访问它。
shadow.querySelector('p');
复制代码
这是一个关键的安全功能。使用封闭的影子根,我们可以确信恶意行为者无法从我们的组件中提取私人用户数据。例如,考虑一个显示银行信息的小部件。可能包含用户的帐号。使用开放的影子根,页面上的任何脚本都可以深入我们的组件并解析其内容。在封闭模式下,只有用户可以通过手动复制粘贴或检查元素来执行此类操作。
建议在使用 Shadow DOM 时采用封闭优先的方法。养成使用封闭模式的习惯,除非正在调试,或仅在无法避免实际限制时绝对必要。如果遵循此方法,会发现实际上需要开放模式的情况很少。
声明式实例化
我们不必使用 JavaScript 来利用 Shadow DOM。可以声明式注册影子根。在任何受支持的元素内嵌套带有shadowrootmode
属性的<template>
将导致浏览器自动升级该元素带有影子根。以这种方式附加影子根甚至可以在禁用 JavaScript 的情况下完成。
<my-widget>
<template shadowrootmode="closed">
<p> Declarative Shadow DOM content </p>
</template>
</my-widget>
复制代码
同样,这可以是开放的或封闭的。在使用开放模式之前考虑安全影响,但请注意,除非此方法与注册的自定义元素一起使用,否则无法通过任何脚本访问封闭模式内容,在这种情况下,可以使用ElementInternals
访问自动附加的影子根:
class MyWidget extends HTMLElement {
#internals;
#shadowRoot;
constructor() {
super();
this.#internals = this.attachInternals();
this.#shadowRoot = this.#internals.shadowRoot;
}
connectedCallback() {
const p = this.#shadowRoot.querySelector('p')
console.log(p.textContent); // 这有效
}
};
customElements.define('my-widget', MyWidget);
export { MyWidget };
复制代码
Shadow DOM 配置
除了模式之外,我们还可以向Element.attachShadow()
传递三个其他选项。
选项 1:clonable:true
直到最近,如果标准元素附加了影子根,并尝试使用Node.cloneNode(true)
或document.importNode(node,true)
克隆它,只会得到宿主元素的浅拷贝,而没有影子根内容。我们刚看的示例实际上会返回一个空的<div>
。这对于在内部构建自己的影子根的自定义元素从来不是问题。
但对于声明式 Shadow DOM,这意味着每个元素都需要自己的模板,并且它们不能被重用。通过这个新添加的功能,可以在需要时有选择地克隆组件:
<div id="original">
<template shadowrootmode="closed" shadowrootclonable>
<p> This is a test </p>
</template>
</div>
<script>
const original = document.getElementById('original');
const copy = original.cloneNode(true); copy.id = 'copy';
document.body.append(copy); // 包括影子根内容
</script>
复制代码
选项 2:serializable:true
启用此选项允许保存元素影子根内内容的字符串表示。在宿主元素上调用Element.getHTML()
将返回 Shadow DOM 当前状态的模板副本,包括所有嵌套的shadowrootserializable
实例。这可用于将影子根的副本注入另一个宿主,或缓存以供以后使用。
在 Chrome 中,这实际上通过封闭的影子根工作,因此要小心意外泄漏用户数据。更安全的替代方法是使用封闭包装器屏蔽内部内容免受外部影响,同时在内部保持开放:
<wrapper-element></wrapper-element>
<script>
class WrapperElement extends HTMLElement {
#shadow;
constructor() {
super();
this.#shadow = this.attachShadow({ mode:'closed' });
this.#shadow.setHTMLUnsafe(`
<nested-element>
<template shadowrootmode="open" shadowrootserializable>
<div id="test">
<template shadowrootmode="open" shadowrootserializable>
<p> Deep Shadow DOM Content </p>
</template>
</div>
</template>
</nested-element>
`);
this.cloneContent();
}
cloneContent() {
const nested = this.#shadow.querySelector('nested-element');
const snapshot = nested.getHTML({ serializableShadowRoots: true });
const temp = document.createElement('div');
temp.setHTMLUnsafe(`<another-element>${snapshot}</another-element>`);
const copy = temp.querySelector('another-element');
copy.shadowRoot.querySelector('#test').shadowRoot.querySelector('p').textContent = 'Changed Content!';
this.#shadow.append(copy);
}
}
customElements.define('wrapper-element', WrapperElement);
const wrapper = document.querySelector('wrapper-element');
const test = wrapper.getHTML({ serializableShadowRoots: true });
console.log(test); // 由于封闭的影子根,空字符串
</script>
复制代码
注意setHTMLUnsafe()
。这是因为内容包含<template>
元素。注入这种性质的可信内容时必须调用此方法。使用innerHTML
插入模板不会触发自动初始化为影子根。
选项 3:delegatesFocus:true
此选项本质上使我们的宿主元素充当其内部内容的<label>
。启用后,单击宿主上的任何位置或对其调用.focus()
将把光标移动到影子根中的第一个可聚焦元素。这还将:focus
伪类应用于宿主,这在创建旨在参与表单的组件时特别有用。
<custom-input>
<template shadowrootmode="closed" shadowrootdelegatesfocus>
<fieldset>
<legend> Custom Input </legend>
<p> Click anywhere on this element to focus the input </p>
<input type="text" placeholder="Enter some text...">
</fieldset>
</template>
</custom-input>
复制代码
此示例仅演示焦点委托。封装的一个奇怪之处是表单提交不会自动连接。这意味着默认情况下,输入的值不会在表单提交中。表单验证和状态也不会从 Shadow DOM 中传达出来。可访问性存在类似的连接问题,影子根边界可能会干扰 ARIA。这些都是特定于表单的考虑因素,我们可以用ElementInternals
解决,这是另一篇文章的主题,并且有理由质疑是否可以依赖轻量 DOM 表单。
插槽内容
到目前为止,我们只看了完全封装的组件。一个关键的 Shadow DOM 功能是使用插槽选择性地将内容注入组件的内部结构。每个影子根可以有一个默认(未命名)<slot>
;所有其他必须命名。命名插槽允许我们提供内容以填充组件的特定部分,以及回退内容以填充用户省略的任何插槽:
<my-widget>
<template shadowrootmode="closed">
<h2><slot name="title"><span>Fallback Title</span></slot></h2>
<slot name="description"><p>A placeholder description.</p></slot>
<ol><slot></slot></ol>
</template>
<span slot="title"> A Slotted Title</span>
<p slot="description">An example of using slots to fill parts of a component.</p>
<li>Foo</li>
<li>Bar</li>
<li>Baz</li>
</my-widget>
复制代码
默认插槽也支持回退内容,但任何杂散文本节点都会填充它们。因此,这仅在折叠宿主元素标记中的所有空白时才有效:
<my-widget><template shadowrootmode="closed">
<slot><span>Fallback Content</span></slot>
</template></my-widget>
复制代码
当添加或删除assignedNodes()
时,插槽元素发出slotchange
事件。这些事件不包含对插槽或节点的引用,因此需要将它们传递到事件处理程序中:
class SlottedWidget extends HTMLElement {
#internals;
#shadow;
constructor() {
super();
this.#internals = this.attachInternals();
this.#shadow = this.#internals.shadowRoot;
this.configureSlots();
}
configureSlots() {
const slots = this.#shadow.querySelectorAll('slot');
console.log({ slots });
slots.forEach(slot => {
slot.addEventListener('slotchange', () => {
console.log({
changedSlot: slot.name || 'default',
assignedNodes: slot.assignedNodes()
});
});
});
}
}
customElements.define('slotted-widget', SlottedWidget);
复制代码
多个元素可以分配给单个插槽,可以通过slot
属性声明式或通过脚本:
const widget = document.querySelector('slotted-widget');
const added = document.createElement('p');
added.textContent = 'A secondary paragraph added using a named slot.';
added.slot = 'description';
widget.append(added);
复制代码
注意此示例中的段落附加到宿主元素。插槽内容实际上属于“轻量”DOM,而不是 Shadow DOM。与到目前为止涵盖的示例不同,这些元素可以直接从文档对象查询:
const widgetTitle = document.querySelector('my-widget [slot=title]');
widgetTitle.textContent = 'A Different Title';
复制代码
如果想从类定义内部访问这些元素,请使用this.children
或this.querySelector
。只有<slot>
元素本身可以通过 Shadow DOM 查询,而不是它们的内容。
从神秘到掌握
现在知道为什么要使用 Shadow DOM,何时应将其纳入工作,以及如何立即使用它。
但 Web Components 之旅不能在这里结束。本文仅涵盖了标记和脚本。我们甚至没有触及 Web Components 的另一个主要方面:样式封装。这将是另一篇文章的主题。更多精彩内容 请关注我的个人公众号 公众号(办公 AI 智能小助手)公众号二维码
办公AI智能小助手
评论