编者按:本文作者:刘观宇,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. java编程思想第四版第十八章总结

    一.概述 如何学习java I/O 学习I/O类库 学习I/O发展史,为什么要学习发展史呢? 因为,如果缺乏发展史,我们就会对什么时候使用哪个类,以及什么时候不该使用它们而感到迷惑. 了解nio 二. ...

  2. nyoj 524-A-B Problem (java stripTrailingZeros, toPlainString)

    524-A-B Problem 内存限制:64MB 时间限制:1000ms 特判: No 通过数:2 提交数:4 难度:3 题目描述: A+B问题早已经被大家所熟知了,是不是很无聊呢?现在大家来做一下 ...

  3. Vue导入非模块化的第三方插件功能无效解决方案

    一.问题: 最近在写vue项目时,想引入某些非模块化的第三方插件时,总是发现会有报错.且在与本地运行插件测试对比时发现插件根本没有注入到jQuery中(console.log($.fn)查看当前jq有 ...

  4. oracle实现"limit"功能

    转载于http://blog.sina.com.cn/s/blog_67e2758d0100s3oc.html oracle数据库不支持mysql中limit功能,但可以通过rownum来限制返回的结 ...

  5. ndk编译出来的executable动态库入口函数的参数错乱

    早些时间用ndk编译带main入口函数的动态库,测试可运行.今天要作它用时,发现在这个入口函数并没有传入正确的参数. hello.cpp有main函数,用ndk分别编译成可执行文件和动态库文件,使两者 ...

  6. 就该这样理解 OSI 七层参考模型、浅谈不同局域网之间的通信

    简介 说到OSI参考模型,理解网络与网络之间的关系,不说太深入难以理解的东西,只求能最大程度上理解与使用. 参考模型是国际标准化组织(ISO)制定的一个用于计算机或通信系统间互联的标准体系,一般称为O ...

  7. vim的查找功能

    vim是一款强大的编辑器. 在vim下要查找字符串: 一,全匹配: 1,从上往下查找,比如“string” :   /string 2,从下往上查找,比如“string” : ?string 二,模糊 ...

  8. python主线程与子线程的结束顺序

    引用自 主线程退出对子线程的影响--YuanLi 的一段话: 对于程序来说,如果主进程在子进程还未结束时就已经退出,那么Linux内核会将子进程的父进程ID改为1(也就是init进程),当子进程结束后 ...

  9. AppBoxFuture: 集成第三方Sql数据库

      框架设计之初是不准备支持第三方数据库的,但最近几个朋友都提到需要将旧的基于传统Sql数据库的应用迁移到框架内,主要是考虑到一方面目前框架内置的分布式数据库尚未完善,另一方面是希望能逐步迭代旧应用替 ...

  10. nginx支持https配置

    nginx证书 nginx.conf配置.