Shadow DOM 在浏览器扩展程序中的应用

最近在开发新版本的划词翻译。目前的划词翻译当中存在很多问题,但是,我首先要解决的就是样式冲突的问题。

为什么要在浏览器扩展程序中使用 Shadow DOM

大部分扩展程序都会尝试往网页当中注入一些 DOM 结构。以划词翻译为例,为了在用户划词之后显示翻译结果,那么肯定是需要在用户的网页里加入一些 DOM 的,那么问题就来了——这些 DOM 的样式是会受到网页影响的。

比如说,用户访问的网页(后面称之为”宿主网页“)里定义了字体大小 div { font-size: 40px },如果划词翻译自己的 DOM 里也用到了 div 元素,那么字体大小也会显示成 40px——很显然这是不应该的。

早在四年前,我就写了篇文章《保护样式王国不受侵犯》,介绍了我当时为了解决这个问题时做的一些调研,方案大致有如下几个:

使用 iframe 将划词翻译的 DOM 包裹起来:这应该是最彻底的解决方案了,它真正隔离了扩展程序的 DOM 结构和宿主网页之间的运行环境,两者不会有任何影响。但是,iframe 本身却是个”口碑“不怎么好的技术,我担心使用了之后虽然解决了样式问题,但又带来其它更多问题。使用 Shadow DOM 将划词翻译的 DOM 包裹起来:我其实比较喜欢这个方案,但一来当时 Shadow DOM 的标准是 v0 的版本,还不太成熟,二来经过测试,我发现 Shadow DOM 虽然能避免自己内部的样式影响到宿主网页,但还是不能避免宿主网页的可继承样式影响到 Shadow DOM 内部,所以最后没有使用。使用 CSS 中的 all: initial 重置来自宿主网页的样式:这个方案我是后来才了解到的,它能避免宿主网页的可继承样式影响到 DOM,但是如果划词翻译使用的 CSS 类名恰好也在宿主网页中定义了,那还是会被影响到。

现在回过头来再看这个问题,用 all: initial + Shadow DOM 的方案就可以避免样式问题了:all: initial 阻止了宿主网页的样式侵入到 Shadow DOM 内部,而 Shadow DOM 则阻止了宿主网页里相同类名的样式应用到内部,它们俩正好形成了一个互补。

虽然技术方案有了,但在实际应用过程中,还是遇到了不少问题。

第一个问题:来自 Shadow DOM 内部事件的 event.target

划词翻译有一个功能,在用户点击并移动翻译窗口的 header 的时候,可以拖动整个翻译窗口。它的实现方式大致如下:

const container = document.createElement(div) document.body.appendChild(container) const shadowRoot = container.attachShadow({ mode: open }) const headerElement = shadowRoot.getElementById(header) document.addEventListener(mousedown, (event) => { if (event.target === headerElement) { startDrag() } })

但是当我使用了 Shadow DOM 之后,这段代码不生效了。debug 了一下才发现,来自 Shadow DOM 内部的事件的 event.target 指向的是 Shadow DOM 的宿主元素,也就是上面代码中的 container,所以 event.target === headerElement 只会是 false。

不过,我们可以用 event.composedPath() 来获取到事件传播时经过的所有 DOM 节点,上面的代码可以这么改一下:

... if (event.composedPath()[0] === headerElement) ... ...

虽然有办法可以绕过这个问题,但是如果我们用了一些第三方包,而这些包又没有对 Shadow DOM 做支持,那么问题就比较麻烦了。

第二个问题:mode 使用 open 还是 closed?

现在,Shadow DOM 是 v1 版本了,在创建 Shadow Root 的时候,可以选择 mode 为 open 或者 closed。在上面的代码中,我用的是 open。

一开始,我不太了解这两个模式的区别,只是单纯的想着,为了避免宿主网页影响到 Shadow DOM 内部,那么用 closed 应该是比较稳妥的,但遇到了一个问题。

我使用了 interact.js 来完成拖动翻译窗口的功能,代码大致如下:

interact(headerElement).draggable(...)

