导语:扩展是最不自由也最有价值的 WASM 宿主
浏览器扩展能给 WASM 提供一块独特的舞台:本地跑模型、处理媒体、做加密与压缩,全程不上传数据。但它同时也是约束最多的宿主——CSP 限制代码来源、service worker 随时被回收、不同浏览器对 WASM 的支持粒度不同。一个在网页里跑得好好的 WASM 模块,装进扩展后可能直接加载失败。
本文聚焦「为什么装进扩展就跑不起来」这类真实问题:CSP 里到底要写什么、MV3 的 service worker 被回收后 WASM 实例怎么办、内容脚本能不能直接实例化、三大浏览器差在哪里。目标是给出一份能照着落地的清单。
目录
- 1. MV3 架构与约束
- 2. CSP 与 wasm-unsafe-eval
- 3. service worker 生命周期
- 4. 打包与 manifest 配置
- 5. 消息传递与类型
- 6. 内容脚本与页面注入
- 7. 性能与内存约束
- 8. 跨浏览器差异
- 9. 调试与发布
- 10. 落地清单
1. MV3 架构与约束
1.1 三块执行环境
┌─ 浏览器扩展 ─────────────────────────────────┐
│ Service Worker(可回收) │ Popup/Options(有 DOM) │
│ ▲ │ │
│ └──── 消息 ──────┘ │
│ Content Script(注入页面, JS 环境隔离) │
└──────────────────────────────────────────────┘
WASM 可跑在任何一块,但三者 CSP、生命周期、可用 API 各不相同。
1.2 为什么容易踩坑
四个核心约束:
1. CSP 默认禁止动态代码生成 → WASM 编译可能被拦
2. Service Worker 空闲约 30 秒被回收 → 内存中的 WASM 实例随之消失
3. 内容脚本的 CSP 受宿主页面影响 → 同一模块在不同站点表现不同
4. 各浏览器对 wasm-unsafe-eval 的支持程度不一致
一句话总结:MV3 有 service worker / popup / content script 三块上下文,CSP 与生命周期各不相同;WASM 能否跑起来取决于它落在哪一块以及对应的 CSP 配置。
2. CSP 与 wasm-unsafe-eval
2.1 报错现象
典型报错:
CompileError: WebAssembly.instantiate(): Wasm code generation
disallowed by Content Security Policy.
原因:WASM 编译被视为「动态代码生成」,被 MV3 默认 CSP 拦截。
解决:在 manifest 的 content_security_policy 里显式允许。
2.2 配置方式
{
"manifest_version": 3,
"content_security_policy": {
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
}
}
要点:
'wasm-unsafe-eval' 只放开 WASM 编译,不放行 eval / new Function
比 'unsafe-eval' 安全得多,是 MV3 推荐的写法
extension_pages 管 service worker 与扩展页面;
content script 的 CSP 由宿主页面决定,不受 extension_pages 控制
若模块用了 SharedArrayBuffer / 多线程:
需要 COOP/COEP,扩展页面可在 manifest 里配 cross_origin_embedder_policy
但内容脚本场景基本无法满足,多线程 WASM 只适合放扩展页面
永远优先 wasm-unsafe-eval 而非 unsafe-eval。后者会把整个扩展暴露在动态代码执行风险下,而前者只放开 WASM 这一条路径。
若同时要用远程 fetch 的 wasm(不推荐):
还需在 CSP 里加 connect-src,并承担商店审核风险
绝大多数场景应把 wasm 打进包体,避免运行时下载
一句话总结:MV3 里 WASM 编译需要
wasm-unsafe-eval;只加这一项即可,绝不用unsafe-eval,且它只对扩展页面生效、管不到内容脚本。
3. service worker 生命周期
3.1 回收与冷启动
MV3 service worker 的生命周期:
事件到来 → 唤醒(冷启动,跑 top-level 代码)→ 处理事件
→ 空闲约 30 秒 → 被终止,全局状态全部丢失
含义:WASM 实例、编译好的 Module、缓存的结果,全都不能假设常驻。
// 反例:把实例存在全局,指望它一直在
let instance; // SW 回收后变成 undefined
self.addEventListener('install', async () => {
instance = await loadWasm(); // 下次唤醒时这个实例已经没了
});
// 正例:每次事件处理时惰性加载,用编译缓存减少重复成本
let modulePromise = null;
async function getInstance() {
modulePromise ??= WebAssembly.compileStreaming(fetch(chrome.runtime.getURL('app.wasm')));
return WebAssembly.instantiate(await modulePromise, imports);
}
3.2 状态持久化
三处可持久化的位置:
chrome.storage.local 存小状态(配置、进度),异步 API
IndexedDB 存大块数据(模型权重、缓存结果)
Cache API 存编译前的字节,配合引擎编译缓存
原则:内存里只放「可重建」的东西,一切重要状态立即落盘。
一句话总结:MV3 service worker 空闲约 30 秒即回收,全局状态不可依赖;WASM 实例改为「事件内惰性创建」,重要状态一律写 storage 或 IndexedDB。
4. 打包与 manifest 配置
4.1 资源声明
{
"manifest_version": 3,
"web_accessible_resources": [
{
"resources": ["app.wasm", "app.js"],
"matches": ["<all_urls>"]
}
]
}
web_accessible_resources 的作用:
允许内容脚本或页面通过 chrome.runtime.getURL() 访问扩展内文件
若要在内容脚本里 fetch 扩展内的 wasm,必须在这里声明,否则 404
注意:暴露给 <all_urls> 会带来指纹识别风险,尽量收窄 matches。
4.2 构建产物
打包要点:
1. wasm 与 js glue 一并放进扩展包,不要依赖运行时下载
2. 保留 application/wasm 的类型信息(部分构建链会丢失)
3. 用 bundler 时确保 wasm 以 asset 形式产出,路径可被 getURL 解析
4. 商店审核对包体有上限(Chrome 单包约 2 GB,实际越小越好)
// 从扩展内部加载 wasm(相对路径会解析到扩展根)
const url = chrome.runtime.getURL('app.wasm');
const { instance } = await WebAssembly.instantiateStreaming(fetch(url), imports);
一句话总结:扩展内 wasm 必须打包进包体,并在
web_accessible_resources声明;内容脚本 fetch 扩展资源要收窄 matches,避免指纹风险。
5. 消息传递与类型
5.1 三种通道
| 通道 | 方向 | 特点 |
|---|---|---|
| runtime.sendMessage | 页面 ↔ SW | 一次性请求响应 |
| tabs.sendMessage | SW → 内容脚本 | 定向到某个标签页 |
| Port(长连接) | 双向 | 可流式、可保活 |
// SW 侧:处理来自 popup / content script 的请求
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === 'compute') {
runWasm(msg.payload).then((out) => sendResponse({ ok: true, out }));
return true; // 关键:返回 true 才能异步 sendResponse
}
});
5.2 类型与边界
消息传递的两个硬约束:
1. 结构化克隆:只能传可克隆对象(ArrayBuffer 可,TypedArray 可,函数不可)
2. 大数组优先传 ArrayBuffer 而非 JSON 数组,避免逐元素序列化
传 WASM 计算结果时:直接在扩展页面侧把结果写成 ArrayBuffer 传出,
接收侧建 TypedArray 视图,一次拷贝即可。
异步响应必须 return true。这是 MV3 消息处理最常见的 bug:忘了返回,sendResponse 被静默忽略,调用方永远等不到回包。
版本兼容注意:
不同浏览器对结构化克隆的支持边界略有差异
复杂对象优先序列化成 ArrayBuffer 再传,兼容性最稳
一句话总结:消息走结构化克隆,大数组传 ArrayBuffer 而非 JSON;异步
sendResponse必须return true,否则回包被静默丢弃。
6. 内容脚本与页面注入
6.1 能否直接实例化
内容脚本里的 WASM 受两重 CSP 影响:
扩展侧:web_accessible_resources 是否放行该文件
页面侧:宿主页面的 CSP 是否允许 wasm 编译
结论:在严格 CSP 的页面上(如部分银行、邮箱站点),内容脚本可能无法编译 WASM。
// 稳健做法:把计算放回扩展页面 / service worker,内容脚本只做 DOM 交互
// content script:
const result = await chrome.runtime.sendMessage({ type: 'compute', payload });
renderResult(result);
6.2 注入与隔离
两种注入方式:
content script(默认) 与页面共享 DOM,但 JS 环境隔离,互不可见
注入到页面主世界 通过 script 标签注入,可与页面 JS 交互但失去隔离
安全建议:需要访问页面 JS 变量时才注入主世界,且只传数据不传逻辑;
WASM 计算尽量留在隔离环境,避免与页面脚本互相污染。
一句话总结:内容脚本的 WASM 同时受扩展与页面 CSP 约束,严格站点上可能无法编译;把计算放回扩展页面、内容脚本只做 DOM,是更稳的架构。
7. 性能与内存约束
7.1 启动成本
扩展里 WASM 的额外成本:
每次 service worker 冷启动都要重新实例化(编译可缓存,实例化不可)
popup 每次打开都是一个新页面上下文,同样要实例化
缓解:把重计算移到常驻性更好的 offscreen document 或独立标签页。
7.2 内存
内存三档参考:
service worker 通常几十 MB,超了会被回收或崩溃
popup 与普通标签页接近,但用户关闭即释放
offscreen 可申请更多,适合长任务
移动端扩展(Firefox Android / Safari iOS)内存更紧,务必按最小可用设计。
offscreen document 的定位:
需要 DOM 或长驻状态时,用 chrome.offscreen 创建一个隐藏页面
它不会随 popup 关闭而消失,适合承载 WASM 实例与长任务
代价是更显眼的内存占用,要显式管理其生命周期。
一句话总结:扩展里编译可缓存但实例化不可,每次冷启动都要重建;重计算放 offscreen document,移动端按最小可用内存设计。
8. 跨浏览器差异
8.1 差异矩阵
| 能力 | Chrome | Firefox | Safari |
|---|---|---|---|
| MV3 支持 | 完整 | 支持(部分 API 不同) | 支持(转换自 Xcode) |
| wasm-unsafe-eval | 支持 | 支持 | 视版本 |
| SharedArrayBuffer | 受限 | 受限 | 受限 |
| 后台模型 | service worker | event page(近 SW) | 类似 SW |
| 移动端 | 无扩展 | Android 支持 | iOS 支持(严格) |
主要差异点:
1. Firefox 的 MV3 仍保留 background scripts 的部分语义
2. Safari 的扩展由 Xcode 打包,CSP 与资源访问更严格
3. 三者的 service worker 回收策略不同,不能假设统一超时
对策:把生命周期相关逻辑写成「随时可重建」,不做任何常驻假设。
8.2 兼容策略
[ ] 用 webextension-polyfill 统一 API 差异
[ ] 不依赖任何「实例常驻内存」的假设
[ ] 对 WASM 加载失败做兜底(回退到纯 JS 实现或提示)
[ ] 每个浏览器单独跑一遍加载与冷启动测试
一句话总结:三大浏览器在 MV3 语义、CSP 与回收策略上都有差异;统一 API 用 polyfill,生命周期写成「随时可重建」,并为 WASM 加载失败准备兜底。
9. 调试与发布
9.1 调试
调试入口:
chrome://extensions → 开启开发者模式 → service worker 有独立 DevTools
WASM 源码级调试需要 DWARF 段 + 浏览器 DevTools 支持
发布版剥离 DWARF(体积),开发版保留
常见问题定位:
编译失败先看 CSP 报错;404 先看 web_accessible_resources
实例化失败先看 import 是否齐全(SW 环境缺 DOM API)
9.2 发布
发布检查:
1. 剥离调试符号与 sourceMappingURL,压缩 wasm
2. 收窄 web_accessible_resources 的 matches
3. 商店审核关注「是否下载并执行远程代码」→ 本地打包可避免
4. 声明数据用途,若 WASM 处理用户数据需在隐私政策中说明
绝不要从远程下载 WASM 再执行。这既违反商店政策,也是安全大忌;所有代码必须随包发布。
一句话总结:调试分「CSP / 资源 404 / import 缺失」三类先定位,发布要剥离符号、收窄资源暴露;WASM 必须随包发布,绝不允许运行时下载执行。
10. 落地清单
10.1 上线清单
[ ] manifest 配 'wasm-unsafe-eval',未使用 'unsafe-eval'
[ ] wasm 与 glue 随包发布,web_accessible_resources 收窄
[ ] 不在全局变量里缓存 WASM 实例,改为事件内惰性创建
[ ] 重要状态写 storage / IndexedDB,内存只放可重建数据
[ ] 消息异步响应 return true,大数组传 ArrayBuffer
[ ] 内容脚本不直接实例化 WASM,计算回扩展页面
[ ] 三浏览器各跑一遍冷启动与加载失败兜底
[ ] 发布版剥离 DWARF 与 sourcemap
10.2 架构建议
推荐分层:
内容脚本 只做 DOM 读取与渲染,不碰 WASM
扩展页面 UI 与 WASM 实例(popup / options / offscreen)
SW 调度与消息中转,尽量无状态
这样每一层都能独立重建,生命周期回收不会导致功能不可用。
一句话总结:按「内容脚本不碰 WASM、扩展页面持有实例、SW 无状态调度」分层;配合上线清单逐项核对,扩展里的 WASM 才能在三浏览器上稳定运行。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。