在用户脚本中避免样式冲突

如果你还不知道什么是用户脚本(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 生成器 来开发用户脚本,开箱即用地享有强大的样式开发能力。


© 2020