但拖动功能却一直没有生效。我查看了 interact.js 的更新记录,作者也确实添加过对 Shadow DOM 的支持(见 PR #143)。我大致扫了一眼代码,发现它用到了 event.path[0]……

event.path 跟 event.composedPath() 返回的数组里的 DOM 节点是一样的,而我注意到,event.path[0] 是 container 而不是 headerElement,但如果我把 mode 改为 open,event.path[0] 就变成 headerElement 了。

这就是了——如果 mode 使用了 closed,那么很多属性或方法都会隐藏 Shadow DOM 内的 DOM 节点,包括但不限于这里提到的 event.path 和 event.composedPath();而且,把 mode 设置成 closed 并不能真的阻止宿主网页访问到 Shadow DOM 的内部结构——它只需要提前修改 attchShaodw 方法就可以了:

const nativeAttachShadow = HTMLElement.prototype.attachShadow HTMLElement.prototype.attachShadow = function () { return nativeAttachShadow.call(this, { mode: open }) }

所以,在我浪费了几个小时之后,我的结论是:不要使用 closed 模式。

第三个问题:在 Shadow DOM 中使用字体图标

趟过了前面两个坑之后,我开始着手给划词翻译加一些图标了。我使用了一些字体图标,代码实现如下:

const css = ` @font-face { font-family: hcfy-font-icons; src: url(data:font/woff2;base64,...) format(woff2); } .icon-help { ... } .icon-settings { ... } ` const style = document.createElement(style) style.textContent = css shadowRoot.appendChild(style)

但是在我给 Shadow DOM 内的元素应用了 class="icon-help" 样式之后,图标没有正常显示出来。

我先解释一下我这里为什么要用 Data URI 内嵌 WOFF2 字体文件。Firefox 和 Chrome 对于注入到宿主网页里的样式中文件引用的解析方式不一样。Firefox 是相对于扩展程序来解析的,而 Chrome 是相对于宿主网页的路径解析的。举个例子,如果我们在 http://a.com 中注入了一条样式 url(./logo.png),那么 Firefox 会读取扩展程序中的 logo.png,而 Chrome 会读取 a.com/logo.png。为了让 Firefox 和 Chrome 表现一致,我想到将文件内嵌的形式,这样就不用考虑到它们之间的解析方式的不同了。

但图标没有正常显示出来,我想是不是因为我内嵌了文件的缘故,于是又试着改为了直接引用文件路径的方式,但是图标还是没有显示出来。

Google 一番之后,找到了这个问题:@font-face doesnt work with Shadow DOM?,而评论里有人提到了解决方案:@font-face 不能定义在 Shadow DOM 里。

于是我改造了一下上面的代码,图标在 Chrome 里就能正常显示了:

// 将 @font-face 写在宿主网页中 const fontFaceCss = ` @font-face { font-family: hcfy-font-icons; src: url(data:font/woff2;base64,...) format(woff2); } ` const fontFaceStyle = document.createElement(style) style.textContent = fontFaceCss document.head.appendChild(fontFaceStyle) // 将图标样式写在 Shadow DOM 里 const iconsCss = ` .icon-help { ... } .icon-settings { ... } ` const style = document.createElement(style) style.textContent = iconsCss shadowRoot.appendChild(style)

然而,在 Firefox 里仍然显示异常:因为违反了宿主网页的内容安全策略(Content Security Policy)。

宿主网页的内容安全策略对扩展程序的影响

我习惯在 GitHub 里测试划词翻译,而 GitHub 跟其他网站相比最特别的,就是它定义了内容安全策略。

这里不打算详细展开内容安全策略的作用,简单点讲,内容安全策略限制了 GitHub 的网页只能从特定的几个域名加载资源。

在开发划词翻译之初,我是直接从内容脚本里请求的翻译接口的数据,但到了 GitHub 就行不通了,因为翻译接口的地址不在 GitHub 的内容安全策略白名单里。为此,我只能把请求数据的逻辑移到背景脚本中。

在 Firefox 中就不会有这样的问题,因为 Firefox 的内容脚本的运行环境是扩展程序,不是宿主网页,所以不会被 CSP 限制。

但是在刚才的情况中,我使用内容脚本创建了 style 元素注入到宿主网页中,Chrome 没报错,Firefox 却报 CSP 的错误了。我猜想应该不能用动态创建 style 的方式注入样式,需要在 manifest.json 中定义注入到宿主网页中的样式文件才行。在改造了之后,果然就没有这个问题了。

然而,manifest.json 中指定的样式文件是应用到宿主网页本身的,Shadow DOM 里的样式还是需要动态创建才行。为此,我修改了 Webpack 的配置:

module.exports = { module: { rules: [ { test: /\.css$/i, oneOf: [ { resourceQuery: /toString/, use: [css-loader], }, { use: [MiniCssExtractPlugin.loader, css-loader], }, ], }, ], }, }

这样我就可以通过改变文件引用的方式来决定哪些样式被抽离成 css 文件、哪些作为字符串引用进来:

import content.css // 这个文件会被抽离到单独的 css 文件当中 import shadowCSS from ./shadow.css?toString // 以 ?toString 结尾的文件会作为字符串引用进来 const style = document.createElement(style) style.textContent = shadowCSS.toString() shadowRoot.appendChild(style) // 注入到 Shadow DOM 中

我的项目使用了 TypeScript,所以还需要定义一下这两种“模块”,避免报错:

// shim.d.ts declare module *.css {} // 抽离的 css 是没有 export 的 declare module *.css?toString { // toString 结尾的 css 其实是一个字符串数组 const cssArr: Array<string> export default cssArr }

总结

新版本的划词翻译仍然在持续开发中,后面遇到其他问题了再更新。