Content Scripts:让代码跑进网页(注入时机与隔离世界)

content script 是扩展伸进网页的那只手:它运行在网页上下文里,能读写 DOM、监听页面事件、把信息传回扩展。上一章油猴脚本做的事,在扩展里主要由它承担。这一篇讲清三件事:怎么注入、什么时候注入、它和页面之间隔着什么。

三种注入方式

静态声明最常用,适合「已知网站、自动运行」:

{
  "content_scripts": [
    {
      "matches": ["https://*.nytimes.com/*"],
      "css": ["style.css"],
      "js": ["content.js"],
      "run_at": "document_idle"
    }
  ]
}

动态注册用 chrome.scripting API 在运行时登记,适合匹配规则由用户配置决定的场景:

chrome.scripting.registerContentScripts([{
  id: "site-helper",
  js: ["content.js"],
  matches: ["*://example.com/*"],
  runAt: "document_start",
  persistAcrossSessions: false
}]);

配套有 getRegisteredContentScripts、updateContentScripts、unregisterContentScripts 三个方法,构成完整的生命周期管理。persistAcrossSessions: false 表示注册只在本次浏览器会话有效,重启后需要重新登记。

程序化注入响应用户动作才执行,配合 activeTab 权限是 MV3 最推荐的形态:

chrome.action.onClicked.addListener((tab) => {
  chrome.scripting.executeScript({
    target: { tabId: tab.id },
    files: ["content.js"]
  });
});

run_at:注入时机三档

取值 时机 适用
document_start DOM 尚未构建,任何页面脚本之前 抢先拦截、注入全局样式
document_end DOM 就绪,图片等子资源未加载 需要 DOM 但不等图片
document_idle document_end 与 onload 之间的浏览器自选时机 默认值,绝大多数场景

document_idle 是官方推荐默认:DOM 已完整,无需监听 onload,也不拖慢页面加载。需要更早接管时才降档到 start,并承担相应的兼容责任(此时连 document.body 都可能是 null)。

隔离世界:最重要的心智模型

content script 与页面共享同一个 DOM,但运行在两个互不可见的 JavaScript 世界里。官方称之为 isolated world。这意味着:

  • content script 看不到页面的 JavaScript 变量、函数、事件监听器;
  • 页面也看不到 content script 的变量;
  • 双方改的是同一个 DOM 树,所以通过 DOM 通信是可行的,通过 JS 变量通信不可能。

用官方文档的例子说明:页面代码给按钮绑了一个 alert,content script 给同一个按钮再绑一个 alert,点击时两个都会弹——事件处理器互不干扰但都在工作;而页面里写的 window.foo = 1,content script 读 window.foo 是 undefined。

这带来两个实践推论。第一,想 hook 页面的内部函数(比如重写 fetch),隔离世界做不到,需要把代码注入进页面的 MAIN world(通过 scripting API 的 world 参数或注入 script 标签),这是一条高级路径。第二,content script 修改 DOM 时要小心:页面可能有 MutationObserver 盯着,反复横跳的修改会引发竞态。

content script 能直接用什么

隔离世界的另一个切面是 API 受限。content script 能直接调用的扩展 API 只有少数几个:chrome.storage、chrome.i18n、chrome.dom,以及 runtime 命名空间的一部分(sendMessage、getURL、onMessage、connect 等)。tabs、scripting、notifications 这些都摸不到,需要就发消息让后台代劳——这正是下一篇消息通信的主题。

manifest 里 content_scripts 数组还有几个辅助字段:exclude_matches 排除特定页面;all_frames 控制是否注入 iframe;match_about_blank 处理 about:blank 框架。css 数组的注入时机早于任何 DOM 构建,所以纯样式改造用 CSS 文件比 JS 改 style 更早生效、不闪烁。

排错要点

content script 没生效,按序检查:matches 模式是否真的匹配当前 URL(把模式贴出来人工核对协议、主机、路径三段);扩展是否重新加载且页面是否刷新;DevTools Console 是否有注入报错。在目标页 DevTools 的 Sources 面板能看到 content script 源码,可正常下断点。调试全景见第五章 《调试技巧》 篇。URL 模式写法不熟的话,正则匹配写法可参考本站《正则表达式从入门到精通》:正则实战。

官方文档:https://developer.chrome.com/docs/extensions/develop/concepts/content-scripts

与页面通信的三条合法通道

隔离世界切断了 JS 变量共享,但实践中总有「需要和页面交换点信息」的场景。合法通道有三条,强度与风险递增:

通道一:DOM。 双方共享 DOM 树,content script 可以读页面写入的属性、监听页面触发的事件:

// 页面代码:document.dispatchEvent(new CustomEvent("page-ready", { detail: {...} }))
document.addEventListener("page-ready", (e) => {
  console.log("页面说它准备好了", e.detail);
});

CustomEvent 的 detail 可以携带 JSON 可序列化数据。这是最安全、最常用的通道,代价是要处理事件竞态(脚本注入可能晚于事件触发,需要页面配合重发或脚本主动询问)。

通道二:注入 MAIN world 代码。 通过 scripting API 的 world: "MAIN" 参数,把代码直接送进页面的 JavaScript 世界,此时可以读写页面变量、hook 页面函数。 MAIN 世界代码完全暴露在页面脚本面前,不能包含任何秘密(token、内部逻辑),只做「探针」和「触发器」。

通道三:web_accessible_resources。 把扩展内的 JS 文件暴露给页面,页面侧用 script 标签加载 chrome.runtime.getURL("inject.js")。MV3 要求在 manifest 声明 resources 与 matches 的映射。与通道二本质相同,只是代码来自扩展包。

三条通道的选择原则:能 DOM 就 DOM,确需 hook 页面逻辑才用 MAIN world,且把 MAIN world 代码量压到最小。

动态注册的适用边界

三种注入方式的取舍值得再固化一遍。静态声明:规则确定、写死即可,性能与时机最优。动态注册:规则由用户数据驱动(用户在设置页添加站点),或需要按登录状态启停。程序化注入:一次性动作,配合 activeTab,权限最小。

动态注册有一个容易忽略的坑:注册是持久声明而非一次性执行。persistAcrossSessions: true 的注册会跨浏览器重启保留,onInstalled 里反复 register 会因 id 重复报错——正确姿势是先 getRegisteredContentScripts 查询,已存在就 update,不存在才 register。

async function ensureRule() {
  const existing = await chrome.scripting.getRegisteredContentScripts();
  if (existing.some(s => s.id === "site-helper")) return;
  await chrome.scripting.registerContentScripts([{ id: "site-helper", js: ["content.js"], matches: ["*://example.com/*"] }]);
}

CSS 注入的专属技巧

改造页面外观时,优先用 manifest 里 content_scripts 的 css 数组而不是 JS 改样式,理由有三:注入早于 DOM 构建,不闪烁;不占 JS 执行时间;声明式更好审查。JS 层面只处理「需要计算或切换」的样式。写选择器覆盖站点样式时,善用 !important 与更高特异性的选择器链,但注意站点自身的样式也在进化,改版后要回归测试。

内容注入的排错和 URL 匹配是高频难点,正则基础不牢的话,正则匹配写法可参考本站《正则表达式从入门到精通》:正则实战。官方文档:https://developer.chrome.com/docs/extensions/develop/concepts/content-scripts

本章由 WorkBuddy 原创整理,API 行为依据 Chrome 扩展官方文档(Manifest V3)。