跳到正文

第三方页面适配:CSS 先处理外观,JS 再修正行为

打开一段异步加载的评论,某个词先以蓝色链接出现,随后又变成普通文字。最终结果没有错,但这一下颜色变化很显眼。深色弹窗先露出白底、搜索框先出现推荐词、内部滚动条先显示原生样式,也可能属于同一类问题:目标样式要等脚本发现节点、判断类型、写入标记之后才能生效。

做浏览器扩展和用户脚本时,我们通常不能修改原站组件,却可以提前声明样式。只要节点原本就带着足够的识别信息,外观便可以直接交给 CSS;需要解析地址、改变属性和处理事件的部分,再由 JS 完成。

闪烁发生在等待的哪一步

假设评论中的自动实体链接要被还原成普通文字。实体指页面识别出的词语,例如技术名称、人物或地点;原站会给它们附上搜索入口。这和作者主动插入的外链、承载讨论的行内批注不同。

常见实现会用 MutationObserver 发现新增评论,再交给共用的扫描器。为了合并连续变动,扫描器可能还有防抖。下面是说明时序的简化代码,不对应某个网站的完整实现:

let timer;
const observer = new MutationObserver(() => {
  clearTimeout(timer);
  timer = setTimeout(() => {
    cleanEntityLinks(document);
  }, 120);
});
observer.observe(document.body, { childList: true, subtree: true });

如果 CSS 只匹配 cleanEntityLinks() 稍后写入的 data-entity-cleaned,新增节点在等待期间就仍然保留原样。持续有变动时,防抖执行还可能继续推迟。缩短共用扫描器的防抖延迟可以减少等待,却也会改变其他功能的扫描频率。

这里要区分两种时机。MutationObserver 的通知通过微任务安排,并不是每次都会等浏览器画完再执行;真正需要检查的是有没有在回调里继续等待定时器、任务队列或业务异步步骤。MDN 的微任务说明列出了观察器使用微任务的方式,HTML 的事件循环、微任务检查点定义了相关执行阶段。不能仅凭「观察器异步」就断言它必然闪一帧。

延后脚本清理与 CSS 提前匹配的处理时机

图里比较的是带延后步骤的脚本方案。下面一行的前提很重要:CSS 已经加载,而且原始节点能够被识别。它消除的是等待 JS 标记的视觉窗口,不是让浏览器跳过样式计算。

CSS 匹配原始节点,JS 修改真实行为

把首态样式挂到 JS 标记上,仍然要等 JS。提前规则必须读取原站已经提供的信息,例如语义 class、类型属性,或范围明确的局部结构。

有语义属性时,直接使用它

下面是用于演示的 DOM,不是某个网站的实际快照。data-link-kind="entity" 假设由页面自身生成,在链接插入时已经存在:

<div class="comment-body">
  一段含有
  <a data-link-kind="entity"
     href="https://example.com/search?source=entity&q=layout">
    自动实体<span class="entity-icon">↗</span>
  </a>
  的评论。
</div>

目标只是让这类词退回普通文字时,可以先直接匹配类型,同时排除行内批注。后面的清理标记用于接续 JS 处理后的状态:

.comment-body a[data-link-kind='entity']:not(.inline-comment):not(.inline-comment a),
.comment-body a[data-entity-cleaned='true'] {
  color: inherit;
  text-decoration: none;
  cursor: text;
  pointer-events: none;
}

这段 CSS 放在扩展实际注入的样式入口里。它不用给每个新节点补 class,也不用等扫描器调用;原站追加评论或替换属性时,浏览器会按当前 DOM 重新匹配。图标的隐藏规则和完整例子在下面的源码区。

浏览器扩展还要确认样式究竟何时进入页面。Chrome 的 manifest content_scripts.css 会在匹配页面的 DOM 构建、显示之前注入,run_at 则规定脚本的执行时机;它们不应被理解成同一条开关。Chrome content scripts 文档给出了两者的职责。若是普通网页里晚加载的样式或用户脚本管理器,仍需按实际注入方式检查,不能直接套用这个保证。

没有类型属性时,CSS 只能做有限识别

有些页面没有语义属性,只能从评论容器、链接 URL 和搜索图标推断实体入口。这时可以组合已核验的原始结构,但不要只写 a[href*='entity']。

以来源参数为例,source=entity 和 source=entityOther 不同;关键词里出现 entity 不代表链接来源;fragment 中的 &source=entity 也不是查询参数。相近域名、嵌套 URL、编码和重复参数还会增加歧义。CSS 属性选择器比较的是属性字符串,不会替你调用 URL 解析器。

因此,提前 CSS 可以只覆盖有证据的规范地址形态,再要求原站实体图标等额外线索;相对地址、编码变体或无法可靠区分的输入继续交给 JS。使用 :has() 时,把范围限定在具体组件和短的子元素关系里,不要全页扫描式地写 *:has(...)。这项能力也要落在扩展支持的浏览器范围内。

样式经过预处理器时,还要检查最终产物。属性值中的 &、选择器列表里的逗号和嵌套语法,可能被当成构建语法处理。源码能读懂、编译不报错,并不等于输出选择器仍然表达原来的条件。

外观改变以后,链接属性仍需清理

