Web Components:使用Shadow DOM
Web Components不仅仅是自定义元素。Shadow DOM、HTML模板和自定义元素各自扮演着重要角色。本文中,Russell Beswick展示了Shadow DOM在整体架构中的位置,解释了其重要性、适用场景及有效应用方法。
常见做法是将Web Components直接与框架组件进行比较。但大多数示例实际上特定于自定义元素,这只是Web Components的一部分。人们容易忘记Web Components实际上是一组可独立使用的Web平台API:
- 自定义元素
- HTML模板
- Shadow DOM
换句话说,可以不使用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智能小助手)
公众号二维码