一次性消息通信
本教程共 56 篇 · 第 20 篇 · 更新于 2026-08-13 · 约 8 分钟阅读
本节目标:学完能写出「发一条消息、收一条回复」的完整请求-响应链路,并知道如何判断消息是谁发来的。
扩展里的各个组件是分开运行的。弹出页、后台服务工作者、内容脚本,它们各跑各的,内存不共享。要让它们交换数据,就得靠消息通信。本章只讲最常用、也最基础的那种:一次性消息。
20-1 什么是一次性消息
一次性消息,就是你扔过去一句话,对方回你一句话,然后这条通道就关了。它适合「问一个问题、拿一个答案」的场景。比如内容脚本想知道后台存的用户设置,弹出页想让后台去抓一个接口,这些都是一次性的。
它的两头和一对 API 要记牢:发送方调用 chrome.runtime.sendMessage(),接收方用 chrome.runtime.onMessage.addListener() 监听。名字里的 runtime 说明这是运行时的能力,不依赖任何具体页面。
Note一次性消息不是「只能发一个字」。你可以发一整个对象,也可以收到一整个对象。所谓一次性,是指通信通道只开一次,发完收回就关。
20-2 发送方怎么发
发送方在任何扩展上下文里都能调用 chrome.runtime.sendMessage。最常见的发送体是一个普通对象,里面放你想传的数据。
// content_script.js 或 popup.js 里都可以
(async () => {
const response = await chrome.runtime.sendMessage({ greeting: "hello" });
// 拿到回复后在这里处理,别跑到函数外面去
console.log(response);
})();
这里有个坑我要提醒你:await 拿到的 response 只在 async 函数内部有效。很多人把后续逻辑写在函数外面,结果 response 是 undefined。要注意,响应就在 await 的同一层处理。
消息体在 Chrome 里走的是 JSON 序列化(Firefox 才是结构化克隆)。也就是说,普通对象、数组、字符串、数字、布尔、null 都能可靠传递,但 Date 会被序列化成字符串、Map/Set 会变成空对象、ArrayBuffer 也保不了真。函数、DOM 节点、类实例方法更没法传,你要是硬塞一个函数进去,发送方会直接抛错:Could not serialize message。
20-3 接收方怎么收并回复
接收方在后台服务工作者里注册监听。监听器拿到三个参数:消息体、发送者信息、以及一个用于回传的 sendResponse 函数。
// background.js
function handleMessages(message, sender, sendResponse) {
if (message !== 'get-status') return;
fetch('https://example.com')
.then((response) => sendResponse({ statusCode: response.status }));
// fetch 是异步的,必须显式返回 true 让通道保持打开
return true;
}
chrome.runtime.onMessage.addListener(handleMessages);
关键点来了:sendResponse 不是同步返回的。如果你要等一个异步结果(比如 fetch),直接 return 会让监听器立刻结束,回复就发不出去。上面代码里 return true 的作用,就是告诉浏览器「我还要用,别急着关通道」。等 fetch 回来再调 sendResponse,回复才真正发出去。
20-4 异步响应的两种正确写法
return true 配合 sendResponse 是老写法,能跑。但 MV3 里更推荐直接用 Promise,干净也更好读——不过有个版本前提:监听器返回 Promise 作异步响应,需要 Chrome 148+,要在老版本上跑,还是得用 return true + sendResponse。只要你的监听器返回一个 Promise,浏览器就会等这个 Promise 落地,把它解析出来的值当作回复发回去。
// background.js 写法一:返回 Promise
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
return new Promise((resolve, reject) => {
fetch('https://example.com')
.then((response) => {
if (!response.ok) reject(response);
else resolve(response.status);
})
.catch((error) => reject(error));
});
});
// background.js 写法二:用 async 函数直接 return
chrome.runtime.onMessage.addListener(async (message, sender) => {
const response = await fetch('https://example.com');
if (!response.ok) {
throw new Error(`Fetch failed: ${response.status}`);
}
return { statusCode: response.status };
});
Tip用
async函数时,你直接return的值就是回复;抛出的错误会变成发送方await时捕获的异常。这样发收两端都用try/catch和await,代码最整齐。
这里还有个容易踩的雷:如果你挂了多个 onMessage 监听器,只有第一个作出有效回复(非 undefined)的会被采用。某个监听器若只是 await 了一下却没 return 值,它返回的是 undefined,浏览器会把回复当成 null。所以我的建议是:一条消息只配一个监听器去处理,别到处堆监听。
20-5 识别发送者 sender
接收方能拿到 sender,这是判断「谁给我发的」的关键。在 MV3 里,sender 上常见的字段有这些:
sender.id:发送扩展的 ID。你自己扩展发来的,它等于chrome.runtime.id。sender.url:发送方页面的地址,内容脚本会是它注入的网页 URL。sender.tab:如果来自内容脚本,这里面有tabId、frameId、documentId等。sender.frameId:来自哪个框架,0 是主框架。sender.origin:来源源站。
// background.js 只信任自己扩展内的组件
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (sender.id !== chrome.runtime.id) {
// 不是本扩展发来的,直接忽略,不要处理
return;
}
// 来自内容脚本时,还可以校验它注入的页面
if (sender.tab && sender.tab.url.startsWith('https://example.com')) {
// 放心处理
}
});
Note内容脚本运行在网页环境里,理论上更容易被网页「搭便车」调用。所以后台在收到消息时,最好校验
sender.id甚至sender.url,只处理可信来源的请求。这就是下面安全章节会反复强调的「内容脚本可信度较低」。
20-6 MV3 服务工作者下的注意事项
后台在 MV3 里是服务工作者,不是常驻页面。它可能在你发消息时是「睡着」的。好消息是:一次性消息会自动把后台唤醒。但唤醒后,浏览器要先执行后台脚本里那段「注册监听」的顶层代码,才能把消息交给监听器。
所以你一定要保证:监听器是同步写在后台脚本顶层的,不要在某个 await 之后才 addListener。下面这种写法是真正的反模式——addListener 被包进 .then() 回调里,后台被消息唤醒后如果初始化还没跑完,监听器就还没注册上,消息会漏接:
// 错误示范:addListener 被包在异步初始化之后
async function someInit() {
await fetch("https://example.com/config");
}
someInit().then(() => {
chrome.runtime.onMessage.addListener(handleMessage); // 注册太晚,消息可能没人接
});
Note注意别混淆:
addListener里的监听器写成async函数本身是合法写法(20-4 刚推荐过),问题只出在”注册时机”上。
正确做法是把 addListener 直接放顶层,初始化逻辑放到 onInstalled 之类的事件里,或者在监听器内部去读取已经准备好的数据。
Tip想确认消息链路通不通,打开
chrome://extensions,翻到你的扩展点「Service Worker」旁边的「检查」按钮,在那里看后台日志最直观。
20-7 错误处理
发送方用 try/catch 就能接住接收方的异常。接收方无论是 throw 还是 return Promise.reject,发送方 await 时都会进 catch。
// sender.js
try {
await chrome.runtime.sendMessage('test');
} catch (e) {
console.log(e.message); // 例如 "some error"
}
// background.js
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
return Promise.reject(new Error('some error'));
});
Note一条消息只能有一个「回复」。发送方最终只拿到一个
response。如果你希望两边各说各话、连续多轮,那就不该用一次性消息,而该看下一章的「长连接通信」。
20-8 消息能传什么、不能传什么
前面提过消息走 JSON 序列化,这里把边界说清楚,免得你调试半天发现数据「变形」了。Chrome 的消息传递用的是 JSON 序列化(Firefox 才是结构化克隆),所以能可靠传递的只有 JSON 兜得住的类型:普通对象、数组、字符串、数字、布尔、null。
Date、RegExp、Map、Set、ArrayBuffer、Blob、ImageData 这类内建类型不会被保真:Date 会变成字符串、Map/Set 变成空对象、ArrayBuffer 变成普通对象,接收方拿到的已经不是原来的东西。如果跨浏览器必须传这些类型,要么自己先序列化(比如 Date 转成时间戳、Map 转成数组),要么借助 webextension-polyfill 这类兼容层。
传不了的东西更要记牢:函数、DOM 节点、类实例上的方法、以及 Symbol。这些一旦进消息体,要么发送方直接抛 Could not serialize message,要么到了对端变成空壳。所以千万别想把「一个带方法的对象」整个传过去让对方调用,那是行不通的。正确做法是只传纯数据,让接收方拿到数据后自己去调自己的逻辑。
Tip一次性消息的体积上限是 64 MiB,一般不会被你撞到,但它终究要经过序列化再反序列化。如果你要传一张大图片、一大段音频,或者几 MB 的文本,干脆只传一个资源的 URL,让接收方自己去取。把巨型数据硬塞进消息,既慢又容易把通道撑出问题。
20-10 小结
一次性消息是扩展通信的基石。四件事要记牢:发送用 sendMessage,接收用 onMessage;异步回复要么 return true + sendResponse,要么直接返回 Promise;用 sender 校验来源;后台监听器要同步写在顶层。把这四点吃透,你就能打通扩展内部「问一答一」的大部分需求了。