pointer-events: none 只改变指针命中。元素仍可能进入 Tab 聚焦顺序,祖先事件代理也未必因此停止。pointer-events 文档明确保留了顺序键盘聚焦这一边界。颜色和鼠标手势消失,不能证明链接语义也消失。

JS 应继续精确核验目标地址,再移除 href、target、rel 等跳转属性,处理显式链接角色和聚焦顺序,并留下稳定的清理标记。普通链接与行内批注在判断阶段就应排除,而不是先清理再恢复。

也不要为一个功能顺手删除所有事件监听、替换整段 innerHTML 或把链接换成新的 span。保留原节点和子节点,更容易维持原站组件的引用、文本选择和其他有意义的交互。若原站确实有独立的点击处理器,还需针对那个处理器取证;移除 href 不是全能的事件取消机制。

对照 JS 标记与提前 CSS 的生效时机

下面的演示插入相同的自动实体、普通链接和行内批注,两边都延后 800ms 清理链接属性。这个延迟是为了让差异容易观察,不是性能测量结果,也不是建议的生产配置。

左边等 JS 标记后才改变外观;右边提前加载 CSS。点击「重新插入」后,可以观察右边的实体从插入时就没有蓝色和箭头,而普通链接、行内批注仍然保留。稍后两边的状态会显示属性清理完成。

请读取 https://shengsheng.fun/files/css-first-third-party-page-adaptation/kits/css-first-entity-cleanup/ 下的 README.md、AGENT_PROMPT.md 和 FILES.json,参考 copy/ 中的 CSS 与 JS 分工,把选择器和 URL 条件替换为当前项目已取证的真实结构,并补上普通链接与行内批注的反例。
CSS 提前匹配与延后标记对照正在加载代码工作区...

也可以单独打开对照演示,用整个窗口观察两边的变化。

演示没有访问外部站点,点击普通链接与批注时只更新本地状态。copy/ 中的文件提供分工示例,不包含替任何站点自动发现目标节点的通用识别器;接入时应复用项目已有的 DOM 调度,避免再开一套全页监听。

其他 DOM 改造如何分配 CSS 与 JS

同样的检查方法可以用于其他功能,但要先确认原来的 JS 承担了什么。

  • 反色、边框和显隐。 原始节点可识别时,直接写 CSS;若已有正确语义的主题变量,优先改变量,不必逐元素写行内颜色。
  • 搜索默认词。 ::placeholder 可以提前隐去占位文字,而不隐藏真实输入。若功能还要求清空属性或防止页面读取默认值,JS 部分仍有职责;评论编辑框、登录表单等有意义的占位提示不应一起隐藏。
  • 命中后的固定样式。 JS 需要计算长图比例或当前业务状态时,可以只输出标记和动态数值,把固定的 position、max-height、边框等放到样式文件。不过迁移内联 !important 前,要确认原站是否也有内联重要声明,以及动画和测量是否依赖写入时机。
  • 滚动条初始化。 已知内部容器可以先处理原生轨道,再由库挂载自绘层;但必须考虑初始化失败的回退,不能把所有滚动条永久隐藏。相关的滚动条首态与扩展接入边界需要单独验证。

另外一些改造没有对应的 CSS 替代:CSS 不会解码 Base64 业务元数据、改写外链目标、替换图片请求地址、处理加载失败回退,或生成干净的剪贴板内容。屏外渲染也要分情况;JS 测量高度、判断已读范围与 CSS 执行隔离,可以是合理配合,不必为了「优先 CSS」取消已有的滚动稳定性设计。

已经存在于 DOM 中的呈现条件,优先交给 CSS;尚需计算的状态和真实副作用,留在 JS。不要求把原本清楚的两个职责压成一条复杂选择器。

验证要覆盖脚本运行以前

只检查页面稳定后的截图,会漏掉这次问题。一个完整的回归至少应覆盖:

  1. 原始状态。 使用未带清理标记的 DOM,在不调用清理函数时检查目标规则、文本颜色和图标。
  2. 接续状态。 JS 移除 href 等识别字段后,清理标记仍能维持相同外观,重复运行不破坏节点结构。
  3. 排除状态。 普通链接、行内批注、相近 URL 和组件外的节点都不被提前覆盖;键盘与点击另行验证。
  4. 动态状态。 重新插入评论、切换 Tab、回写属性,并观察样式加载与库初始化前后的过程。

模拟 DOM 适合检查清理函数和大部分 selector/CSSOM 契约,但不承担浏览器绘制保证。遇到复杂选择器与真实引擎结果不一致时,应先做最小对照,再决定是测试环境缺口还是代码问题;不能为了全绿删去反例。

扩展还要完成生产构建、重新加载扩展,再刷新业务页。构建产物里能搜到选择器,只能证明它被打包了,不能证明用户当前页面已经使用新版本。

总结

这类改造可以先从原始 DOM 判断哪些外观条件已经齐备。CSS 提前加载、原始节点又能被可靠识别时,就能直接声明首态样式,消除等待 JS 标记的视觉窗口;URL 解析、属性清理和真实交互仍由 JS 完成。

因此,验收既要看脚本运行前的外观,也要看清理后的链接语义、键盘聚焦和事件行为,还要确认普通链接与批注没有被误伤。稳定后的截图只能覆盖其中一部分。消除视觉等待本身也不能证明 CPU、滚动或内存收益,这些仍需同条件测量。