编者按:本文作者:刘观宇,360 奇舞团高级前端工程师、技术经理,W3C CSS 工作组成员。

为什么会有Shadow DOM

你在实际的开发中很可能遇到过这样的需求:实现一个可以拖拽的滑块,以实现范围选择、音量控制等需求。

除了直接用组件库,聪明的你肯定已经想到了多种解决办法。如在数据驱动框架React/Vue/Angular下,你可能会找到或编写对应的组件,通过相应数据状态的变更,完成相对复杂的交互;如在小快灵的项目下,用jQuery的Widget也是一个不错的选择;再或者,你可以点开你的HTML+JavaScript+CSS技能树,纯手工打造一个。这都是不难完成的任务。

当然,在完成之后,你可能会考虑对组件做一些提炼,下次再遇到同样的需求,你就可以气定神闲地“开箱即用”。

这里1是Clair组件库对这个需求的封装。

我们不妨从这个层面再多想一步。其实由于HTML和CSS默认都是全局可见的,因此,尤其是纯手工打造的组件,其样式是很容易受到所在环境的干扰的;由于选择器在组件层没有统一的保护手段,也会造成撰写时候的规则可以被随意修改;事件的捕获和冒泡过程会和所在环境密切相关,也可能会引起事件管理的混乱。

根据一般意义上“封装”的概念,我们希望相对组件来讲,DOM和CSS有一定的隐藏性;如非必要,外部的变化对于内部的有一定的隔离;同时,外界可以通过且仅可以通过一些可控的方法来影响内部,反之亦然。

针对这些问题,其实浏览器提供了一种名叫Shadow DOM的解决方案。这个方案目前与 Custom Elements、HTML Templates、CSS changes和JSON, CSS, HTML Modules并列为Web Components标准2

Shadow DOM的概念

我们仍以上面的滑块作为例子。在最新的Chrome浏览器上,你可以输入如下代码来实现上面的功能:

<input type="range" disabled min="20" max="100" defaultValue="30"/>

请打开DevTools中的“show user agent shadow DOM”:

在DevTools的Elements标签中,我们可以看到这个“组件”的实现细节。

上面的input range,可以看作是浏览器内置的一个组件。它是利用Shadow DOM来完成的一个组件。类似的,还有Audio、Video等组件。读者可以做类似的实验。

为了搞清Shadow DOM的机制,我们需要先理清几个概念:

  1. Shadow DOM: 是一种依附于文档原有节点的子 DOM,具有封装性。

  2. Light DOM: 指原生的DOM节点,可以通过常规的API访问。Light DOM和Shadow DOM常常一起出现。这也是很有意思的一个比喻。一明一暗,灯下有影子。

  3. Shadow Trees:Shadow DOM的树形结构。一般地,在Shadow Trees的节点不能直接被外部JavaScript的API和选择器访问到,但是浏览器会对这些节点做渲染。

  4. Shadow Host:Shadow DOM所依附的DOM节点。

  5. Shadow Root:Shadow Trees的根节点。外部JavaScript如果希望对Shadow Dom进行访问,通常会借助Shadow Root。

  6. Shadow Boundary:Shadow Tree的边界,是JavaScript访问、CSS选择器访问的分界点。

  7. content:指原本存在于Light DOM 结构中,被标签添加到影子 DOM 中的节点。自Chrome 53以后,content标签被弃用,转而使用template和slot标签。

  8. distributed nodes:指原本位于Light DOM,但被content或template+slot添加到Shadow DOM 中的节点。

  9. template:一致标签。类似我们经常用的<script type='tpl'>,它不会被解析为dom树的一部分,template的内容可以被塞入到Shadow DOM中并且反复利用,在template中可以设置style,但只对这个template中的元素有效。

  10. slot:与template合用的标签,用于在template中预留显示坑位。如:


<div id="con">

我是基础文字

<span slot="main1">

占位1

</span>

<span slot="main2">

占位2

</span>

我还是基础文字

</div>

<template id="tpl">

我是模版

<slot name="main1">

</slot>

<slot name="main2">

</slot>

我还是模版

</template>

<script>

let host = document.querySelector('#con');

