blog
blog copied to clipboard
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"
样式之后,图标没有正常显示出来。
题外话:Chrome 与 Firefox 对 CSS 解析的不同之处以及解决办法
你可能会好奇我在上面的例子中使用了 Data URI 的形式载入字体图标。
Firefox 和 Chrome 对于注入到宿主网页里的样式中文件引用的解析方式不一样。Firefox 是相对于扩展程序来解析的,而 Chrome 是相对于宿主网页的路径解析的。举个例子,如果我们在 a.com 中注入了一条样式 url(./logo.png)
,那么 Firefox 会读取扩展程序中的 logo.png,而 Chrome 会读取 a.com/logo.png。在 Chrome 中,我们需要给资源文件加上 chrome-extension://__MSG_@@extension_id__/
的前缀才能正确读取到扩展程序内的文件。
所以为了让 Firefox 和 Chrome 表现一致,我想到使用 Data URI 将文件内嵌的形式,这种形式在 Chrome 和 Firefox 里的表现都是一致的。(但其实这种方式对于限制了 CSP 的网站是行不通的,后面会提到。)
如果你仍然想用 url(path/to/font.woff2)
的形式来加载文件,那么最好使用 Webpack 这类打包工具来处理 Firefox 和 Chrome 的差异,例如:
// webpack.config.js
modules.exports = {
output: {
publichPath: BUILD_TARGET === 'chrome' ? 'chrome-extension://__MSG_@@extension_id__/' : '',
path: `dist/${BUILD_TARGET}`
}
}
回到正题
但图标没有正常显示出来,我想是不是因为我内嵌了文件的缘故,于是又试着改为了直接引用文件路径的方式,但是图标还是没有显示出来。
Google 一番之后,找到了这个问题:@font-face doesn't 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 的方式给宿主网页注入了样式,这种方式可能在定义了 CSP 规则的网站里行不通,需要在 manifest.json 中定义注入到宿主网页中的样式文件才行。在改为使用 manifest.json 注入样式文件之后,果然就没有这个问题了。
然而,manifest.json 中指定的样式文件是应用到宿主网页本身的,Shadow DOM 里的样式还是需要动态创建才行。为此,我修改了 Webpack 的配置:
module.exports = {
module: {
rules: [
{
test: /\.css$/i,
oneOf: [
{
resourceQuery: /toString/,
use: ['to-string-loader', 'css-loader'],
},
{
use: [MiniCssExtractPlugin.loader, 'css-loader'],
},
],
},
],
},
}
这样我就可以通过改变文件引用的方式来决定哪些样式被抽离成 css 文件、哪些作为字符串引用进来:
import 'font-face.css' // 这个文件会被抽离到单独的 css 文件当中
import shadowCSS from './shadow.css?toString' // 以 ?toString 结尾的文件会作为字符串引用进来
const style = document.createElement('style')
style.textContent = shadowCSS
shadowRoot.appendChild(style) // 注入到 Shadow DOM 中
我的项目使用了 TypeScript,所以还需要定义一下这两种“模块”,避免报错:
// shim.d.ts
declare module '*.css' {} // 抽离的 css 是没有 export 的
declare module '*.css?toString' {
const cssStr: string
export default cssStr
}
第四个问题:在 ShadowDOM 的 CSS 中引用资源文件,例如图片
在第三个问题中,我们提到过,如果我们在内容脚本里引用了资源文件,那么 Chrome 需要加上 chrome-extension://__MSG_@@extension_id__/
前缀,Firefox 不需要额外的处理。
Chrome 会自动将 chrome-extension://__MSG_@@extension_id__/
中的 __MSG_@@extension_id__
替换成你扩展程序的 id,Firefox 会自动把相对链接指向扩展程序内,但如果样式是写在 ShadowDOM 里的,那么这两个处理都会失效,所以我们在 ShadowDOM 里引入样式时,需要自行将所有路径都转为指向扩展程序内的绝对路径:
import cssInShadowDOM from 'shadow.css?toString'
const style = document.createElement('style')
style.textContent = shadowCSS.replace(/url\((.*)\)/g, function (match, p1) {
let result = p1
if (process.env.TARGET === 'chrome') {
result = result.replace('chrome-extension://__MSG_@@extension_id__/', '')
}
return `url(${browser.runtime.getURL(result)})`
})
shadowRoot.appendChild(style)
顺带一提,Firefox 是没有类似于 Chrome 这种 __MSG_@@extension_id__
自动转成扩展 id 的机制的,所以我们没法(也没必要,毕竟 Firefox 会自动把相对路径指向扩展内)像 Chrome 那样加上 chrome-extension://__MSG_@@extension_id__/
的 publicPath。
Chrome 和 Firefox 还是有一些不同的差异的,后面有空的话我再单独写一篇文章介绍。
总结
新版本的划词翻译仍然在持续开发中,后面遇到其他问题了再更新。
弹出窗口的大小可以不受页面缩放影响吗?
@lmk123 可以