ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Chrome插件MV3消息通信实战:打通Content Script、Background与Popup

Chrome插件MV3消息通信实战:打通Content Script、Background与Popup 简介Chrome插件开发中背景脚本、内容脚本、弹出窗口脚本与选项页脚本运行在各自隔离环境里如何安全高效地跨脚本通信是扩展开发的核心难点。这份demo面向已掌握JavaScript基础、希望理解Chrome扩展消息机制的开发者围绕V3规范演示了chrome.runtime.sendMessage、chrome.runtime.onMessage以及chrome.storage的实际用法包含弹出窗口、背景脚本、内容脚本及对应样式文件可快速搭建最小可运行示例。资源共11个文件含3个js脚本、2个css样式、1个html页面、1个manifest配置及4个图标png整体压缩包仅43KB结构精简便于对照学习。目前已有525人学习/下载。通过该示例可直观看到不同脚本间如何发送和接收JSON消息、如何用存储API共享网页信息并理解MV3隔离机制下的通信模型适合作为入门Chrome插件通信的动手素材。1. Chrome 插件里的 JS 通信五种环境各说各话demo 就是用来打通这个的写 Chrome 插件踩坑最多的不是功能逻辑而是脚本之间对不上话。内容脚本从网页里拿到了数据背景脚本拿不到弹出窗口想读点东西发现另一个沙箱里压根没有这个变量。这个chrome-ctx-msgdemo 就是在解决这个问题它把背景脚本、内容脚本、弹出窗口脚本之间的消息通道跑了一遍让你看到 V3 版本下 JS 通信的标准姿势。适合两类人刚接触插件开发、被各种沙箱隔离搞晕的新手以及想在 MV3 里快速搭一套通信骨架的熟手。先把这个 demo 的 manifest 和脚本结构捋清楚后面的事就好办了。2. 五种 JS 环境的隔离边界先看 manifest 再定通信方案2.1 manifest.json 是通信方案的蓝图Chrome 插件的所有脚本环境都写在manifest.json里。这个 demo 的文件清单里有manifest.json、background.js、content_script.js、popup.js典型结构长这样{ manifest_version: 3, name: chrome-ctx-msg, version: 1.0, action: { default_popup: popup.html }, background: { service_worker: background.js }, content_scripts: [ { matches: [all_urls], js: [content_script.js] } ], permissions: [storage], icons: { 16: icon_16.png, 32: icon_32.png, 48: icon_48.png, 128: icon_128.png } }content_scripts里的matches控制内容脚本注入哪些页面all_urls表示所有页面实际开发时建议收紧到具体站点避免权限过大。background.service_worker在 MV3 里是一个独立的 Service Worker不再是 MV2 那种常驻页面。action.default_popup指定点击图标后弹出的页面。permissions里的storage是这个 demo 通信方案的基石后面会讲到为什么需要它。这个文件决定了哪些脚本存在、以什么方式存在也就决定了通信要走的路径。看一个插件源码第一步永远是打开 manifest.json它能告诉你这个插件的通信格局是什么。2.2 三类脚本的职责与生存周期背景脚本是整个插件的核心负责事件监听、定时任务、跨脚本消息中转。在 MV3 里它是 Service Worker意味着它不是常驻内存的——空闲一段时间就会被回收下次事件触发时再唤醒。这是个非常重要的边界很多人第一次从 MV2 迁到 MV3 翻车就在这里。内容脚本注入到网页里能操作 DOM但运行在一个 isolated world隔离世界里。它和页面本身的 JavaScript 完全隔离拿不到页面里的全局变量也访问不到页面加载的其他库。它唯一能碰到的就是 DOM 结构和一部分浏览器 API。这就是为什么内容脚本要发消息给背景脚本让背景脚本去处理数据。弹出窗口脚本活在popup.html里用户点击图标时才创建关闭弹出窗口就销毁。它的生命周期极短不适合做长时间状态维护。选项页面脚本同理但存活时间通常比 popup 长一些。这几种环境的隔离是 Chrome 的安全设计不是 bug理解这一点才能设计出合理的消息流。2.3 MV3 对通信边界的影响MV3 把所有脚本进一步隔离背景脚本从常驻页面变成 Service Worker全局变量不再是可靠的状态存储。这个 demo 里用chrome.storage做跨脚本数据交换就是绕开全局变量这个不稳定因素的常见做法。组件通信的思路在 React、Vue 里是父子传值、事件总线在 Chrome 插件里完全是另一回事。这里没有共享的作用域没有全局事件总线唯一靠谱的通道就是显式的消息传递和存储读写。把插件里的几个脚本想成几个独立的微服务它们之间只通过 API 对话这样理解起来就顺畅多了。3. 消息通道怎么选runtime、storage、长连接的使用边界3.1 runtime.sendMessage 与 onMessage一请求一响应的最短路径chrome.runtime.sendMessage是插件内部通信最常用的通道适合一次请求一次响应的场景。内容脚本把数据发给背景脚本背景脚本处理后回执路径最短。demo 里内容脚本抓取页面数据后就是用这个 API 发给背景脚本的// content_script.js const metaDesc document.querySelector(meta[namedescription]); const pageData { title: document.title, description: metaDesc ? metaDesc.content : , url: location.href, timestamp: Date.now() }; chrome.runtime.sendMessage({ type: PAGE_DATA, payload: pageData }, (response) { console.log(内容脚本收到回执, response); });sendMessage的第一个参数是消息体必须是可以 JSON 序列化的对象。第二个参数是回调函数接收背景脚本的响应。注意这个回调是异步的如果发送失败比如背景脚本没注册监听回调会收到 undefined。背景脚本这端用chrome.runtime.onMessage接收// background.js chrome.runtime.onMessage.addListener((message, sender, sendResponse) { if (message.type PAGE_DATA) { const { title, description, url, timestamp } message.payload; sendResponse({ status: ok, receivedAt: timestamp }); } else { sendResponse({ status: ignored }); } });message是发送过来的消息体sender包含发送方的信息比如sender.tab能拿到是哪个标签页发来的sendResponse是回传响应的函数。这个 API 的坑在于如果监听器里有异步操作sendResponse必须在异步操作完成后调用并且监听器要返回true告诉 Chrome 保持消息通道打开。后面避坑章节会细说。3.2 chrome.storage 当消息总线什么时候绕道读写更合适有些场景不适合直接发消息。比如内容脚本抓到的数据背景脚本过一会儿才需要或者 popup 关了再开还想读到上次的数据。这时候chrome.storage更合适——它天然是一个跨脚本共享的存储层任何脚本都能异步读写。demo 里背景脚本收到内容脚本的数据后没有直接把数据留在内存里而是写进了chrome.storage.local。这样 popup 打开时可以直接从 storage 读不用等背景脚本转发// background.js chrome.runtime.onMessage.addListener((message, sender, sendResponse) { if (message.type PAGE_DATA) { chrome.storage.local.set({ lastPageData: message.payload }, () { sendResponse({ status: ok }); }); return true; } });// popup.js chrome.storage.local.get(lastPageData, (result) { const data result.lastPageData || {}; document.getElementById(page-title).textContent data.title || 暂无数据; });storage.local的数据在浏览器重启后保留适合做持久化。如果只是临时会话数据MV3 还提供了chrome.storage.session生命周期和浏览器会话一致Service Worker 被回收再唤醒也能读到。用 storage 做消息总线的好处是解耦发送方只管写接收方按需读不需要双方同时在线。3.3 Port 长连接与 tabs.sendMessage第二梯队的选择有些场景需要持续通信比如内容脚本要频繁上报页面状态或者监听页面里的某个变化。每次都sendMessage会重复建立握手效率不高。这时可以用chrome.runtime.connect建立长连接// content_script.js const port chrome.runtime.connect({ name: page-watch }); port.postMessage({ type: START_WATCH }); port.onMessage.addListener((msg) { console.log(长连接收到背景脚本消息, msg); });// background.js chrome.runtime.onConnect.addListener((port) { port.onMessage.addListener((msg) { if (msg.type START_WATCH) { port.postMessage({ type: WATCHING, message: 已开始监听 }); } }); });还有一种情况是背景脚本需要主动向某个标签页的内容脚本发消息要用chrome.tabs.sendMessage并指定标签页 id// background.js chrome.tabs.query({ active: true, currentWindow: true }, (tabs) { const tabId tabs[0].id; chrome.tabs.sendMessage(tabId, { type: GET_PAGE_INFO }, (response) { console.log(来自内容脚本的响应, response); }); });第二梯队的通道不是必须的但如果你的插件涉及持续监听或主动拉取选对通道能少踩很多坑。单次交互用sendMessage数据共享用storage持续通信用connect主动下发用tabs.sendMessage。4. 在 chrome-ctx-msg 里跑通一次完整通信content 到 background 再到 popup4.1 内容脚本抓取数据并发送这个 demo 的完整链路是内容脚本在网页加载后抓取数据发给背景脚本背景脚本存入 storage弹出窗口打开时从 storage 读取展示。先看内容脚本这端核心代码就是抓取页面数据并调sendMessage。// content_script.js function collectPageData() { const metaDesc document.querySelector(meta[namedescription]); return { title: document.title || , description: metaDesc ? metaDesc.content : , url: location.href, timestamp: Date.now() }; } chrome.runtime.sendMessage( { type: PAGE_DATA, payload: collectPageData() }, (response) { if (response response.status ok) { console.log([chrome-ctx-msg] 数据已交给背景脚本, response.receivedAt); } } );collectPageData从页面 DOM 里取meta[namedescription]的值拿不到就返回空字符串。location.href在内容脚本的隔离环境里是可读的这个没问题。timestamp用于后续排查看数据是不是最新的。4.2 背景脚本接收、存储、回执背景脚本在 MV3 里是 Service Worker收到消息后把数据写进chrome.storage.local同时给发送方回执。因为chrome.storage.local.set是异步的监听器必须返回true才能保证sendResponse在写入完成后还能回调到内容脚本那里。// background.js chrome.runtime.onMessage.addListener((message, sender, sendResponse) { console.log([chrome-ctx-msg] 收到消息, message.type, 来自标签页, sender.tab ? sender.tab.id : 未知); if (message.type PAGE_DATA) { chrome.storage.local.set({ lastPageData: message.payload }, () { sendResponse({ status: ok, receivedAt: message.payload.timestamp }); }); return true; } sendResponse({ status: ignored }); });sender.tab.id能拿到发送方所在标签页的 id排查问题时很有用能确认消息到底是从哪个页面发出来的。chrome.storage.local.set的第二个参数是写入完成后的回调在这里调用sendResponse是安全的异步时机。4.3 弹出窗口读取并展示popup 这端要做一个用户能看到的界面。demo 的popup.html里有标题、描述和 URL 的展示字段popup.js负责从 storage 里把数据捞出来渲染上去。// popup.js document.addEventListener(DOMContentLoaded, () { const titleEl document.getElementById(page-title); const descEl document.getElementById(page-description); const urlEl document.getElementById(page-url); chrome.storage.local.get(lastPageData, (result) { const data result.lastPageData || {}; titleEl.textContent data.title || 当前没有已抓取的数据; descEl.textContent data.description || 访问任意网页后内容脚本会自动抓取信息; urlEl.textContent data.url || ; }); });popup 每次打开都是全新创建所以DOMContentLoaded里读 storage 是最常见的方式。不用等背景脚本主动推送因为 popup 关闭再打开背景脚本可能已经被回收了直接读 storage 最省事。4.4 验证闭环加载未打包扩展看完整链路跑通这个链路分三步。打开 Chrome 的扩展管理页面开启右上角的开发者模式点「加载已解压的扩展程序」选中有manifest.json的目录。然后访问任意网页按 F12 打开开发者工具在 Console 里能看到内容脚本打印的「数据已交给背景脚本」。接着点浏览器工具栏里的插件图标弹出窗口里应该能看到刚才访问页面的标题和描述。如果 popup 里没有数据先点开扩展详情里的「Service Worker」链接查看背景脚本的控制台那里会显示「收到消息」。三条 Console 分别把内容脚本、背景脚本、popup 三个环境的状态暴露出来任何一段链路断了都能定位出是哪一层的问题。这个 demo 的妙处在于它把数据存到了 storage 而不是只在内存里传一遍。因为 MV3 的 Service Worker 随时可能休眠如果背景脚本收到数据后只放在变量里休眠再唤醒数据就丢了。实测做法是把数据落盘到chrome.storage.local再清理掉内存引用这样任何环境在任何时间都能读到。5. 通信踩坑实录MV3 里最容易翻车的五个问题5.1 Service Worker 休眠导致全局数据丢失现象背景脚本里定义一个全局变量存数据内容脚本发消息写入过一会儿 popup 去读发现是空的。调试时把 Service Worker 面板打开能看到「Service worker 已停止」的状态。原因MV3 的 Service Worker 不是常驻的几十秒空闲就会被浏览器回收全局变量随之清除。这不是代码逻辑问题是生命周期设计变了。MV2 里背景页面常驻全局变量是可靠的MV3 里这套行不通了。解决所有需要跨脚本共享的数据都用chrome.storage存。临时数据用chrome.storage.session需要持久保留的用chrome.storage.local。从那以后我写背景脚本第一件事就是先问自己「这个变量能活过 Service Worker 休眠吗」活不过就直接换 storage。5.2 sendResponse 在异步回调里失效现象监听器里调了接口或读写 storage在回调里执行sendResponse内容脚本那边收到的是 undefined。错误信息通常不报但数据就是没了。原因onMessage监听器默认是同步的sendResponse一旦执行完消息通道就关闭了。如果监听器里有异步操作sendResponse被推迟到异步回调里此时通道可能已经关闭回调函数执行了但没人接收。解决监听器里一旦有异步操作必须返回true明确告诉 Chrome「这个通道我还要用等我异步操作完成后再响应」chrome.runtime.onMessage.addListener((message, sender, sendResponse) { if (message.type PAGE_DATA) { chrome.storage.local.set({ lastPageData: message.payload }, () { sendResponse({ status: ok }); }); return true; } });这是一个靠return true显式声明异步的场景。如果监听器内是纯同步代码不需要返回true但为了统一我一般会全部返回true省得后面加了异步逻辑又踩坑。5.3 内容脚本读不到页面里的全局变量现象页面里有一个window.someData内容脚本里想读结果undefined。页面里加载的库比如 jQuery在内容脚本里也调不到。原因内容脚本运行在 isolated world和页面主世界共享 DOM但不共享 JavaScript 变量。这是 Chrome 的安全隔离目的是防止页面脚本污染插件代码也防止插件代码干扰页面。解决如果一定要拿页面变量有两个思路。一是让内容脚本把需要的值嵌到 DOM 属性里再用document.querySelector读二是通过script标签向页面注入一段临时脚本脚本把数据通过自定义事件传给内容脚本。常见做法是// 页面注入脚本把数据挂到 DOM 上 const injectedScript document.createElement(script); injectedScript.textContent document.documentElement.setAttribute(data-page-lib-version, window.libVersion || ); ; document.documentElement.appendChild(injectedScript); injectedScript.remove(); // 内容脚本再读 DOM 属性 const version document.documentElement.getAttribute(data-page-lib-version);避免在生产环境过度用这种方式能用 content script 直接读 DOM 的场景尽量用 DOM 读script注入是最后手段。5.4 消息体里带 Date 或函数序列化翻车现象消息发送方传的是带Date对象的对象接收方拿到的是字符串。传了一个函数进去接收方直接拿到 undefined。复杂一点循环引用的对象会直接抛异常。原因消息传递走的是结构化克隆structured clone或者 JSON 序列化。Date会被转成字符串函数会被丢弃循环引用的对象无法序列化。解决消息体里只传纯数据对象所有字段都保证能被 JSON 序列化。时间戳用Date.now()的数字形式不用Date对象函数调用放在发送方本地处理只传结果不传函数嵌套对象检查是否有循环引用。检查代码时要多一步自问这个对象到对方手里会变成什么样。5.5 popup 关闭导致长连接中断现象用chrome.runtime.connect建立长连接popup 这边连接挂在onload之后用户一关 popup背景脚本那边就报错或者收不到后续消息。原因popup 的生命周期和 UI 绑定用户关闭弹出窗口的瞬间popup 里的 JavaScript 环境就被销毁了之前建立的 port 也随之断开。长连接没有意识和预期中那么稳定。解决对于 popup 和背景脚本之间的通信不要依赖长连接。popup 每次打开时按需读 storage或者发一次sendMessage请求数据拿到就展示拿不到就显示空态。需要背景脚本长时间保持实时数据那就把数据源放在 storage 里popup 每次打开直接读背景脚本只负责更新 storage不做实时推送。6. 把 demo 改造成自己的通信中枢两个可以直接上手的加固技巧第一个技巧是给消息加统一信封。demo 里的消息体是{ type, payload }实际项目里这个结构不够用。我一般会扩展成{ type, payload, requestId, source }requestId用于追踪一次请求对应哪次响应source标记消息来自哪个脚本环境。背景脚本里写一个分发器按type路由到对应处理函数// background.js const handlers { PAGE_DATA: (payload, sender) { console.log([分发] 页面数据来自标签页 ${sender.tab ? sender.tab.id : unknown}); return chrome.storage.local.set({ lastPageData: payload }).then(() ({ status: ok })); }, GET_LAST_DATA: () chrome.storage.local.get(lastPageData) }; chrome.runtime.onMessage.addListener((message, sender, sendResponse) { const messageType message.type; if (!handlers[messageType]) { sendResponse({ status: error, message: 未知消息类型: ${messageType} }); return; } handlers[messageType](message.payload, sender) .then(sendResponse) .catch((err) sendResponse({ status: error, message: err.message })); return true; });把处理逻辑集中到handlers对象里新增消息类型只需要加一个函数不用改监听器结构。排查问题时直接在分发器里打一行日志所有消息的流向一目了然。第二个技巧是调试日志开关。开发阶段把每个环境里收到的消息、写入的 storage key、回调的结果都打出来但发布前要一键关闭。用chrome.storage.local里存一个debug标志写一个小工具函数// 每个脚本里都引用的 logger.js const DEBUG_KEY debug_enabled; function getDebugFlag(callback) { chrome.storage.local.get(DEBUG_KEY, (result) callback(!!result[DEBUG_KEY])); } function log(tag, ...args) { getDebugFlag((enabled) { if (enabled) { console.log([chrome-ctx-msg:${tag}], ...args); } }); }调log(content, 发送数据, pageData)的时候数据量大时会正常打印用户关掉调试开关后生产环境不会漏出任何日志。相比手动删console.log这个方式后期维护成本低很多。这个 demo 给我的最大教训是写插件通信先画图再写代码。把「谁发给谁、回不回应、数据落在哪」三件事先在纸上写清楚再动手写消息体和监听器。因为 MV3 的每个脚本环境生命周期都不同消息通道的选择会直接影响代码结构。从那以后我每次写新的通信链都强制走一遍这个流程画发送方、画接收方、画存储位置最后才是写代码。希望帮到你。本文还有配套的精品资源点击获取
返回列表