深入理解Shadow DOM v1[每日前端夜话0x63]
shadow DOM不是超级英雄电影中的恶棍,也不是DOM的黑暗面。 shadow DOM只是一种解决文档对象模型(或简称DOM)中缺少的树封装方法。
网页通常使用来自外部源的数据和小部件,如果它们没有封装,那么样式可能会影响HTML中不必要的部分,迫使开发人员使用特定的选择器和!important 规则来避免样式冲突。
尽管如此,在编写大型程序时,这些努力似乎并不是那么有效,并且大量的时间被浪费在防止CSS和JavaScript的冲突上。 Shadow DOM API旨在通过提供封装DOM树的机制来解决这些问题。
Shadow DOM是用于创建Web组件的主要技术之一,另外两个是自定义元素和HTML模板。 Web 组件的规范最初是由Google提出的,用于简化Web小部件的开发。
虽然这三种技术旨在协同工作,不过你可以自由地分别使用每种技术。本教程的范围仅限于shadow DOM。
什么是DOM?
在深入研究如何创建shadow DOM之前,了解DOM是什么非常重要。 W3C文档对象模型(DOM)提供了一个平台和语言无关的应用程序编程接口(API),用于表示和操作存储在HTML和XML文档中的信息。
通过使用DOM,程序员可以访问、添加、删除或更改元素和内容。 DOM将网页视为树结构,每个分支以节点结束,每个节点包含一个对象,可以使用JavaScript等脚本语言对其进行修改。请考虑以下HTML文档:
1<html>2 <head>3 <title>Sample document</title>4 </head>5 <body>6 <h1>Heading</h1>7 <a href="https://example.com">Link</a>8 </body>9</html>
此HTML的DOM表示如下:
此图中所有的框都是节点。
用于描述DOM部分的术语类似于现实世界中的家谱树:
给定节点上一级节点是该节点的父节点
给定节点下一级节点是该节点的子节点
具有相同父级的节点是兄弟节点
给定节点上方的所有节点(包括父节点和祖父节点)都称为该节点的祖先
- 最后,给定节点下所有的节点都被称为该节点的后代
节点的类型取决于它所代表的HTML元素的类型。 HTML标记被称为元素节点。嵌套标签形成一个元素树。元素中的文本称为文本节点。文本节点可能没有子节点,你可以把它想象成是一棵树的叶子。
为了访问树,DOM提供了一组方法,程序员可以用这些方法修改文档的内容和结构。例如当你写下document.createElement('p');时,就在使用DOM提供的方法。没有DOM,JavaScript就无法理解HTML和XML文档的结构。
下面的JavaScript代码显示了如何使用DOM方法创建两个HTML元素,将一个嵌套在另一个内部并设置文本内容,最后把它们附加到文档正文:
1const section = document.createElement('section');2const p = document.createElement('p');3p.textContent = 'Hello!';4section.appendChild(p);5document.body.appendChild(section);
这是运行这段JavaScript代码后生成的DOM结构:
1<body>2 <section>3 <p>Hello!</p>4 </section>5</body>
什么是 shadow DOM?
封装是面向对象编程的基本特性,它使程序员能够限制对某些对象组件的未授权访问。
在此定义下,对象以公共访问方法的形式提供接口作为与其数据交互的方式。这样对象的内部表示不能直接被对象的外部访问。
Shadow DOM将此概念引入HTML。它允许你将隐藏的,分离的DOM链接到元素,这意味着你可以使用HTML和CSS的本地范围。现在可以用更通用的CSS选择器而不必担心命名冲突,并且样式不再泄漏或被应用于不恰当的元素。
实际上,Shadow DOM API正是库和小部件开发人员将HTML结构、样式和行为与代码的其他部分分开所需的东西。
Shadow root 是 shadow 树中最顶层的节点,是在创建 shadow DOM 时被附加到常规DOM节点的内容。具有与之关联的shadow root的节点称为shadow host。
你可以像使用普通DOM一样将元素附加到shadow root。链接到shadow root的节点形成 shadow 树。通过图表应该能够表达的更清楚:
术语light DOM通常用于区分正常DOM和shadow DOM。shadow DOM和light DOM被并称为逻辑DOM。light DOM与shadow DOM分离的点被称为阴影边界。 DOM查询和CSS规则不能到达阴影边界的另一侧,从而创建封装。
创建一个shadow DOM
要创建shadow DOM,需要用Element.attachShadow()方法将shadow root附加到元素:
1var shadowroot = element.attachShadow(shadowRootInit);
来看一个简单的例子:
1<div id="host"><p>Default text</p></div> 2 3<script> 4 const elem = document.querySelector('#host'); 5 6 // attach a shadow root to #host 7 const shadowRoot = elem.attachShadow({mode: 'open'}); 8 9 // create a <p> element10 const p = document.createElement('p');1112 // add <p> to the shadow DOM13 shadowRoot.appendChild(p);1415 // add text to <p> 16 p.textContent = 'Hello!';17</script>
此代码将一个shadow DOM树附加到div元素,其id是host。这个树与div的实际子元素是分开的,添加到它之上的任何东西都将是托管元素的本地元素。
Chrome DevTools中的 Shadow root。
注意#host中的现有元素是如何被shadow root替换的。不支持shadow DOM的浏览器将使用默认内容。
现在,在将CSS添加到主文档时,样式规则不会影响shadow DOM:
1<div><p>Light DOM</p></div> 2<div id="host"></div> 3 4<script> 5 const elem = document.querySelector('#host'); 6 7 // attach a shadow root to #host 8 const shadowRoot = elem.attachShadow({mode: 'open'}); 910 // set the HTML contained within the shadow root11 shadowRoot.innerHTML = '<p>Shadow DOM</p>';12</script>1314<style>15 p {color: red}16</style>
在light DOM中定义的样式不能越过shadow边界。因此,只有light DOM中的段落才会变为红色。
相反,你添加到shadow DOM的CSS对于hosting元素来说是本地的,不会影响DOM中的其他元素:
1<div><p>Light DOM</p></div> 2<div id="host"></div> 3 4<script> 5 const elem = document.querySelector('#host'); 6 const shadowRoot = elem.attachShadow({mode: 'open'}); 7 shadowRoot.innerHTML = ` 8 <p>Shadow DOM</p> 9 <style>p {color: red}</style>`;1011</script>
你还可以将样式规则放在外部样式表中,如下所示:
1shadowRoot.innerHTML = `2 <p>Shadow DOM</p>3 <link rel="stylesheet" href="style.css">`;
要获取 shadowRoot 附加到的元素的引用,使用host属性:
1<div id="host"></div>23<script>4 const elem = document.querySelector('#host');5 const shadowRoot = elem.attachShadow({mode: 'open'});67 console.log(shadowRoot.host); // => <div id="host"></div>8</script>
要执行相反操作并获取对元素托管的shadow root的引用,可以用元素的shadowRoot属性:
1<div id="host"></div>23<script>4 const elem = document.querySelector('#host');5 const shadowRoot = elem.attachShadow({mode: 'open'});67 console.log(elem.shadowRoot); // => #shadow-root (open)8</script>
shadowRoot mod
当调用Element.attachShadow()方法来附加shadow root时,必须通过传递一个对象作为参数来指定shadow DOM树的封装模式,否则将会抛出一个TypeError。该对象必须具有mode属性,其值为 open 或 closed。
打开的shadow root允许你使用host元素的shadowRoot属性从root外部访问shadow root的元素,如下例所示:
1<div><p>Light DOM</p></div> 2<div id="host"></div> 3 4<script> 5 const elem = document.querySelector('#host'); 6 7 // attach an open shadow root to #host 8 const shadowRoot = elem.attachShadow({mode: 'open'}); 910 shadowRoot.innerHTML = `<p>Shadow DOM</p>`;11 // Nodes of an open shadow DOM are accessible12 // from outside the shadow root13 elem.shadowRoot.querySelector('p').innerText = 'Changed from outside the shadow root';14 elem.shadowRoot.querySelector('p').style.color = 'red';15</script>
但是如果mode属性的值为“closed”,则尝试从root外部用JavaScript访问shadow root的元素时会抛出一个TypeError:
1<div><p>Light DOM</p></div> 2<div id="host"></div> 3 4<script> 5 const elem = document.querySelector('#host'); 6 7 // attach a closed shadow root to #host 8 const shadowRoot = elem.attachShadow({mode: 'closed'}); 910 shadowRoot.innerHTML = `<p>Shadow DOM</p>`;1112 elem.shadowRoot.querySelector('p').innerText = 'Now nodes cannot be accessed from outside';13 // => TypeError: Cannot read property 'querySelector' of null 14</script>
当mode设置为closed时,shadowRoot属性返回null。因为null值没有任何属性或方法,所以在它上面调用querySelector()会导致TypeError。浏览器通常用关闭的 shadow roo 来使某些元素的实现内部不可访问,而且不可从JavaScript更改。
要确定shadow DOM是处于open还是closed模式,你可以参考shadow root的mode属性:
1<div id="host"></div>23<script>4 const elem = document.querySelector('#host');5 const shadowRoot = elem.attachShadow({mode: 'closed'});67 console.log(shadowRoot.mode); // => closed8</script>
从表面上看,对于不希望公开其组件的shadow root 的Web组件作者来说,封闭的shadow DOM看起来非常方便,然而在实践中绕过封闭的shadow DOM并不难。通常完全隐藏shadow DOM所需的工作量超过了它的价值。
并非所有HTML元素都可以托管shadow DOM
只有一组有限的元素可以托管shadow DOM。下表列出了支持的元素:
1+----------------+----------------+----------------+ 2| article | aside | blockquote | 3+----------------+----------------+----------------+ 4| body | div | footer | 5+----------------+----------------+----------------+ 6| h1 | h2 | h3 | 7+----------------+----------------+----------------+ 8| h4 | h5 | h6 | 9+----------------+----------------+----------------+10| header | main | nav |11+----------------+----------------+----------------+12| p | section | span |13+----------------+----------------+----------------+
尝试将shadow DOM树附加到其他元素将会导致“DOMException”错误。例如:
1document.createElement('img').attachShadow({mode: 'open'}); 2// => DOMException
用<img>元素作为shadow host是不合理的,因此这段代码抛出错误并不奇怪。你可能会收到DOMException错误的另一个原因是浏览器已经用该元素托管了shadow DOM。
浏览器自动将shadow DOM附加到某些元素
Shadow DOM已存在很长一段时间了,浏览器一直用它来隐藏元素的内部结构,比如<input>,<textarea>和<video>。
当你在HTML中使用<video>元素时,浏览器会自动将shadow DOM附加到包含默认浏览器控件的元素。但DOM中唯一可见的是<video>元素本身:
要在Chrome中显示此类元素的shadow root,请打开Chrome DevTools设置(按F1),然后在“elements”部分下方选中“Show user agent shadow DOM”:
选中“Show user agent shadow DOM”选项后,shadow root节点及其子节点将变为可见。以下是启用此选项后相同代码的显示方式:
在自定义元素上托管shadow DOM
Custom Elements API 创建的自定义元素可以像其他元素一样托管shadow DOM。请看以下示例:
1<my-element></my-element> 2<script> 3 class MyElement extends HTMLElement { 4 constructor() { 5 6 // must be called before the this keyword 7 super(); 8 9 // attach a shadow root to <my-element>10 const shadowRoot = this.attachShadow({mode: 'open'});1112 shadowRoot.innerHTML = `13 <style>p {color: red}</style>14 <p>Hello</p>`;15 }16 }1718 // register a custom element on the page19 customElements.define('my-element', MyElement);20</script>
此代码了创建一个托管shadow DOM的自定义元素。它调用了customElements.define()方法,元素名称作为第一个参数,类对象作为第二个参数。该类扩展了HTMLElement并定义了元素的行为。
在构造函数中,super()用于建立原型链,并且把Shadow root附加到自定义元素。当你在页面上使用<my-element>时,它会创建自己的shadow DOM:
请记住,有效的自定义元素不能是单个单词,并且名称中必须包含连字符( - )。例如,myelement不能用作自定义元素的名称,并会抛出 DOMException 错误。
样式化host元素
通常,要设置host元素的样式,你需要将CSS添加到light DOM,因为这是host元素所在的位置。但是如果你需要在shadow DOM中设置host元素的样式呢?
这就是host()伪类函数的用武之地。这个选择器允许你从shadow root中的任何地方访问shadow host。这是一个例子:
1<div id="host"></div> 2 3<script> 4 const elem = document.querySelector('#host'); 5 const shadowRoot = elem.attachShadow({mode: 'open'}); 6 7 shadowRoot.innerHTML = ` 8 <p>Shadow DOM</p> 9 <style>10 :host {11 display: inline-block;12 border: solid 3px #ccc;13 padding: 0 15px;14 }15 </style>`;16</script>
值得注意的是:host仅在shadow root中有效。还要记住,在shadow root之外定义的样式规则比:host中定义的规则具有更高的特殊性。
例如,#host { font-size: 16px; } 的优先级高于 shadow DOM的 :host { font-size: 20px; }。实际上这很有用,这允许你为组件定义默认样式,并让组件的用户覆盖你的样式。唯一的例外是!important规则,它在shadow DOM中具有特殊性。
你还可以将选择器作为参数传递给:host(),这允许你仅在host与指定选择器匹配时才会定位host。换句话说,它允许你定位同一host的不同状态:
1<style> 2 :host(:focus) { 3 /* style host only if it has received focus */ 4 } 5 6 :host(.blue) { 7 /* style host only if has a blue class */ 8 } 910 :host([disabled]) {11 /* style host only if it's disabled */12 }13</style>
基于上下文的样式
要选择特定祖先内部的shadow root host ,可以用:host-context()伪类函数。例如:
1:host-context(.main) {2 font-weight: bold;3}
只有当它是.main的后代时,此CSS代码才会选择shadow host :
1<body class="main">2 <div id="host">3 </div>4</body>
:host-context()对主题特别有用,因为它允许作者根据组件使用的上下文对组件进行样式设置。
样式钩子
shadow DOM的一个有趣地方是它能够创建“样式占位符”并允许用户填充它们。这可以通过使用CSS自定义属性来完成。我们来看一个简单的例子:
1<div id="host"></div> 2 3<style> 4 #host {--size: 20px;} 5</style> 6 7<script> 8 const elem = document.querySelector('#host'); 9 const shadowRoot = elem.attachShadow({mode: 'open'});1011 shadowRoot.innerHTML = `12 <p>Shadow DOM</p>13 <style>p {font-size: var(--size, 16px);}</style>`;1415</script>
这个shadow DOM允许用户覆盖其段落的字体大小。使用自定义属性表示法(— size: 20px)设置该值,并且shadow DOM用var()函数(font-size: var( — size, 16px))检索该值。在概念方面,这类似于<slot>元素的工作方式。
可继承的样式
shadow DOM允许你创建独立的DOM元素,而不会从外部看到选择器可见性,但这并不意味着继承的属性不会通过shadow边界。
某些属性(如color,background和font-family)会传递shadow边界并应用于shadow树。因此,与iframe相比,shadow DOM不是一个非常强大的障碍。
1<style> 2 div { 3 font-size: 25px; 4 text-transform: uppercase; 5 color: red; 6 } 7</style> 8 9<div><p>Light DOM</p></div>10<div id="host"></div>1112<script>13 const elem = document.querySelector('#host');14 const shadowRoot = elem.attachShadow({mode: 'open'});1516 shadowRoot.innerHTML = `<p>Shadow DOM</p>`;17</script>
解决方法很简单:通过声明all: initial将可继承样式重置为其初始值,如下所示:
1<style> 2 div { 3 font-size: 25px; 4 text-transform: uppercase; 5 color: red; 6 } 7</style> 8 9<div><p>Light DOM</p></div>10<div id="host"></div>1112<script>13 const elem = document.querySelector('#host');14 const shadowRoot = elem.attachShadow({mode: 'open'});1516 shadowRoot.innerHTML = `17 <p>Shadow DOM</p>18 <style>19 :host p {20 all: initial;21 }22 </style>`;23</script>
在此例中,元素被强制回到初始状态,因此穿过shadow边界的样式不起作用。
重新定位事件
在shadow DOM内触发的事件可以穿过shadow边界并冒泡到light DOM;但是,Event.target的值会自动更改,因此它看起来好像该事件源自其包含的shadow树而不是实际元素的host元素。
此更改称为事件重定向,其背后的原因是保留shadow DOM封装。请参考以下示例:
1<div id="host"></div> 2 3<script> 4 const elem = document.querySelector('#host'); 5 const shadowRoot = elem.attachShadow({mode: 'open'}); 6 7 shadowRoot.innerHTML = ` 8 <ul> 9 <li>One</li>10 <li>Two</li>11 <li>Three</li>12 <ul>13 `;1415 document.addEventListener('click', (event) => {16 console.log(event.target);17 }, false);18</script>
当你单击shadow DOM中的任何位置时,这段代码会将 <div id =“host”> ... </div> 记录到控制台,因此侦听器无法看到调度该事件的实际元素。
但是在shadow DOM中不会发生重定目标,你可以轻松找到与事件关联的实际元素:
1<div id="host"></div> 2 3<script> 4 const elem = document.querySelector('#host'); 5 const shadowRoot = elem.attachShadow({mode: 'open'}); 6 7 shadowRoot.innerHTML = ` 8 <ul> 9 <li>One</li>10 <li>Two</li>11 <li>Three</li>12 </ul>`;1314 shadowRoot.querySelector('ul').addEventListener('click', (event) => {15 console.log(event.target);16 }, false); 17</script>
请注意,并非所有事件都会从shadow DOM传播出去。那些做的是重新定位,但其他只是被忽略了。如果你使用自定义事件的话,则需要使用composed:true标志,否则事件不会从shadow边界冒出来。
Shadow DOM v0 与 v1
Shadow DOM规范的原始版本在 Chrome 25 中实现,当时称为Shadow DOM v0。该规范的新版本改进了Shadow DOM API的许多方面。
例如,一个元素不能再承载多个shadow DOM,而某些元素根本不能托管shadow DOM。违反这些规则会导致错误。
此外,Shadow DOM v1提供了一组新功能,例如打开 shadow 模式、后备内容等。你可以找到由规范作者之一编写的 v0 和 v1 之间的全面比较(https://hayato.io/2016/shadowdomv1/#multiple-shadow-roots)。可以在W3C找到Shadow DOM v1的完整描述。
浏览器对Shadow DOM v1的支持
在撰写本文时,Firefox和Chrome已经完全支持Shadow DOM v1。不幸的是,Edge尚未实现v1,Safari 只是部分支持。在 Can I use…(https://caniuse.com/#feat=shadowdomv1)上提供了支持的浏览器的最新列表。
要在不支持Shadow DOM v1的浏览器上实现shadow DOM,可以用shadydom和shadycss polyfills。
总结
DOM开发中缺乏封装一直是个问题。 Shadow DOM API为我们提供了划分DOM范围的能力,从而为这个问题提供了一个优雅的解决方案。
现在,样式冲突不再是一个令人担忧的问题,选择器也不会失控。 shadow DOM改变了小部件开发的游戏规则,能够创建从页面其余部分封装的小部件,并且不受其他样式表和脚本的影响,这是一个巨大的优势。
如前所述,Web 组件由三个主要技术组成,而shadow DOM是其中的关键部分。希望在阅读本文之后,你将更容易理解这三种技术是如何协同构建Web组件的。
原文:https://blog.logrocket.com/understanding-shadow-dom-v1-fa9b81ebe3ac
©著作权归作者所有:来自51CTO博客作者mb5ff980b461ced的原创作品,如需转载,请注明出处,否则将追究法律责任更多相关文章
- Web UI自动化测试之元素定位
- LeetCode #27 移除元素
- (美团)巧用数组下标,轻轻松松找出所有元素
- LeetCode 图解 | 237.删除链表中的节点
- 超详细!详解一道高频算法题:数组中的第 K 个最大元素
- 动画:面试必刷之二叉树搜索第 K 大节点
- 动画:面试必刷之二维数组中查找一个元素
- 前 K 个高频元素告诉你桶排序有啥用
- SSH AKS集群节点的几种方法(一)