ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Chrome插件JS通信实战:打通popup、content script与页面JS的隔离壁垒

Chrome插件JS通信实战:打通popup、content script与页面JS的隔离壁垒 简介一份讲解 Chrome 插件多 JavaScript 环境间通信的演示压缩包适用于初步接触扩展开发的前端工程师或浏览器插件学习者。资源聚焦 background、content、popup、options 等脚本之间的消息传递与数据共享帮助理解 V3 版本下的隔离机制。包内共 11 个文件包括 3 个 js 脚本、2 个 css 样式、popup 页面、manifest 配置及图标文件压缩后约 43KB结构精简可直接对照阅读。已有 525 人学习。借助该 demo 可掌握 chrome.runtime.sendMessage、onMessage 与 chrome.storage 的典型用法梳理内容脚本与后台脚本的通信链路适合作为入门浏览器扩展通信机制的动手参考。1. 一个 chrome插件demo 先讲清楚这里到底有几套JS在互相看不见“chrome插件demo”这个主题看起来入门但真正打开编辑器后你才会发现一个插件里至少有三种JS谁也不认识谁。popup里的js、background里的js、content script里的js再加上页面自己跑的js它们各自拥有独立的全局对象、独立的生命周期甚至对同一个DOM的观察方式都不一样。标题里的“通信”指的就是把这几个隔离的世界用消息串起来。这篇笔记会从一个最小demo出发把四种环境的边界、两条主干API、一条完整链路和常见翻车点一次讲清适合刚跑通Hello World、卡在“浏览器插件如何调用页面js函数”的开发者。2. 插件里四种JS环境从隔离边界到消息传递的两条主干2.1 四种JS环境与它们的隔离边界写插件的第一件事不是写代码而是分清“你现在写的这段JS到底跑在哪个壳里”。同一个插件里最常见的四种JS环境分别是backgroundMV3里是一个service worker、popup页面、content script、页面自身的window上下文。很多人一开始只把它们当成“四个文件”其实它们是四个隔离的小宇宙。JS环境全局对象能否操作页面DOM可用的chrome API生命周期background / service workerself不能几乎全部扩展API按事件唤醒空闲休眠popup / options 页面window只能碰自己的页面DOM几乎全部扩展API打开时存在关闭即销毁content script隔离的window能但用的是扩展视角的DOM少量API如runtime、storage随页面加载而注入页面自身的JSwindow能但那是页面自己的世界完全没有随页面加载、销毁这里最容易让新手崩溃的是content script。表面上它和页面共享同一个文档但它运行在Chrome的isolated world里意思是你能看到页面的DOM能改样式、能加按钮但你访问不到页面里的全局变量也调用不到页面里定义的函数反过来页面也看不见你在content script里定义的变量。所以“浏览器插件如何调用页面js函数”这个问题标准的答案是不能直接调用。你只能通过消息通道把需求递过去页面JS收到后再自己执行。这听起来很不方便但这是Chrome刻意设计的隔离模型它保证页面里的恶意脚本没办法通过插件越权。2.2 为什么“各个类型的JS之间通信”是个真问题既然四个环境各过各的那它们之间怎么协作答案是消息传递。Chrome插件体系里消息传递有两条主干chrome.runtime.sendMessage和chrome.tabs.sendMessage。这两条API的方向完全不同搞混了就是你排查一下午玄学bug的起点。chrome.runtime.sendMessage是“扩展内部广播”它可以把消息发给所有注册了onMessage监听的扩展环境包括background、popup、options页面。content script也能调用它把消息发给background。它的特点是不指定接收方谁监听谁收到。chrome.tabs.sendMessage是“扩展主动发给某个标签页里的content script”必须传入tabId。它的特点是精确投递只发给一个标签页里的content scriptpopup和background都能调用它。这两条API的方向用一句话概括扩展页面之间用runtime扩展发给内容脚本用tabs。页面JS想要参与进来则必须通过content script中转。整个通信模型并不复杂但在你动手写demo之前先把这张“消息地图”在脑子里过一遍后面查起来会省很多时间。3. 把最小 demo 跑起来manifest 与 background/content 的第一次握手3.1 demo 目录结构与 manifest.json 关键字段这个demo的目标很单纯让popup发一条消息经过background中转由content script确认收到再返回一个应答。先把文件结构搭好四个文件足够跑通最小闭环。js-comm-demo/ ├── manifest.json ├── background.js ├── content.js ├── popup.html └── popup.jsmanifest.json是插件的身份证也是Chrome理解插件结构的唯一起点。这里用Manifest V3MV3来写因为从2023年起Chrome已经逐步停止加载MV2扩展新写的demo没必要再回头用旧规范。{ manifest_version: 3, name: js-communication-demo, description: 演示插件内多种JS环境之间的消息传递 demo, version: 1.0.0, action: { default_popup: popup.html }, background: { service_worker: background.js }, content_scripts: [ { matches: [all_urls], js: [content.js], run_at: document_idle } ], permissions: [activeTab, scripting] }这里有两个关键选择。第一content script用matches: [all_urls]声明注入所有页面这是最简单的做法但代价是它只在“页面加载”时注入已打开的页面不会凭空多出一个content script这一点会在后面避坑章节展开。第二permissions里只给了activeTab和scriptingactiveTab用于popup点击后获取当前页面的临时访问权scripting用于后续向页面注入脚本这是最小权限方案安全审查也更友好。3.2 background.js一个只做转发和应答的service workerMV3里background不再是一个常驻页面而是一个service worker按需唤醒、空闲销毁。这意味着你不能依赖它保存全局状态但它用来做消息转发是完全没有问题的因为事件监听本身会唤醒它。// background.js // 监听所有来自扩展内部的消息popup 或 content script 发来的都走这里 chrome.runtime.onMessage.addListener((message, sender, sendResponse) { // sender.id 是发送方扩展IDsender.tab 在 content script 发来时存在 console.log([background] receive from, sender.id, tab?, sender.tab ? sender.tab.id : -, message); if (message message.kind demo.ping) { sendResponse({ ok: true, ack: background got it }); } // 如果后续要在这里做异步操作再 sendResponse必须 return true });这段代码的逻辑是任何扩展环境发来消息先看sender判断来源然后根据消息里的kind字段决定怎么回应。sendResponse可以同步调用也可以异步调用但异步调用时必须让监听函数return true来告诉Chrome“这个通道我还没用完别急着关”。这里有个MV3特有的注意点service worker可能会休眠但onMessage监听是注册在worker启动时的消息到来会唤醒它所以不用担心“休眠了就收不到消息”。真正需要担心的是你在background里存的全局变量会不会丢这个我们放到避坑章节细说。3.3 content.js扩展世界和页面世界之间的翻译官content script是整条通信链路里最特别的角色它既活在扩展体系里又和页面共享同一个DOM事件流。这个demo里它要做的事有两件监听扩展侧消息再通过postMessage把消息投递给页面同时反过来监听页面用postMessage发来的消息转发给background。// content.js // 监听扩展侧发来的消息popup 或 background 通过 tabs.sendMessage 发送 chrome.runtime.onMessage.addListener((message, sender, sendResponse) { if (!message || !message.kind) return; console.log([content] receive from extension:, message); // 转发给页面世界的 JS用 postMessage 投递到 DOM 事件流 window.postMessage({ __demo: true, kind: message.kind, payload: message.payload }, location.origin); sendResponse({ ok: true, forwarded: true }); }); // 监听页面世界用 postMessage 发来的消息 window.addEventListener(message, (event) { // 只认当前页面 origin避免被其他窗口的消息干扰 if (event.origin ! location.origin) return; if (!event.data || !event.data.__demo) return; console.log([content] receive from page:, event.data); // 把页面消息转发到扩展侧的 background chrome.runtime.sendMessage({ kind: event.data.kind, payload: event.data.payload, via: page }, (reply) { if (chrome.runtime.lastError) { console.warn([content] background 未响应, chrome.runtime.lastError); } }); });content script里有两个细节值得说。第一这里没有直接用event.data.type判断消息类型而是用event.data.__demo这个自定义标记来区分“自己人”防止页面上其他脚本的postMessage干扰通信第二window.postMessage(message, location.origin)里的location.origin是当前页面的源比用*更严谨避免把消息泄漏给其他源。把这一章三个文件串起来最小demo就已经能跑点击popup发送消息background打印日志content script确认收到。但只到这一步还不够因为你还没把“页面自身的JS”拉进链路里那是标题里“各个类型的JS”里最容易被漏掉的一环。4. 打通双向链路popup、content script、页面JS三方互发干活4.1 能不能直接调用页面JS函数搜到的一半答案都不对先回应那个高频搜索词浏览器插件如何调用页面js函数。很多文章会告诉你用chrome.tabs.executeScript执行一段代码但那只适合一次性调用而且是在content script的isolated world里执行调不到页面自己的函数。真正要“和页面JS对话”必须靠两套机制配合往页面里注入一段运行在MAIN world的脚本再用postMessage做桥。这段脚本可以借助chrome.scripting.executeScript来注入它支持world: MAIN参数让代码真正跑在页面的主世界里这样它就能访问页面定义的全局变量也能监听页面自己的事件。// 在 popup.js 或 background.js 中调用 const [tab] await chrome.tabs.query({ active: true, currentWindow: true }); await chrome.scripting.executeScript({ target: { tabId: tab.id }, world: MAIN, func: () { // 这段代码运行在页面主世界能访问 window 上的全局变量 if (window.__demoBridgeInstalled) return; window.__demoBridgeInstalled true; window.addEventListener(message, (event) { if (event.origin ! location.origin) return; if (!event.data || !event.data.__demo) return; // 页面侧收到消息可以在这里调用页面自己的函数 const data { __demo: true, kind: page.reply, payload: { pageGot: event.data.kind } }; window.postMessage(data, location.origin); }); } });这段代码的意义是把“页面世界的耳朵”装好。从此以后content script用postMessage发消息页面主世界能收到页面主世界用postMessage回消息content script也能收到。页面是否直接暴露函数给插件其实不重要重要的是双方有了约定的消息协议。4.2 完整链路一次消息往返跨越四种环境六次跳转现在把整条链路串起来。用户在popup里点击按钮消息的旅程是popup → background → content script → 页面主世界 → content script → background → popup。这不是绕路而是隔离模型下的必经之路。发起方是popup它通过tabs.sendMessage发给指定tab的content scriptcontent script把消息转成postMessage投给页面页面主世界的监听器收到后立刻回一条postMessagecontent script监听window message收到这条回信再通过runtime.sendMessage广播给扩展侧此时background和popup都会收到popup负责展示结果。// popup.js const btn document.getElementById(sendBtn); const status document.getElementById(status); // 在 popup 里注册监听接收整条链路最终回传的结果 chrome.runtime.onMessage.addListener((message) { if (message message.kind page.reply) { status.textContent 页面回执: JSON.stringify(message.payload); } }); btn.addEventListener(click, async () { const [tab] await chrome.tabs.query({ active: true, currentWindow: true }); if (!tab || tab.id undefined) { status.textContent 当前页面不可用请在普通网页上测试; return; } try { // 第一步向 content script 发送消息 const ack await chrome.tabs.sendMessage(tab.id, { kind: demo.ping, payload: { at: Date.now() } }); status.textContent content 确认: JSON.stringify(ack); } catch (err) { status.textContent 发送失败: err.message; } });注意这里chrome.tabs.sendMessage在Chrome 99支持Promise写法旧版本需要用回调。await出现异常时最常见的报错就是“Receiving end does not exist”这代表目标标签页里根本没有content script在听具体原因放在避坑章节讲。4.3 消息契约约定一个稳定的消息结构链路越长消息结构越要稳定。我一般不用裸的{type: xxx}因为type字段太容易撞名。更稳妥的是用kind做消息名用payload做数据体再保留一个__demo命名空间标记表示这条消息属于这个demo协议。字段作用示例值__demo命名空间标记防止误收页面其他消息truekind消息类型接收方靠它路由demo.ping/page.replypayload业务数据体必须是可序列化对象{ at: 1690000000000 }from可选的来源标记方便排查popup/content/page这条协议约定在content script和页面脚本里都要遵守。页面脚本回消息时要知道自己回的是page.replycontent script转发时要保留kind和payload原样。一旦消息路由出现混乱顺着kind字段打日志就能快速定位是哪一跳出了问题。5. JS通信翻车现场与排查方法五个反复出现的坑5.1 报错“Receiving end does not exist”其实页面里根本没有content script现象插件装好了点popup按钮发消息控制台直接抛Could not establish connection. Receiving end does not exist.原因这个报错的含义不是“消息没送达”而是Chrome在目标标签页里找不到content script。最常见的三种情况扩展刚安装之前已经打开的页面不会自动注入content script目标页面是chrome://或Chrome应用商店这类受保护页面根本不允许注入还有人是把content script配置了matches: [https://example.com]然后在一个普通的百度页面上测试自然不匹配。解决最简单的办法是刷新目标页面让content script跟着页面重新注入。如果刷新也不行检查manifest里的matches是否覆盖了目标页面。对于受保护页面无解换一个普通http或https网页测试就好。更灵活的做法是不依赖静态注入而是在用户触发时用chrome.scripting.executeScript动态注入这样能确保content script一定存在。5.2 异步回调里sendResponse永远不送达监听函数没return true现象content script在收到消息后先异步调用一个API比如查storage拿到结果后调sendResponse但popup那边始终等不到回应。原因这是Chrome消息机制里最经典的坑。onMessage监听函数返回后Chrome就认为消息处理完毕准备关闭发送通道即使你后面再调sendResponse也已经晚了。要告诉Chrome“我这儿还在异步处理先别关通道”必须让监听函数返回true。解决在需要异步响应的监听函数末尾加上return true;或者把监听函数改写成async函数。用async写法时Chrome会把Promise解析结果作为auto sendResponse但要注意返回值必须是可序列化对象。5.3 把函数塞进消息里报DataCloneError现象content script向页面postMessage传一个对象对象里带一个回调函数或者引用了DOM节点控制台报DataCloneError: The object could not be cloned.原因消息通道底层用的是结构化克隆算法它只能复制普通对象、数组、字符串、数字这些数据函数和DOM节点无法被克隆。很多人习惯把“请求 回调”打包成一个对象传出去这在同页面JS里没问题但跨环境通信时这样做直接就炸。解决消息里只放纯数据函数留在接收方自己定义。需要回调的话通过消息里的kind字段区分“请求”和“回应”发送方自己注册对应监听。换句话说把消息当API调用不要把消息当函数传参。5.4 popup一关消息就石沉大海现象popup里注册了onMessage监听调试时一切正常但关闭popup后再从页面或content script发消息监听的响应就没了。原因popup是一个临时页面关闭后整个window实例被销毁里面注册的监听器也随之消失。这不是bug是生命周期。反过来popup打开状态下发的消息能正常送达因为那时页面是活的。解决凡是需要“随时接收”的消息监听都放到background里由background做中转或持久处理。popup只负责展示状态打开时主动向background查询当前状态。这样即使popup没开功能也不会丢。顺带一提MV3的service worker也会休眠但休眠后能被事件唤醒所以监听器本身还在这一点和popup有本质区别。5.5 在chrome://页面或file://页面上测试全部失灵现象在chrome://extensions页面或本地file文件上点按钮消息发送失败甚至popup都正常但就是没反应。原因Chrome出于安全考虑不允许向chrome://页面、Chrome应用商店页面注入content script或执行脚本。file://页面默认也被排除除非用户在扩展详情页手动开启“允许访问文件网址”。解决开发调试统一用http或https页面别在扩展管理页里点来点去试功能。如果业务确实需要处理本地文件引导用户开启文件访问权限但这属于额外的权限配置不是通信问题本身能解决的。这个坑几乎每个新手都会踩一次排查时先确认测试页面类型再深入协议。6. 把消息收口成一个bus封装、调试与扩展思路每一跳都直接调chrome.runtime.sendMessage不是不能跑但项目一复杂到处散落的sendMessage会让排查变成一场灾难。我习惯在content script和background之间包一层极简的messageBus只暴露send(kind, payload)和on(kind, handler)两个方法内部统一处理命名空间、错误和日志。// message-bus.js可以在 content.js 和 background.js 里共用 const bus { send(kind, payload, opts {}) { const msg { __demo: true, kind, payload, at: Date.now() }; const target opts.tabId ? chrome.tabs.sendMessage(opts.tabId, msg) : chrome.runtime.sendMessage(msg); return target.catch ? target : Promise.resolve(target); }, on(kind, handler) { const listener (message, sender, sendResponse) { if (!message || message.__demo ! true) return; if (kind message.kind ! kind) return; const result handler(message.payload, sender); if (result result.then) { result.then(sendResponse); return true; } sendResponse(result); }; chrome.runtime.onMessage.addListener(listener); return listener; } };这个封装的参数要点是opts.tabId存在时走tabs通道没有则走runtime广播handler返回Promise时自动保持通道。调试时可以在bus的send和listener里各加一行console.log消息去哪一跳、带什么数据一目了然比关掉代码盲猜效率高得多。验证一条链路是否通也用不着完整业务发一个demo.ping看回执的at字段就能确认每一跳耗时。我自己写插件这几年最大的习惯是先把消息契约定下来再写业务代码。链路越长越不能靠“顺手写个type”蒙混过关你省的那几分钟最后都会在排查翻车现场时十倍还回去。这个demo做完之后至少能让你面对任意一个“插件里JS互相不通”的报错时先分清是哪一堵墙挡住了而不是对着黑匣子干瞪眼。希望帮到你。本文还有配套的精品资源点击获取
返回列表