let root = host.attachShadow({mode:'open'});

let con = document.getElementById("tpl").content.cloneNode(true);

root.appendChild(con);

</script>

下面这幅图,展示了上述概念的相互关系:

Shadow DOM的特性

了解了Shadow DOM相关的概念,我们来了解一下相关的特性,以便更好地使用Shadow DOM:

  1. DOM 的封装性:在不同的 Shadow Trees中无法选择另外 Shadow Tree 中的元素,只有获取对应的 Shadow Tree 才能对其中的元素进行操作。

  2. 样式的封装性:原则上,在Shadow Boundary外的样式,无法影响Shadow DOM的样式;而对于Shadow Tree内部的样式,可以由自身的style标签或样式指定;不同的Shadow Tree元素样式之间,也不会相互影响。对于需要影响的、以Shadow Boundary分离的样式,需要由特殊的方案显示指定,如::host选择器,:host-context()选择器、::content()选择器等等。

  3. JavaScript事件捕获与冒泡:传统的JavaScript事件捕获与冒泡,由于Shadow Boundary的存在,与一般的事件模型有一定的差异。

    在捕获阶段,当事件发生在Shadow Boundary以上,Shadow Boundary上层可以捕获事件,而Shadow Boundary下层无法捕获事件。在冒泡阶段,当事件发生在Shadow Boundary以下,Shadow Boundary上层会以Shadow Host作为事件发生的源对象,而Shadow Boundary下层可以获取到源对象。

    事件abort、 error、 select 、change 、load 、reset 、resize 、scroll 、selectstart不会进行重定向而是直接被干掉。

    读者可以从这个例子3里感受一下。

  4. 多个Shadow Tree同时共用一个Shadow Host,只会展示最后一个Shadow Tree。

如何使用Shadow DOM

了解了上述基础知识之后,我们可以试着利用Shadow DOM做些事情了。

1. 创建Shadow DOM


const div = document.createElement('div');

const sr = div.attachShadow({mode: 'open'});

sr.innerHTML = '<h1>Hello Shadow DOM</h1>';

这里注意下{mode: 'open'},此后通过div.shadowRoot即可拿到sr的实例。sr可以使用一般的JavaScript API来做相关的操作。

如果这里采用{mode: 'closed'},则此时div.shadowRoot为null。外部不可能再拿到sr的实例。此时外部很难操作到sr下的Shadow DOM,仅可以依靠Shadow内部的元素来进行操作。

2. 在Shadow DOM内部来操作Shadow Host的样式

:host 允许你选择并样式化 Shadow Tree所寄宿的元素


<button class="red">My Button</button>

<script>

var button = document.querySelector('button');

var root = button.createShadowRoot();

root.innerHTML = '<style>' +

':host { text-transform: uppercase;font-size:30px; }' +

'</style>' +

'<content></content>';

</script>

3. 跨越Shadow Boundary的样式::part()

对于::part,在允许样式的Shadow DOM,给属性part赋值,样式选择器可以使用::part(属性值)即可实现指定样式。需要注意的是,在::part()选择器后,子代选择器无效。如你不能使用::part(foo) span。

<style>

  c-e::part(innerspan) { color: red; }

</style>


<template id="c-e-outer-template">

  <c-e-inner exportparts="innerspan: textspan"></c-e-inner>

</template>


<template id="c-e-inner-template">

  <span part="innerspan">

    This text will be red because the containing shadow

    host forwards innerspan to the document as "textspan"

    and the document style matches it.

  </span>

  <span part="textspan">

    This text will not be red because textspan in the document style

    cannot match against the part inside the inner custom element

    if it is not forwarded.

  </span>

</template>


<c-e></c-e>

<script>

  // Add template as custom elements c-e-inner, c-e-outer


let host = document.querySelector('c-e');

let root = host.attachShadow({mode:'open'});


let con = document.getElementById("c-e-inner-template").content.cloneNode(true);


root.appendChild(con);

</script>

::part()选择器自Chrome73开始支持。之前的版本,可以考虑^和^^选择器,^和^^选择Shadow DOM在最新版本已经无效。

4. 定义一个组件


