如果你还不知道什么是用户脚本(userscript),不妨先去了解一下暴力猴。
用户脚本的作用是在网站上额外地执行用户定制的(或安装的)第三方脚本,实现一些辅助功能。
很多情况下,我们的用户脚本可能会实现一些比较通用的功能,希望在各种各样的网站上都能正常工作,这就需要脚本不受网站本身的干扰,在各种各样的环境下,都能正常展示自己的样式。一个简单的例子就是划词翻译。
对用户脚本来说,在页面上插入一个面板,加入一些自定义的样式,是一个很常见的需求,所以在暴力猴的辅助脚本 vm-ui 中,就专门提供了一个样式安全的 panel 功能。下面介绍一下相关的技术方案。
Shadow DOM
说到避免样式冲突,很容易想到的一个方案就是 Shadow DOM 了。它已经被现代浏览器广泛支持,可以用于封装自定义的组件,所以天然就是样式隔离的。早期的 vm-ui 就是使用的 Shadow DOM 方案。
Shadow DOM 本质上是在页面上创建一个宿主元素(Shadow host),然后在里面创建一个与外面的 DOM 隔离的 Shadow tree,其根节点就是 Shadow root。这时我们只要在 Shadow tree 中创建 <style>
标签插入样式,就只会影响 Shadow tree 中的元素,而外面的样式选择器无法选择到 Shadow tree 中的元素,所以也无法覆盖内部的样式。唯一需要注意的是,Shadow tree 中的元素会继承 Shadow host 的一些样式,所以我们可以通过对根节点设置 all: initial
来重置样式,实现完全隔离。
大致代码如下:
const host = document.createElement('div');
const root = host.attachShadow({ mode: 'open' });
root.append(<style>{css}</style>, content);
:host {
all: initial;
}
最终得到的 DOM 结构如下:
<div>
#shadow-root (open)
<style>...</style>
<div>content</div>
</div>
绕过 CSP 限制
大部分情况下,Shadow DOM 的方案已经可以满足需求了。然而在某些网站上,比如 GitHub,有非常严格的 CSP 限制,禁止一切内联样式,导致所有动态创建的 <style>
都会被拦截。
针对 CSP 限制,有两个方案可以绕过:
-
第一个方案是 userscript 特有的。可以通过暴力猴提供的
GM_addStyle
API 来插入样式,将<style>
标签挂载到document.head
上并生效。然而,Shadow DOM 的样式并不能挂载到 Shadow tree 的外部。
-
第二个方案是使用比较冷门的 CSSOM。
这个方案也不适用于 Shadow DOM,甚至不能完全解决 CSP 带来的问题。因为在不使用内联样式的前提下,CSSOM 并不能直接创建新的样式表,只能对已有的 CSSRuleList 进行修改。所以它的前提是,页面上至少有一个样式表存在。如果是 Shadow DOM 的话,这显然也是难以实现的。
从这两个方案来看,第一个方案的可行性更高,只要我们不使用 Shadow DOM,CSP 的问题就迎刃而解了。
避免样式冲突
如果注定不能使用 Shadow DOM 来隔离样式的话,我们就必然要直面样式冲突的问题了。
在 Web 前端领域,避免样式冲突的方案已经有不少了,比如 CSS modules,这也正是我们接下来将要依赖的手段。
在此之前还有一个难题要解决,就是如何避免组件的样式被页面样式所干扰。如果要在不同网站上都保持相同的视觉效果,就要让所有 CSS 属性都在用户脚本的控制下,不能继承或被覆盖成页面的样式。
然而我们永远无法预料到页面会有怎样千奇百怪的样式,也很难保证组件内部的元素不被页面的样式选择器命中。那么,有没有办法让组件内部的元素绝对不会被页面样式覆盖呢?
这里,我们可以先回顾一下 CSS的优先级。划重点:
- ID 选择器的优先级最高。只要有一个 ID 选择器命中,不论其他规则有多少个 class、attributes、type 等选择器,优先级都不如 ID 选择器高。
*
对优先级没有影响。所以只要让元素的 tagName 和 class、attributes 都无法被外部样式命中,它的样式就不会被外部样式覆盖。
这样一来,方案就有了,首先生成一个随机 ID,用来同时当元素的 tagName 和 ID,假设生成的 ID 为 a1b2c3d4
,则 DOM 结构如下:
<a1b2c3d4 id="a1b2c3d4">
<a1b2c3d4 class="x1y2z3">hello, world</a1b2c3d4>
</a1b2c3d4>
这里随机 ID 的作用是提高组件内部样式的优先级,随机 tagName 的作用是防止被外部样式命中。所以我们还需要附加样式如下:
#a1b2c3d4 * {
all: initial;
}
#a1b2c3d4 .x1y2z3 {
font-size: 20px;
}
这样无论在什么网站上运行,都可以保证这部分 DOM 的样式是一致的。至于组件内部的样式,就可以使用 CSS modules 轻松地实现无冲突,比如上面的 .x1y2z3
就应该是由 CSS modules 生成的。
结语
一套非主流的样式无冲突方案就这样实现了,本质上全都是最基础的 Web 前端技术,所以也不会有什么兼容性问题。实现要点如下:
- 使用随机的 ID 提高组件内部所有元素的样式优先级;
- 重置组件内部所有元素的样式,防止被外部通用选择器命中;
- 使用随机的 tagName + CSS modules 防止 DOM 元素被外部样式命中。
推荐使用暴力猴提供的 userscript 生成器 来开发用户脚本,开箱即用地享有强大的样式开发能力。