Chrome插件JS通信:Popup、Background、Content Script与页面注入
简介这份Chrome插件demo围绕扩展开发中最常见的跨脚本通信问题特别适合刚接触MV3或对消息机制不熟的JavaScript开发者。工程用极简结构演示了背景脚本、内容脚本与弹出窗口脚本之间的消息闭环内容脚本获取网页信息后通过chrome.runtime.sendMessage发给背景脚本背景脚本处理完再回传同时借助chrome.storage完成跨脚本数据共享整个流程覆盖事件监听、定时任务和弹出页交互等典型场景。压缩包共11个文件包括3个JS脚本、1个HTML弹出页、2个CSS样式、4个PNG图标和1个manifest.json清单整体仅43KB代码量少且无冗余依赖目录结构一目了然适合逐行阅读和二次修改。已有525人学习下载。通过这个demo可以快速理解sendMessage与onMessage的配对方式、popup与后台的通信写法也能学会在不同上下文间安全传递JSON消息从而少走弯路是搭建Chrome插件通信骨架的实用参考。 做 chrome 插件 demo 时新手最容易翻车的点不是界面布局而是“这些 js 明明都属于 JavaScript,互相之间却像隔着墙”。一个最小 demo 里至少存在弹窗页 popup 的 js、后台 service worker 的 js、注入页面的 content script 的 js再加上页面自己原有的 js。它们各有各的全局作用域window 上的变量和 localStorage 都不共享。这篇笔记就顺着 chrome 插件 demo 的常见开发路线把各类型 js 之间的通信方式拆开从最小配置讲到 postMessage 搭桥和避坑适合正在做 chrome 插件开发、demo 程序或页面注入功能的开发者直接照着改。2. 先搭最小骨架manifest 与四个 JS 环境各就各位动手写通信代码之前先让所有参与通信的 JS 环境在一个干净的 demo 里各就各位。常见做法是自己手写一份 MV3 的 manifest.json而不是从模板里复制这样后面哪条链路断了你能第一时间想起对应配置项。2.1 manifest.json 手写一遍MV3 的四个声明点一个能同时跑通 popup、background、content script、注入脚本的最小 manifest 长这样{ manifest_version: 3, name: js-message-demo, version: 1.0.0, minimum_chrome_version: 102, action: { default_popup: popup.html }, background: { service_worker: background.js }, content_scripts: [ { matches: [all_urls], js: [content.js], run_at: document_idle } ], web_accessible_resources: [ { resources: [injected.js], matches: [all_urls] } ] }决定“哪些 js 存在”的是四个字段action声明 popup.html 及其脚本background.service_worker声明后台脚本content_scripts声明注入到页面的脚本web_accessible_resources决定哪些文件可以被页面里的 script 标签继续引用。很多人做到注入脚本那一步时忘了在web_accessible_resources里登记 injected.js结果页面加载这个文件直接 404,排查半天查不到原因。参数说明run_at建议用document_idle表示 DOM ready 之后再执行适合大部分读取数据的场景document_start能尽早劫持页面函数但那时 DOM 节点还不全。matches在 demo 里用all_urls省事真实插件尽量收窄到https://前缀或具体站点否则在 chrome:// 页面上注入会直接报错。minimum_chrome_version不是必填但 MV3 的 Promise 风格 API 在旧版浏览器上有差异写 102 能少踩一半坑。2.2 四个 JS 环境的能力边界决定消息走哪条路在写任何sendMessage之前先把能力边界列清楚。这一节是整个通信方案的地基边界认知错了后面选的路就是错的。环境能访问页面 DOM能访问页面 window 变量能用 chrome 消息 API生命周期popup 页面脚本只能访问 popup 自己的 DOM不能runtime、tabs 可用打开才存在background service worker不能不能全部可用按事件唤醒可能休眠content script可以isolated world不能runtime、tabs 可用随页面加载和销毁injected script页面主世界可以可以不能用 chrome API随页面加载和销毁这张表基本决定了通信方向popup 和 background 之间用chrome.runtime.*popup 或 background 要发给某个页面的 content script用chrome.tabs.sendMessagecontent script 和页面自身 js 之间因为页面 js 拿不到 chrome API只能靠 DOM 事件或window.postMessage搭桥。injected script 虽然是插件注入的但它运行在页面主世界所以它和页面 js 可以互相访问只是同样不能用chrome.runtime.sendMessage。这里有一个常见的认知误区很多人以为 content script 就是“跑在页面里的脚本”应该和页面 js 是一家人。实际上 Chrome 给 content script 单独建了一个 isolated worldDOM 共享JS 全局对象隔离。这个设计是安全机制也正是通信需求最旺盛的地方第 4 章会专门处理它。2.3 给每个环境打独立 log 标记四个环境的日志最终都会出现在同一个 DevTools console 里只是来源不同。不标记的话你根本不知道某条消息到底是在哪一跳打印的。我一般会在每个脚本文件顶部这样写// content.js console.log([content], document.title); // background.js console.log([background], service worker alive); // popup.js console.log([popup], popup opened);如果是在页面主世界里调试还可以用console.log([page], ...)跟在 injected.js 里。这个习惯对后面排查“消息走到哪一跳才断”非常有用能直接把问题定位到某一个环境而不是四处乱猜。3. 跑通 popup ↔ background ↔ content scriptruntime 消息三件套通信骨架搭好之后先跑最常用的三条链路popup 发消息给 background、background 或 popup 发消息给 content script、以及异步响应的生命周期。核心就三个 APIchrome.runtime.sendMessage、chrome.tabs.sendMessage、chrome.runtime.onMessage。3.1 popup 给 background 发一条最小消息先确认消息结构、监听位置、回调三条边。常见做法是消息体统一用{type, payload}这样的格式type当路由用payload放业务数据。popup 里这样发// popup.js const btn document.getElementById(send); btn.addEventListener(click, async () { const response await chrome.runtime.sendMessage({ type: PING, payload: { from: popup } }); console.log([popup] 收到回应, response); });background 里这样收// background.js chrome.runtime.onMessage.addListener((message, sender, sendResponse) { console.log([background] 收到, message, 来自, sender); if (message.type PING) { sendResponse({ ok: true, reply: pong from background }); } });逻辑说明popup 发出的chrome.runtime.sendMessage会被所有扩展上下文里的onMessage监听器收到包括 background service worker 和 content script。demo 里要确保业务逻辑只在期望的监听器里应答所以要用type做一道过滤。参数说明sendMessage第一个参数传对象第二个参数传回调也可以不传回调直接等 Promise。MV3 里大多数方法支持 Promise但监听器那侧没有 Promise 风格它只认sendResponse。这是最容易翻车的地方下一节细说。sender参数里包含tab、frameId、url等来源信息多个监听器同时存在时它是判断消息从哪个环境来的主要证据。3.2 popup 或 background 精确发给 content scriptchrome.runtime.sendMessage是广播适合全局事件要精确发给某个标签页里的 content script得用chrome.tabs.sendMessage。在 popup 里获取当前标签页再发// popup.js const [tab] await chrome.tabs.query({ active: true, currentWindow: true }); const res await chrome.tabs.sendMessage(tab.id, { type: GET_TITLE }); console.log([popup] content script 回传, res);content script 这边监听// content.js chrome.runtime.onMessage.addListener((message, sender, sendResponse) { if (message.type GET_TITLE) { sendResponse({ title: document.title }); } });这里注意三个必调参数tab.id决定发给哪个标签页message是消息体第三个参数可以是回调函数也可以不传直接等 Promise。旧代码里常见chrome.tabs.sendMessage(tab.id, msg, callback)的写法我建议新 demo 统一用await处理起来更顺。还有一个容易忽略的点chrome.tabs.query({ active: true, currentWindow: true })拿到的tab.id在你点开 popup 的瞬间是固定的但页面一旦刷新content script 会重新注入消息发送时机要放在页面加载完成后。这个时序问题在第 5 章排查里会再见到。3.3 sendResponse 的生命周期同步返回、return true 与异步MV3 的 service worker 是事件驱动的消息监听器执行完如果 Chrome 判断事件已经结束worker 可能随时休眠。监听器里做异步操作时最常见的写法是// background.js chrome.runtime.onMessage.addListener((message, sender, sendResponse) { if (message.type PING_ASYNC) { fetch(https://example.com/demo) .then((r) r.json()) .then((data) { sendResponse({ ok: true, data }); }); return true; // 告诉 Chrome这个回调还没走完 } });逻辑说明return true的意思是“我要异步调用 sendResponse”。如果你不写它Chrome 在监听器函数返回后就把消息通道收掉了sendResponse仍然被调用但 popup 那边会一直 pending 或超时。这个坑在 MV3 下特别明显MV2 常驻后台页面不太容易触发。参数建议能用同步逻辑解决的 demo 就不要做异步如果必须异步记住三个字“return true”。很多现成代码库到现在还用 callback 风格写的chrome.runtime.sendMessage和 Promise 混用时注意不要重复调用sendResponse否则会报The message port closed before a response was received。4. 页面自身的 js 想掺和进来isolated world 与 window.postMessage 搭桥当你想读取页面里的 demo 程序挂出来的全局数据或者想让页面里的表格变化实时通知插件单纯靠 chrome 消息 API 已经不够了因为页面 js 没有chrome.runtime对象。这章讲清楚隔离世界和 postMessage 桥。4.1 为什么 content script 直接访问页面变量会翻车先看一个典型的失败场景。页面里有一段原有脚本// 页面原有脚本 window.__demoData { count: 42 };content script 里这样写// content.js console.log(window.__demoData); // undefined现象是页面上明明数据存在content script 里却是 undefined。原因是 Chrome 把 content script 放在 isolated world 里它和页面主世界共享 DOM但 JS 全局对象互相隔离。window.__demoData是页面主世界的数据content script 里的window并不是同一个 JS 环境里的window。这个隔离机制是安全设计但也意味着想拿页面数据得让数据主动“过桥”或者把一段脚本注入到主世界去读。4.2 用 window.postMessage 搭一条双向桥既然 JS 对象隔离DOM 上的事件却是共享的那么用window.postMessage做桥就是最直接的方法。页面自己的脚本这样发数据// 页面原有脚本 const data { count: 42, label: page-data }; window.postMessage({ source: page-bridge, payload: data }, *);content script 这样收// content.js window.addEventListener(message, (event) { if (event.source ! window) return; if (event.data event.data.source page-bridge) { console.log([content] 拿到页面数据, event.data.payload); } });逻辑说明第一个判断event.source ! window是为了过滤 iframe 或外部窗口传进来的消息第二个判断是业务路由避免页面其他 postMessage 干扰到插件逻辑。source字段相当于协议号我习惯把所有桥接消息都带上它后面加新消息类型时不用改判断逻辑。反向发送也是一样的。content script 往页面里发把source换成插件的标识页面里的脚本监听同一个message事件就能收到。注意做 demo 时为了省事可以把目标源写成*这是常见做法真实场景建议按页面站点收窄避免数据发给不相关的 iframe。4.3 页面 SDK demo 数据抠不出来时注入 script 标签postMessage 桥需要页面脚本配合。如果页面里有一个 demo 程序挂在window上又不愿意改页面代码那就得强制注入。常见做法是把 injected.js 声明到 web_accessible_resources然后从 content script 以 script 标签方式塞进去// content.js const script document.createElement(script); script.src chrome.runtime.getURL(injected.js); script.dataset.source js-message-demo; document.documentElement.appendChild(script);injected.js 在主世界执行可以直接读页面变量// injected.js const demo window.__demoData || window.demoSDK || null; window.postMessage( { source: injected-bridge, payload: demo }, * );为什么不能直接fetch(chrome.runtime.getURL(...))再eval因为chrome-extension://下的文件默认不暴露给页面必须先通过web_accessible_resources声明否则 script 标签加载会 404。这一步是很多人卡住的地方。注入之后injected.js 其实就算页面主世界的一份子它可以调用页面的全局函数也可以被页面反过来调用但不能再使用chrome.runtime.sendMessage它需要把结果通过postMessage送回 content script再由 content script 用runtime.sendMessage转发到后台。很多真实插件的数据采集链路就是这个结构injected.js 读页面数据 → postMessage 到 content.js → runtime 消息到 background → 再决定要不要存 storage。5. 各类型 js 通信避坑5 条故障的现象、原因与处理这一章不是理论是实打实会遇到的坑。每一条我都按“现象 → 原因 → 解决”写你可以直接对照自己的场景排查。5.1 刷新页面后 tabs.sendMessage 报 Receiving end does not exist现象第一次打开插件点按钮能收到回复刷新页面或新开标签页后再点击就报Could not establish connection. Receiving end does not exist.原因content script 是页面加载时注入的页面刷新那一刻旧的注入环境已经销毁新的还没完全初始化。popup 拿到的tab.id虽然还是同一个标签页但消息发送时 content script 没有就绪于是找不到接收端。解决发送前先做“握手”。popup 先发一个PING试探如果抛异常就给用户提示先刷新页面或者在背景页里缓存一个“content script ready”状态。不要用setTimeout硬等那是玄学。5.2 sendResponse 在异步之后调用却收不到回应现象background 里做了fetch或读 storage数据拿到后调用sendResponse(data)但 popup 那边await一直 pending最终超时。原因MV3 的 service worker 生命周期比想象中短。监听器函数本身一旦返回没有return true消息通道就先关了。后面sendResponse虽然不报错但实际已经无效。解决在监听器里遇到异步分支函数末尾写return true。还有一个隐藏点同一个消息如果被多个监听器处理只有一个能调用sendResponse第二个再调用会报错所以要确保业务逻辑只在一条分支上应答。5.3 消息被处理两次页面数据重复现象点击一次按钮content script 两遍收到同样的消息页面可能弹了两次提示。原因常见的是 content script 在多个 frame 里各注入了一份。如果一个页面有主框架和 iframematches下的 content script 会默认注入所有 iframe每个 frame 的onMessage都会独立收到消息。也可能是插件在chrome://extensions里被重复加载。解决给消息加上 frame 限制。用chrome.tabs.sendMessage(tab.id, msg, { frameIds: [0] })只发给主帧或者在 content script 监听器里判断if (window.top ! window) return;跳过 iframe。排查时先打开chrome://extensions看有没有重复实例别急着改代码。5.4 content script 里读 window.__demoData 是 undefined现象页面上手写了一个window.__demoData {...}content script 用window.__demoData读取结果 undefined。原因isolated world 隔离content script 的window不是页面主世界的windowDOM 是同一份JS 对象不是同一份。解决按第 4 章的方案用postMessage桥或者注入脚本。如果只是在 DevTools 里临时调试可以切到 console 的上下文下拉框选页面主世界那里能直接访问但这是调试技巧不是插件 runtime 里的能力。5.5 排查顺序与日志标注习惯遇到通信断链按这个顺序查而不是从头到尾读代码先确认每个环境都打印了带标记的日志确定消息走到哪一跳才断。检查消息体有没有type路由监听器有没有明确匹配监听器里没匹配就静默 return看不出任何问题。查看chrome://extensions里 service worker 是否报错MV3 的background.js崩溃会断掉整条链路。如果用的是 callback 风格 API检查有没有处理chrome.runtime.lastError否则异常会被静默吞掉。最后才怀疑事件顺序、iframe 注入、worker 休眠这类状态问题。这条排查顺序是我自己的习惯多数情况前两步就能定位问题。日志标记那一节要舍得用别节省那几个字符。提示MV3 的 service worker 调试入口在chrome://extensions里点击对应插件的“service worker”链接打开的 DevTools 能看到 background.js 的实时日志。6. 从短消息到长连接把 demo 升级成可用的通信方案消息通信跑通后下一个需求通常是持续推送。比如页面 demo 程序每秒上报一次数据短消息就会有大量 handshake 开销这时候改用长连接更稳。6.1 chrome.runtime.connect 长连接最小改法content script 或 popup 里建立连接// content.js const port chrome.runtime.connect({ name: page-channel }); port.postMessage({ type: WATCH, target: __demoData }); port.onMessage.addListener((msg) { console.log([content] 长连接收到, msg); });background 里响应// background.js chrome.runtime.onConnect.addListener((port) { console.log([background] 连接建立, port.name); port.onMessage.addListener((msg) { port.postMessage({ ok: true, echo: msg }); }); });参数说明connect({ name: page-channel })里的name用来标识业务通道一个插件可以同时有多条连接port.postMessage走的是同一条消息隧道的持续链路没有sendResponse那套生命周期问题比较适合持续上报数据的场景。长连接的坑是忘记disconnect。页面刷新或 popup 关闭后残留连接会在 background 侧累积时间长了 service worker 内存涨上来性能问题就来了。常见做法是在window.onunload或页面销毁时调用port.disconnect()。6.2 用一张通信矩阵验证 demo写完所有链路我会用一张矩阵验收而不是凭感觉点按钮因为 popup 开合、页面刷新、worker 休眠这些状态组合才是真正的考验。发送方接收方路径常见失败点popupbackgroundruntime.sendMessageworker 休眠backgroundcontent scripttabs.sendMessage页面未注入content scriptbackgroundruntime.sendMessagesender 指向 tab没有 popup 上下文content script页面 jswindow.postMessagesource 过滤写错页面 jscontent scriptwindow.postMessage未监听 message 事件注入脚本content scriptwindow.postMessageweb_accessible_resources 漏配实际改这一版 demo我一般会把“健康检查”和“数据上报”两条链路先跑通再补业务消息。单条链路验证通过不能说明整合后一定通因为页面上可能同时存在 iframe、页面自身也在用 postMessage、background 可能被休眠唤醒各种状态组合才是真正考验。最后说一句不算客套的教训这类 demo 我交过不少最费时间的都是消息格式和生命周期。把第 3 章的return true和第 5 章的排查顺序吃透能省下大量跟玄学战斗的时间。希望帮到你。本文还有配套的精品资源点击获取