class FlagIcon extends HTMLElement {

constructor() {

super();

this._countryCode = null;

}

static get observedAttributes() { return ["country"]; }

attributeChangedCallback(name, oldValue, newValue) {

// name will always be "country" due to observedAttributes

this._countryCode = newValue;

this._updateRendering();

}

connectedCallback() {

this._updateRendering();

}

get country() {

return this._countryCode;

}

set country(v) {

this.setAttribute("country", v);

}

disconnectedCallback() {

console.log('disconnected!');

}

_updateRendering() {

// Left as an exercise for the reader. But, you'll probably want to

// check this.ownerDocument.defaultView to see if we've been

// inserted into a document with a browsing context, and avoid

// doing any work if not.

}

}

customElements.define("flag-icon", FlagIcon);

const flagIcon = new FlagIcon()

flagIcon.country = "zh"

document.body.appendChild(flagIcon)

自定义的组件,都需继承自HTMLElement。然后调用customElements.define方法,将组件引入过来。之后,就可以在代码中使用了。

组件生命周期大致经过以下几个阶段:

  1. constructor 会在元素创建后而尚未被附加到文档上之前被调用。我们用 constructor 来设置初始状态、事件监听以及 shadow DOM。

  2. connectCallback 会在元素被添加到 DOM 中后被调用。此时非常适合执行初始化代码,比如获取数据或是设置默认属性。

  3. disconnectedCallback() 会在元素从 DOM 中被移除后调用。可以利用 disconnectedCallback 来移除事件监听器或取消定时循环事件。

  4. attributeChangedCallback 会在元素的受监控的属性变动时被调用。

兼容性

目前Shadow dom有两个主流的标准,V0和V1,V0已经被废弃,当前的版本为V1。以下是当前(2019年10月)的主流浏览器支持情况:

小结

本文介绍了Shadow DOM的标准内容。这里或多或少的涉及到了WebComponents标准的其他内容,我们会在后面的文章,详细介绍其他相关标准的内容。在翻阅Shadow DOM历史资料的过程中,发现很多标准中定义的方法发生了变化甚至废弃,建议大家以官方最新的标准4为准。

参考资料

  1. https://meowni.ca/posts/part-theme-explainer/

  2. https://www.html5rocks.com/zh/tutorials/webcomponents/shadowdom-201/

  3. https://drafts.csswg.org/css-shadow-parts/

  4. https://www.cnblogs.com/yangguoe/p/8486046.html

  5. https://html.spec.whatwg.org/multipage/custom-elements.html#custom-elements

文内链接

  1. https://clair-design.github.io/component/slider

  2. https://github.com/w3c/webcomponents

  3. https://jsbin.com/kiqatolede/1/edit?html,console,output

  4. https://dom.spec.whatwg.org/#shadow-trees

回复“加群”与大佬们一起交流学习~

