不用怀疑,内容脚本是浏览器扩展开发里最实用、也最容易被误解的一块。它直接决定你的扩展“能不能看见页面”“能不能改页面”“什么时候能动手”。我见过不少新手,manifest 写好了、权限也配了,但一刷新页面发现脚本根本没执行,或者执行了却拿不到想要的数据,最后绕了一大圈才发现是注入时机和隔离模型的锅。这篇我就把内容脚本的注入、隔离、执行时机这三个核心问题一次性讲清楚,顺带把我实际排查问题时的思路也留给你,适合正在做 Chrome 扩展、或者从 MV2 迁到 MV3 后对内容脚本行为感到困惑的人。
1. 内容脚本到底是站在哪边的代码
1.1 一个脚本,两个世界
内容脚本本质上是一段由扩展注入到普通网页里的 JavaScript,但它又不是普通网页 JavaScript。它跑在页面里,却拥有一个独立的 JavaScript 执行环境,跟页面自己的脚本环境完全隔开。你可以把它理解成“带着工牌进入别人办公室的咨询顾问”:能翻阅桌面上的资料(操作 DOM),但不能直接打开对方的手机(访问页面的 JS 变量和函数),也不能顺便用办公室的座机打长途(调用后台扩展的全部 API)。
这个设计不是浏览器故意刁难,而是安全和稳定的需要。普通网页可能来自任何地方,上面的脚本有可能是恶意的;扩展则拥有更高权限。如果两者共用同一个 JS 环境,页面脚本就可以轻易篡改扩展逻辑,或者反过来,扩展不小心覆盖页面的全局变量,导致网页功能崩溃。所以浏览器强行把这两个世界隔开,只留下一扇门:共享的 DOM。
1.2 内容脚本的职权范围
搞清楚这个边界,很多困惑就迎刃而解了。内容脚本能做的事包括:
- 读取、修改、监听页面 DOM,包括样式、属性、文本内容。
- 监听页面事件,例如 click、scroll、keydown。
- 使用部分扩展 API,最常用的是
chrome.runtime.sendMessage、chrome.storage。 - 跨域请求一个特殊来源(扩展自己),不受页面同源策略限制。
内容脚本做不到的事,同样很重要:
- 不能读取或调用页面 JavaScript 里的变量和函数,比如页面里
window.app、window.$上的方法。 - 不能直接使用后台脚本的全部 API,比如不能直接调用
chrome.tabs.create(需要走消息通道)。 - 不能在不加处理的情况下把页面脚本产生的复杂对象直接传回扩展后台。
理解这层边界,你就知道为什么内容脚本和页面之间通信需要“曲线救国”了。相关细节我后面单独展开,这里先记住结论:DOM 是两边的交际层,JS 环境是隔离的。
2. 注入方式的选择:静态声明还是运行时注入
2.1 静态注入:manifest 里的 content_scripts
最常见的注入方式是打开manifest.json,在content_scripts字段里声明。MV3 下典型的配置长这样:
{ "manifest_version": 3, "name": "示例扩展", "version": "1.0.0", "content_scripts": [ { "matches": [ "https://example.com/*", "https://*.example.org/*" ], "js": ["content.js"], "css": ["content.css"], "run_at": "document_idle", "all_frames": false, "match_about_blank": true } ] }这里面有几个字段需要逐个理解。
matches决定哪些页面会被注入,注意https://*/*和<all_urls>的区别,前者需要明确的host_permissions,后者权限更宽,审核时也更扎眼。js声明的文件按数组顺序加载,如果你的内容脚本依赖另一个脚本里定义的函数,顺序错了会直接报错。
run_at控制注入时机,这个我后面用一整节细讲。all_frames控制是否注入到 iframe 子框架里;如果不了解页面内嵌 iframe 对你的功能有没有影响,建议保持false,否则可能所有广告 iframe 里都跑一份你的代码,又耗资源又难调试。match_about_blank则决定about:blank页面或由目标页面创建的 iframe 是否也被注入。
静态注入的优点是无脑可靠:只要匹配的页面一加载,扩展就会在指定时机注入脚本。缺点是它只会发生在文档加载阶段。如果用户已经在页面上停留了五分钟,你才想让脚本进去,静态声明帮不了你。
2.2 动态注入:chrome.scripting.executeScript
MV3 推荐用chrome.scripting取代旧版tabs.executeScript。动态注入的典型场景是:用户点击工具栏图标后,才对当前页面注入脚本。代码大概长这样:
// 从 popup 或者 background 脚本里调用 async function injectContentScript(tabId) { try { await chrome.scripting.executeScript({ target: { tabId: tabId, allFrames: false }, files: ["content-scripts/feature-only.js"], injectImmediately: false }); } catch (err) { console.error("注入失败", err); } } // 需要先拿到当前活动标签页的 id chrome.action.onClicked.addListener(async (tab) => { await injectContentScript(tab.id); });injectImmediately对应静态注入的run_at:为true时尽量早执行(约等于document_start),为false时稍晚一点。动态注入同样可以注入函数和参数,比如:
await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: (keyword) => { document.querySelectorAll("a").forEach((a) => { if (a.textContent.includes(keyword)) a.style.outline = "2px solid red"; }); }, args: ["下载"] });用func的方式能避免为一次临时操作专门建一个文件,但要注意函数体里面无法直接访问扩展作用域的变量,只能靠args传参。
2.3 预注入加动态补充的组合模式
实际项目里我经常用的是混合策略。核心逻辑配静态注入,保证页面一打开就有基础行为;耗资源的功能或只在特定条件下才需要的模块,用动态注入按需加载。例如一个阅读辅助扩展,高亮按钮和样式在页面加载时就挂好,但“全文导出”这种重逻辑,只在用户点按钮时才注入。
这个组合的好处有两点:启动更轻,不会每打开一个符合匹配的页面就被迫执行全部逻辑;出错面更小,动态注入的代码只会在真实需要时运行,出问题后影响范围可控。
2.4 注入配置里的常见坑
我见过最多的问题有三个。
一是忘了配host_permissions。MV3 里content_scripts.matches的匹配、chrome.scripting.executeScript的目标页面权限,都和host_permissions有关。你只写content_scripts而不写host_permissions,很可能在部分网站上报“无法访问该网址的内容”。
二是在频繁触发的事件里反复动态注入同一个脚本。每执行一次executeScript就会多注入一份脚本实例,重复绑定监听器,后果是事件响应一次变两次、三次,页面越来越卡。
三是all_frames: true没看清页面结构导致脚本在几十个 iframe 里同时执行。如果你只需要主框架,请明确写成all_frames: false。
3. 隔离机制:不是限制,是保护
3.1 为什么 window 不是同一个 window
页面里的window和内容脚本里的window指向的不是同一个对象。你在内容脚本里给window.foo = 1,页面的window.foo依然是undefined。这不是浏览器没有实现好,而是隔离设计的一部分。
想象两套 JavaScript 虚拟机分别跑在同一个浏览器进程的不同“容器”里,它们之间通过一个共享的 DOM 树通信。DOM 上的属性、事件、样式是两边都能看到的,对象模型是唯一的,但各自环境里的全局变量、函数对象、原型链各不相同。这个设计保证了网页脚本没法反过来篡改扩展代码,也让扩展不会因为页面里的Array.prototype被改掉而崩溃。
3.2 共享与隔离的边界表
为方便记忆,我把边界整理成一张表:
| 内容 | 是否共享 | 说明 |
|---|---|---|
| DOM 树结构 | 共享 | 两边操作的是同一个 DOM 节点对象 |
| DOM 事件监听 | 部分共享 | 在同一节点上绑定监听器,页面和内容脚本都能收到事件,但监听函数属于各自环境 |
| window 对象及其属性 | 隔离 | window.$、window.app在两边各自独立 |
| 全局函数、类、原型 | 隔离 | 页面原型链的篡改不影响内容脚本 |
| 扩展 API(chrome.*) | 不共享 | 只有扩展环境能调用 |
| 网络请求来源 | 隔离 | 内容脚本请求属于扩展源,不受页面 CORS 限制 |
CSS 方面要注意:content_scripts.css里定义的样式会直接作用到页面上,没有默认的样式隔离。如果你不希望自己的样式污染页面,比如按钮组件、弹窗样式,建议用 Shadow DOM 隔离,或者给所有选择器加一个独特前缀,再配合高优先级覆盖。
3.3 想访问页面里的变量怎么办
有时候你就是需要页面里某个变量的值,这属于扩展开发的“灰色地带”。有两个常见方案:
第一个是通过 DOM 自定义事件中转。扩展脚本往 document 上派发一个CustomEvent,里面带上要请求的数据键名;页面里的脚本监听这个事件后,把数据填进事件的detail,再派发一个新的自定义事件。两边虽然环境隔离,但共享 DOM,事件能穿过边界。这个方案适合页面已经引入你自己的脚本、并且你信任该脚本的情况。
第二个是注入一个<script>标签。内容脚本创建 Script 标签,设置textContent为一段代码,这段代码会以“页面脚本”的身份执行,可以直接读取页面变量,然后把结果放到某个 DOM 节点或全局属性上,内容脚本再通过轮询或事件拿到结果。
第二个方案更强大,但也更危险:注入到页面里的代码拥有页面权限,一旦被外部输入污染,可能导致 XSS。另外,扩展商店审核对这类“再注入”非常敏感,如果应用场景不清不楚,大概率会被打回。我的经验是:能不碰页面变量就不碰,优先想办法通过 DOM 取数据,实在不行再上注入方案,而且必须严格把注入内容的生成逻辑隔离好,不接受任何来自页面的原始字符串拼进代码。
4. 时机问题:脚本进去了,不等于能干活了
4.1 run_at 的背后含义
run_at接受三个值:document_start、document_end、document_idle。
document_start:CSS 加载之后,DOM 尚未构建、脚本尚未执行时注入。适合需要最早执行干预逻辑的场景,比如拦截请求、记录初始状态、替换页面行为。document_end:DOM 解析完成,但页面里的资源(图片、子 iframe)可能仍在加载时注入。document_idle:document的readyState变为complete之后再等一段时间才执行。这是默认值,也是多数场景下最稳妥的选项,因为此时 DOM 基本稳定,异步脚本也大多执行完。
很多人把document_idle理解成“页面完全加载完毕”,其实不完全准确。它更像“文档加载完成后、浏览器即将进入空闲状态时”。这两个时机对大多数扩展没什么差别,但如果你依赖页面某些很晚的异步操作来生成 DOM,可能还是会踩空。
如果你在document_start就操作一个尚未存在的元素,那就什么都拿不到。所以一个稳妥的内容脚本开头,通常会写成:
function init() { const node = document.querySelector("#app"); if (!node) { setTimeout(init, 200); return; } doRealWork(); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", () => setTimeout(init, 0)); } else { init(); }4.2 SPA 页面里的时机噩梦
单页应用(SPA)是内容脚本时机问题的高发区。SPA 通过 JS 切换路由,页面文档不会重新加载,所以内容脚本只在首次加载时注入一次。之后用户点链接跳转到了新页面,DOM 结构完全变了,你以为内容脚本会“再次执行”,但它不会。
解决办法一般是监控路由变化。SPA 的常见路由方式有两种:hash 路由和 history 路由。hash 变化可以通过hashchange事件监听;history 路由则需要包装history.pushState和history.replaceState,同时监听popstate:
const originalPushState = history.pushState; history.pushState = function (...args) { const result = originalPushState.apply(this, args); window.dispatchEvent(new CustomEvent("url-changed")); return result; }; window.addEventListener("popstate", () => { window.dispatchEvent(new CustomEvent("url-changed")); });然后在url-changed事件里重新扫描 DOM、重新绑定逻辑。更省事的做法是直接用一个MutationObserver观察页面主容器,只要里面的子节点发生大规模变化,就相当于“新页面加载了”:
const observer = new MutationObserver((mutations) => { // 这里做节流,否则路由动画期间会疯狂触发 onRouteMaybeChanged(); }); observer.observe(document.getElementById("app"), { childList: true, subtree: true });强烈建议加上节流或防抖,不然一个路由过渡动画期间,DOM 会被改几百次,你的回调也跟着跑几百次。
4.3 性能时机:别让注入拖慢首屏
把大脚本一次性注入到document_start会影响首屏渲染时间。内容脚本和页面脚本一样,都会争抢 JS 主线程的资源。我常用的策略是:
- 只把真正必须最早执行的小段逻辑放到
document_start。 - 主体逻辑放到
document_idle。 - 需要时才动态注入的大模块,留到
chrome.scripting.executeScript。
例如我的一个页面截图标注扩展,核心标注逻辑有几十 KB,但页面打开时真正需要做的只是判断“当前页面要不要显示标注按钮”,这段判断代码不到 2KB,放到document_start,按钮在document_idle再挂。这样首屏几乎没感知,按钮也足够快。
5. 从注入到交互:消息通道与 DOM 操作细节
5.1 内容脚本和后台脚本怎么对话
内容脚本和后台(MV3 里的 service worker)的通信靠chrome.runtime消息通道。一次性消息用sendMessage和onMessage就够了:
// 内容脚本侧 chrome.runtime.sendMessage({ type: "GET_TODAY_DATA" }, (response) => { if (chrome.runtime.lastError) { console.log(chrome.runtime.lastError.message); return; } console.log(response); });// 后台脚本侧 chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.type === "GET_TODAY_DATA") { fetchData().then(sendResponse); return true; // 表示异步响应,重要 } });MV3 里异步消息一定要return true,或者干脆写成async监听函数。否则你的异步fetch刚发出去,监听器已经结束了,sendResponse会变成无效调用。这个是我反复踩过的坑,尤其从 MV2 迁移过来的代码最容易漏。
5.2 页面交互中最常翻车的三个问题
第一是重复绑定事件。内容脚本重复注入或者 SPA 路由切换时重新绑定,但没有解除旧监听器,结果一次点击触发好几次。解决办法是保存旧监听函数引用并removeEventListener,或者在 DOM 节点上挂一个标记:
if (!el.dataset.extBound) { el.dataset.extBound = "1"; el.addEventListener("click", handler); }第二是直接改 DOM 导致页面框架状态失控。在 React 或 Vue 的页面里,你直接往某个组件里塞了一堆节点,框架下次重新渲染时可能把你加的东西直接清掉,或者反过来,框架触发了 update 后你的节点出现在奇怪的位置。更稳妥的做法之一是配合框架的自定义事件或数据接口,实在不行才用 DOM 操作,并且操作后尽量避免触发框架的更新区域。
第三是内容脚本里拿到的事件对象和页面里的事件对象不完全一致。最常见的就是event.isTrusted、event.composedPath(),某些页面自定义事件在内容脚本看来属性不完整。处理时先console.log(event.constructor.name),别误判事件来源。
5.3 大量数据的传法
内容脚本和后台之间传字符串和普通对象没问题,但传大量数据(比如几百 MB 的二进制流)会卡到怀疑人生。涉及大数据时优先考虑:
- 把数据写入
chrome.storage.local,让另一侧读取。 - 用
URL.createObjectURL生成临时 URL,再通过消息只传 URL 字符串。 - 长连接
chrome.runtime.connect,保持单一通道持续传输,减少频繁建立连接的开销。
日常场景里,chrome.storage.local往往是最省事的:写入一个对象,另一侧监听onChanged事件就能拿到最新值,天然解决消息丢失和乱序问题。
6. 万能的排查路径:内容脚本不生效时怎么办
6.1 从 manifest 开始查
内容脚本不生效,第一件事永远是看 manifest。我会按这个顺序检查:
content_scripts.matches是否覆盖目标网址。这是个匹配规则问题,不是“看起来像就行”,比如目标页是https://www.example.com/path,你的 matches 写的是https://example.com/*,那www子域名就匹配不上。host_permissions有没有对应的权限。尤其 MV3 里动态注入,缺了这个会直接报错。- 扩展是否处于“已加载”状态。开发模式下改完 manifest 后必须回扩展管理页点刷新,否则注入的还是旧版本。
run_at是否符合预期。如果脚本逻辑依赖一个很晚才出现的元素,而你用的是document_start,那大概率拿不到。
6.2 如何确认内容脚本真的执行了
Chrome DevTools 里打开目标页面,按 F12,切到 Sources 面板,左侧扩展目录下可以看到 Content scripts 列表,里面列出了所有注入当前页面的内容脚本。如果列表里没有你的脚本,说明还没注入。
如果列出来了但逻辑不执行,在内容脚本里插一行最显眼的console.log("[DEBUG] content script running"),然后刷新页面,在 Console 面板里筛选内容脚本来源。注意内容脚本的 log 和页面 log 在 Console 里混着显示,要看清楚来源再下结论。
后台 service worker 的日志要单独去chrome://extensions里点击你的扩展卡片上的“Service Worker”链接,那里能看到后台脚本的输出和报错。很多时候内容脚本本身没问题,是消息发到后台后后台报错没响应,结果前端看起来像“内容脚本不工作”。
6.3 安全检查优先级
最后必须强调安全习惯。内容脚本和页面共享 DOM,意味着页面内容有机会影响你的逻辑。如果你把页面上的文本直接插进innerHTML,可能触发 XSS,而 SOP 严格看待内容脚本里的操作。我的底线是:
- 所有来自页面的文本一律
textContent,非必要不用innerHTML。 - 自己生成的 HTML 要转义,不接受页面数据直接拼接。
- 不要信任页面发来的 URL,跳转前先校验协议白名单。
- 动态注入的脚本内容绝不允许包含任何未验证的页面输入。
这些习惯看着基础,但真出问题时都是事故级别的。尤其是内容脚本,它既有页面访问能力,又有扩展身份,一旦被利用,影响面比普通页面漏洞大得多。
如果你能把注入方式、隔离边界、执行时机这三件事想明白,再配合一套不靠猜的排查路径,内容脚本这块基本就没有能拦住你的坑了。