【Web技术】400- 浅谈Shadow DOM的更多相关文章

  1. 【Web技术】399- 浅谈前端代码加密

    作者简介:于航,PayPal Senior Software Engineer,在 PayPal 上海负责 Global GRT 平台相关的技术研发工作.曾任职于阿里巴巴.Tapatalk 等企业.f ...

  2. 【web安全】浅谈web安全之XSS

    XSS定义 XSS, 即为(Cross Site Scripting), 中文名为跨站脚本, 是发生在目标用户的浏览器层面上的,当渲染DOM树的过程成发生了不在预期内执行的JS代码时,就发生了XSS攻 ...

  3. Web 字体 font-family 浅谈

    前言 最近研究各大网站的font-family字体设置,发现每个网站的默认值都不相同,甚至一些大网站也犯了很明显的错误,说明字体还是有很大学问的,值的我们好好研究. 不同的操作系统.不同浏览器下内嵌的 ...

  4. 浅谈JavaScript DOM编程艺术读后感和一点总结

    最近工作不是很忙就想想想JavaScript的内部组成和一些要点,就是从这本书开始的.对新手来说还好,简单易懂. 简单终结下我重书中学到的一些要点. 下面都是个人学习的要点提取: 1.给自己预留退路, ...

  5. 理解Web路由(浅谈前后端路由与前后端渲染)

    1.什么是路由? 在Web开发过程中,经常会遇到『路由』的概念.那么,到底什么是路由?简单来说,路由就是URL到函数的映射. 路由的概念最开始是由后端提出来的,在以前用模板引擎开发页面的时候,是使用路 ...

  6. 浅谈 Virtual DOM 的那些事

    背景 我们都知道频繁的dom给我们带来的代价是昂贵的,例如我们有时候需要去更新Table 的部分数据,必须去重新重绘表格,这代价实在是太大了,相比于频繁的手动去操作dom而带来性能问题,vdom很好的 ...

  7. 技术分析 | 浅谈在MySQL体系下SQL语句是如何在系统中执行的及可能遇到的问题

    欢迎来到 GreatSQL社区分享的MySQL技术文章,如有疑问或想学习的内容,可以在下方评论区留言,看到后会进行解答 SQL语句大家并不陌生,但某种程度上来看,我们只是知道了这条语句是什么功能,它可 ...

  8. 技术分享 | 浅谈mysql语法解析调试方法

    欢迎来到 GreatSQL社区分享的MySQL技术文章,如有疑问或想学习的内容,可以在下方评论区留言,看到后会进行解答 本文向您介绍一种利用mysql解析器和bison的调试选项进行sql语法解析跟踪 ...

  9. 技术分享 | 浅谈MySQL闪回的实现

    欢迎来到 GreatSQL社区分享的MySQL技术文章,如有疑问或想学习的内容,可以在下方评论区留言,看到后会进行解答 1.闪回实现原理 2.binlog文件格式初探 3.闪回实现过程 1.闪回实现原 ...

随机推荐

  1. 有关html的标签以及css属性(border、div)

    border 边框css属性 边框颜色 border-color边框样式 border-style:solid (实线)dashed(虚线)默认为none边框粗细 border-width:1px:默 ...

  2. git Lab ssh方式拉取代码失败

    gitLab在linux上已经安装好了, 在配置项目的时候报如下异常 使用http方式没问题, 但是用ssh方式设置repository URL 提示资源库不存在. returned status c ...

  3. GCD 面试题

    今天我们讲解几道这两天遇到的面试题--GCD编程的.题目很不错,很考究关于GCD的基本概念和使用. 对于基本的概念,本人博客已在前面讲过,本篇主要以面试题来讲解.大家可看一下本人关于GCD的基本讲解  ...

  4. 菜鸟手把手学Shiro之shiro认证流程

    一.使用的spring boot +mybatis-plus+shiro+maven来搭建项目框架 <!--shiro--> <dependency> <groupId& ...

  5. 【集训Day1 测试】装饰

    装饰(decorate) [题目描述] 一个图有 N 个结点,编号 1 至 N,有 M 条无向边,第 i 条边连接的两个结点是 Ai 和Bi,其中 Ai 和 Bi 是不同的结点.可能有多条边连接的是同 ...

  6. java集合讲解

    java集合讲解 1.概述 集合类的顶级接口是Iterable,Collection继承了Iterable接口 常用的集合主要有 3 类,Set,List,Queue,他们都是接口,都继于Collec ...

  7. Matlab查看本机IP地址---xdd

    复制粘贴于http://www.matlabsky.com/thread-28597-1-1.html [s, r]=system('ipconfig') % r=regexp(r,'IP Addre ...

  8. Spring security (一)架构框架-Component、Service、Filter分析

    “致"高级"工程师(BUG工程师) 一颗折腾的心

  9. Python执行系统命令的四种方法

    一.os.system方法 在子终端运行系统命令,可以获取命令执行后的返回信息以及执行返回的状态.执行后返回两行结果,第一行是结果, 第二行是执行状态信息,如果命令成功执行,这条语句返回0,否则返回1 ...

  10. 在 Kubernetes 集群快速部署 KubeSphere 容器平台

    KubeSphere 不仅支持部署在 Linux 之上,还支持在已有 Kubernetes 集群之上部署 KubeSphere,自动纳管 Kubernetes 集群的已有资源与容器. 前提条件 Kub ...