ARTICLE DETAIL

资讯详情

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

从脚本到工程化:MV3浏览器插件开发与端侧AI实战

从脚本到工程化:MV3浏览器插件开发与端侧AI实战 这几年如果还有人跟我讲“浏览器插件不就是个小脚本”我一般会直接把一份 MV3 工程目录甩过去。今天的浏览器插件尤其围绕 Chromium 生态做的扩展早就不再是往 manifest.json 里塞一段 JS 就能搞定的小东西了。从 MV3 把常驻后台页改成 Service Worker到 content script、popup、offscreen 之间那套绕不开的消息通信再到把视觉模型、语言模型直接塞进端侧跑推理一个正经插件工程已经和一个中小型前端项目没有本质区别。这篇文章我想把这几年做插件工程化的一些实战经验聊透适合正在从脚本思维切换到工程化思维的开发者也适合准备在插件里做端侧 AI 功能、但不知道怎么下手的同学。1. 为什么说现在做插件等于做一套前端工程1.1 MV3 把“常驻后台”的脚本思维彻底抬走了先说一个最容易被低估的变化Manifest V3 里没有 Background Page只有事件驱动的 Service Worker。这在 MV2 时代是不可想象的——以前大家在 background 里声明一个页面它就常驻在后台全局变量随便挂定时器随便跑整个插件状态像写单个 HTML 文件一样随意。MV3 的 Service Worker 不是这个模型。它在没有事件的时候会被浏览器休眠下次事件到来再被唤醒。这意味着两个工程化层面的直接后果第一你不能依赖内存里的全局变量做状态管理。因为 Worker 一休眠内存里的对象全部清空。以前那些“启动时加载一次配置、常驻内存里随时读”的写法在 MV3 下就是定时炸弹。现在必须主动把状态持久化到 chrome.storage 或 IndexedDB每次唤醒先读状态处理完再写回去。第二长后台任务没法跑了。Service Worker 的设计目标是处理短事件任何超过几十秒的持续任务都可能被中断。如果你在 background 里做大批量处理、轮询、或者长时间跑一个模型推理很容易做到一半就无声无息地消失。这里就需要把重活拆到 Offscreen Document 或 Web Worker 里让 Service Worker 只做事件路由。所以 MV3 本身就是一个强制的工程化推手。它逼着你把“状态、逻辑、界面、后台”拆开否则项目五月能跑六月就崩。1.2 从一个页面角色变成一整套分层架构以前写插件脑子里只需要两个概念background 和 content script。现在一个真正意义上的插件工程至少要拆出下面几层界面层popup、options、newtab、或插入页面的 shadow DOM 面板。内容脚本层content script负责操作页面 DOM但只能拿到有限的 API。后台逻辑层Service Worker负责事件监听、扩展 API 调用、消息路由。重计算层Offscreen Document 或 Web Worker用于文字识别、模型推理、音视频处理等不适合在 SW 里做的操作。数据层chrome.storage、IndexedDB、或本地文件缓存负责跨层共享持久化状态。这些层之间并不是互相 import 就能访问的关系它们运行在完全隔离的上下文里。content script 拿不到 background 里的变量background 也摸不到页面上的 DOMpopup 和 options 虽然是页面但各自又是独立的环境。跨层协作只能靠消息通信。我把几个常见运行环境的差异整理成了表格方便对照运行环境生命周期可访问 DOM可用的扩展 API典型用途Service Worker事件驱动可休眠不能大部分扩展 API后台路由、状态管理、跨域请求Content Script跟随页面加载可以少量 API受限操作页面 DOM、采集数据Popup / Options打开时存在关闭即销毁可以大部分扩展 API用户交互界面Offscreen Document手动创建手动关闭可以部分扩展到 API音频播放、DOM 解析、重计算Web Worker随创建者生命周期不能几乎不能直接用纯计算、模型推理如果你上来就把业务逻辑塞进 popup等用户把 popup 关掉所有状态就没了。这就是最典型的“脚本思维”翻车现场。1.3 选型思考先别急着上框架我见过很多做插件的朋友一上来就用 React Webpack 全套结果 content script 做得很重几兆的 JS 被打到每个页面上体验反而下降。做插件工程化有一个特别重要的原则按需选型。如果插件功能是“插入按钮 发请求 显示结果”用原生 TypeScript 完全够。popup 可以是单 HTML 文件content script 保持轻量不需要任何框架。这样构建简单、产物小、调试也直接。如果要做复杂的 options 配置页、数据可视化面板、多窗口管理这时候上 React/Vue 才划算。因为界面复杂度上去了用组件化组织代码收益明显。同样工程构建层面也可以分而治之界面部分用框架content script 部分还是尽量保持原生或轻量依赖。扩展开发框架方面我试过 Plasmo也用过 Vite CRXJS。Plasmo 把 React、HMR、内容脚本打包都集成好了对新项目很友好CRXJS 则更灵活可以保留自己对 Vite 的配置习惯。我的经验是如果你打算长期维护选 Vite 生态的那条路坑最少。2. 跨进程通信插件的各个模块怎么协作2.1 先搞懂 MV3 里有哪几类“上下文”题目里说的“跨进程通信”在浏览器插件领域其实并不是操作系统层面的进程通信而是不同扩展上下文之间的隔离与协作。MV3 的各个模块运行在独立的 JavaScript 环境里它们彼此看不见对方的内存只能通过 chrome.runtime 和 chrome.tabs 提供的消息 API 通信。这个机制和传统前端的 postMessage 有点像但又有自己的规则。需要打通的链路主要有四条content script 和 background 之间的双向消息用来让页面侧和扩展后台联动比如采集页面信息回传、后台下发指令操作页面。popup/options 与 background 之间的消息界面操作后台数据。popup 向指定 tab 发送消息而且要先拿到 tab.id。扩展页面与原生宿主程序之间的通信走 Native Messaging。background 与 Offscreen Document 的通信常用来传递计算任务结果。这里最容易踩的坑是消息方向搞混。chrome.tabs.sendMessage 是从 background 发到指定页面里的 content scriptchrome.runtime.sendMessage 是发给扩展自身的其它上下文。一个是给页面侧的一个是给扩展侧的用错了消息就悄悄丢在风里。2.2 做一个统一的消息封装工程化的第一件事就是把乱的 sendMessage 收敛成一个消息总线。我在实际项目里会做两层封装第一层定义消息类型第二层封装发送和接收。消息类型定义大致长这样// messages.ts export const MessageType { GetPageInfo: GET_PAGE_INFO, SetBadge: SET_BADGE, RunInference: RUN_INFERENCE, } as const; export type MessagePayload | { type: typeof MessageType.GetPageInfo; tabId?: number } | { type: typeof MessageType.SetBadge; text: string } | { type: typeof MessageType.RunInference; imageData: ArrayBuffer };然后在 background 里维护一张路由表而不是在每个监听器里写一堆 if/else// background.ts import { MessageType } from ./messages; const handlers { [MessageType.GetPageInfo]: async (payload) { // ... return { title: example, url: https://example.com }; }, [MessageType.SetBadge]: async (payload) { await chrome.action.setBadgeText({ text: payload.text }); return { ok: true }; }, }; chrome.runtime.onMessage.addListener((message, sender, sendResponse) { const handler handlers[message?.type as string]; if (!handler) { sendResponse({ error: unknown message type }); return false; } Promise.resolve(handler(message)) .then(sendResponse) .catch((error) sendResponse({ error: error.message })); return true; // 表示 sendResponse 会被异步调用 });统一路由的最大好处是可测试、可维护。以后每加一种消息只需要在类型定义里加一项、在 handlers 里加一个函数不需要在所有监听器里翻来翻去找逻辑。团队协作时新成员看着消息类型表就能知道整个插件的通信边界。发送侧同样要封装。content script 里发消息时我一般封装一个 withTimeout 版本防止 background 没响应导致 Promise 一直挂着export async function sendMessageToBackgroundT(message: any): PromiseT { return new Promise((resolve, reject) { const timer setTimeout(() reject(new Error(message timeout)), 5000); chrome.runtime.sendMessage(message, (response) { clearTimeout(timer); if (chrome.runtime.lastError) { reject(new Error(chrome.runtime.lastError.message)); } else { resolve(response); } }); }); }2.3 长连接与“Port disconnected”的坑MV3 早期文档里不太建议用长连接因为 Service Worker 休眠后长连接很可能被掐断。后来 Chrome 改进了生命周期允许连接活动时重置空闲计时器但依然要注意如果长连接被系统从外部断开不会自动重连。什么场景该用 chrome.runtime.connect 长连接我建议只用在强实时、持续双向通信的场景例如插件面板实时显示页面滚动位置、页面侧持续上传事件流。如果只是“请求一次拿个结果”短消息足够了。用长连接时标准的接法是这样的// background.ts chrome.runtime.onConnect.addListener((port) { if (port.name ! page-panel) return; port.onMessage.addListener((msg) { // ... port.postMessage({ received: true }); }); port.onDisconnect.addListener(() { console.log(port disconnected); }); });发送侧const port chrome.runtime.connect({ name: page-panel }); port.postMessage({ type: start }); port.onMessage.addListener((msg) { // ... });这里要记住onDisconnect 只是告诉你断了它不会帮你重连。我的办法是在 onDisconnect 里做指数退避重连否则用户只要一休眠电脑回来插件面板就是半死的。2.4 与系统侧进程通信Native Messaging不少设备厂商的浏览器插件比如路由器管理、安防监控、身份证读卡器这类场景都需要插件去调用本地的可执行程序。这时候就走 Native Messaging浏览器替你把消息通过 stdin/stdout 传给本地程序本地程序返回 JSON 结果给扩展。MV3 里使用 Native Messaging 需要两步准备。第一在扩展的 manifest 里声明permissions: [nativeMessaging]。第二本地机器上必须安装一个 Native Host manifest 文件里面写明可执行程序路径和允许通信的扩展 ID。示例{ name: com.example.myhost, description: Native host for my extension, path: C:\\Program Files\\MyCompany\\native-host.exe, type: stdio, allowed_origins: [chrome-extension://xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx/] }然后代码里用 chrome.runtime.connectNative 连接const port chrome.runtime.connectNative(com.example.myhost); port.postMessage({ cmd: get-device-info }); port.onMessage.addListener((resp) { // handle native message });Native Messaging 是一个稳定的正式 API但要注意不同浏览器对 Native Host 的注册路径要求不同而且扩展更新之后扩展 ID 变了所有宿主配置都要同步改。工程化上建议把 Host 安装脚本和扩展发布流程捆绑起来避免两边版本对不上。3. 端侧 AI 上插件从加载模型到推理优化3.1 插件里跑 AI 的几种路线现在大家聊端侧 AI脑子里的画面往往是某个低功耗视觉模块装在电池供电的设备上。但浏览器插件其实也是一种很理想的端侧 AI 载体——不需要用户安装原生运行时只要浏览器里能打开扩展就能用 WebAssembly、WebGL、WebGPU 跑推理。隐私更好离线可用服务器成本几乎为零。在 Chromium 插件里做端侧 AI主流路线有四条方案适合场景加速方式注意事项TensorFlow.js图像分类、姿态估计、文本分类WebGL / WebGPU / WASM生态成熟模型转换方便ONNX Runtime Web从 PyTorch/TensorFlow 导出 ONNX 后的通用推理WASM / WebGPU更适合跨框架场景MediaPipe Tasks视觉任务封装好上手快WASM / GPU内置了很多 Ready-to-use 模型Transformers.js / WebLLM小型 Transformer 语言模型、文本生成WASM / WebGPU模型体积大加载慢我的选择原则很简单团队里算法同学给什么格式我就选什么 runtime没有历史包袱的话视觉任务优先 MediaPipeNLP 任务优先 Transformers.js通用分类任务 ONNX Runtime Web 最稳妥。3.2 一个可落地的图片分类示例假设我们要做一个插件用户在当前页面右键一张图片弹窗里显示这张图属于哪个类别。流程是content script 拿到图片 URL把图片转成 Tensor交给后台或 Offscreen Document 里的 ONNX Runtime Web 做推理。我用 ONNX Runtime Web 举例。先在前端入口里初始化 WebAssembly 路径因为在扩展的沙箱环境里它不会自动找到 wasm 文件import * as ort from onnxruntime-web; ort.env.wasm.wasmPaths chrome.runtime.getURL(wasm/); ort.env.wasm.numThreads navigator.hardwareConcurrency || 4;然后创建推理 sessionconst session await ort.InferenceSession.create( chrome.runtime.getURL(models/mobilenet-v3.onnx), { executionProviders: [wasm, webgpu] } );图片数据转换不能直接在 content script 里做因为 content script 对跨域图片有 CORS 限制。我会把图片交给 Offscreen Document在 canvas 里绘制并拿到原始像素数据再传给推理环境// offscreen.html 中 const img new Image(); img.crossOrigin anonymous; img.src imageUrl; await img.decode(); const canvas document.createElement(canvas); canvas.width 224; canvas.height 224; const ctx canvas.getContext(2d); ctx.drawImage(img, 0, 0, 224, 224); const imageData ctx.getImageData(0, 0, 224, 224); const floatData preprocess(imageData.data); // 归一化到 [0, 1] 或 [-1, 1]最后跑推理const inputTensor new ort.Tensor(float32, floatData, [1, 3, 224, 224]); const feeds { input: inputTensor }; const results await session.run(feeds); const topClass Array.from(results.output.data).indexOf( Math.max(...results.output.data) );这个流程看起来简单实际上工程化的坑都在“谁会加载模型、谁负责排队、模型什么时候释放”。如果每次点一下图片都重建 session几秒之后用户就会关掉插件。3.3 模型加载、缓存与资源释放端侧模型体积从几兆到几百兆都有加载策略直接影响体验。我在项目里的做法分三步第一模型文件优先打进扩展包里。如果模型太大或者需要动态更新模型版本再放到远程 CDN。本地缓存到 Cache Storage避免每次启动都从网络拉一遍。第二用单例管理 session。整个插件生命周期里同一个模型只创建一次 session其他地方通过 getModelSession() 拿同一个实例。let sessionPromise null; export function getModelSession() { if (!sessionPromise) { sessionPromise ort.InferenceSession.create( chrome.runtime.getURL(models/model.onnx), { executionProviders: [wasm] } ); } return sessionPromise; }第三推理完成或扩展卸载时主动释放 session。session.release() 不调用内存会一直占着。尤其在低端笔记本上跑一次 100MB 的模型后内存就像漏了一样。还有一点要提醒WebGPU 虽然快但在部分 Windows 设备上可能不稳定。实际运行时我会先尝试 webgpu失败就自动回退到 wasm。回退逻辑一定要做否则就是“我电脑能跑用户电脑白屏”。3.4 性能优化与功耗“低功耗端侧 AI”在浏览器插件里的表现和硬件模块不太一样但原则相通能不做就不做能做轻绝不做重。插件的后台不是什么时候都在运行所以 AI 推理更要按需唤醒不要固定轮询。比如视觉监控类插件如果每秒都跑一次帧检测用户的电脑风扇会直接起飞。我一般会加两个机制第一是检测阈值画面变化率低于某个值就跳过第二是防抖两帧推理之间至少间隔几百毫秒甚至几秒。如果逻辑比较复杂可以考虑 Web Worker 里跑推理避免阻塞交互。但要注意Web Worker 里不能用 chrome.runtime 的消息 API 直接跟 background 通信需要把消息转发给页面里的 content script再走 runtime 通道回去。链路多一点但界面仍然流畅。模型尺寸也要控。能用 MobileNet 解决的不要上 ResNet能用量化后 10MB 的模型不要塞 300MB 的大模型。浏览器端侧算力有限很多场景“够用”比“最准”更重要。4. 工程化落地目录、构建、测试与发布4.1 从零搭建一个可维护的目录结构一个维护三个月以上的插件目录结构会决定你后期加功能的效率。我现在的标准结构长这样extension/ manifest.json src/ background/ index.ts handlers/ content/ index.ts styles.css popup/ index.html index.tsx options/ offscreen/ index.html inference.ts shared/ messages.ts storage.ts utils.ts assets/ models/ icons/ wasm/ tests/ unit/ e2e/ scripts/ build.mjs sign.mjs publish.mjs几个关键点manifest.json 里的版本号要和 package.json 同步。我吃过一次亏手动改了 package.json 但忘了改 manifest结果商店审核通过后线上行为和我本地测试版本完全不一样。后来用脚本在构建时自动从 package.json 读取版本号写入 manifest。content script 的 CSS 在 MV3 里可以通过 manifest 的content_scripts.css字段直接注入也可以运行时通过chrome.scripting.insertCSS动态注入。工程上建议把样式文件独立出来让构建工具单独输出而不是混进 JS bundle。4.2 构建用 Vite CRXJS 还是 Plasmo构建工具这块我两个都用过。Plasmo 的体验很顺内置了 HMR、content script 热更新、自动生成 manifest适合快速起步但如果项目已经有很多自定义配置它那种“我帮你做决定”的模式反而难受。我现在更倾向 Vite CRXJS因为它给了我完整的 Vite 能力。比如 content script 和 popup 可以分开配置入口模型文件、wasm 文件可以直接放进 public 目录构建完自动拷贝。一个比较典型的 vite.config.ts 片段import { defineConfig } from vite; import { crx } from crxjs/vite-plugin; import manifest from ./manifest.json; export default defineConfig({ plugins: [crx({ manifest })], build: { rollupOptions: { input: { popup: src/popup/index.html, offscreen: src/offscreen/index.html, }, }, }, });开发时vite dev会自动把构建产物加载到 Chrome并监听文件变化。content script 改动后浏览器插件会自动 reloadpopup 页面也能做到 HMR这套体验甚至比不少普通前端项目还要顺。4.3 自动化测试mock chrome API 才能测插件测试比普通前端测试麻烦因为大部分逻辑都依赖 chrome.* 全局对象。跑单元测试时Node 环境里根本没有 chrome所以我们得 mock。以 Vitest 为例我会在测试 setup 文件里做全局 mock// tests/setup.ts import { vi } from vitest; globalThis.chrome { runtime: { sendMessage: vi.fn(), onMessage: { addListener: vi.fn(), }, lastError: undefined, }, storage: { local: { get: vi.fn(), set: vi.fn(), }, }, } as any;然后专门测试消息路由是不是正确处理了各种情况import { describe, it, expect, vi } from vitest; import { handlers } from ../src/background/handlers; describe(GetPageInfo handler, () { it(returns page info correctly, async () { const payload { type: GET_PAGE_INFO, tabId: 1 }; const result await handlers.GET_PAGE_INFO(payload); expect(result.title).toBeDefined(); expect(result.url).toMatch(/^https?:\/\//); }); });现在很多团队的工程管道里已经接了 AI 自动写测试用例。AI 可以快速从 handler 的字段定义里推断出边界条件生成几十条测试参数。但我的态度是AI 生成初版可以review 一定不能省。因为 AI 特别擅长测“正常路径”却经常漏掉插件特有的场景比如 Service Worker 被唤醒后状态丢失、消息超时、扩展被更新导致上下文失效。这些坑还得靠人补上去。4.4 发布与更新流程发布是插件工程化里最容易被忽略的一环。Chrome Web Store 要求每次上传版本号必须严格递增否则直接返回错误。如果你在本地测试时改过版本号忘了同步发布流程就会卡住。我现在的发布脚本做三件事lint type check、单元测试、构建 zip。然后根据目标商店调用不同的上传脚本或者输出交付包。如果是企业内部分发通常会把 .crx 文件和更新配置文件发布到内部服务器扩展自己拉取更新。此时需要注意manifest 里update_url字段指向自己服务器的更新配置地址。用官方 key 签名 .crx 时要保存好私钥丢失后所有线上用户都收不到更新。权限申请一定要克制。很多浏览器在安装页都会展示权限警告权限越多用户安装率越低。做一个计算器插件却申请tabs和all_urls大多数用户会直接划走。在国内基于 Chromium 的浏览器生态里同一套扩展代码大多也能跑但各家商店的上传规则和审核尺度不完全一致发布前最好逐个确认。隐私政策、数据采集声明这些材料一定要在项目早期就准备好等到审核被拒再补至少浪费一周时间。5. 问题排查与避坑白名单5.1 Service Worker 总是被杀最常见的现象是长时间运行的后台逻辑执行到一半就没了日志里没有任何报错。原因是 MV3 Service Worker 基于事件驱动的空闲回收机制就算你的定时器还没跑完也可能被浏览器回收。排查思路分三步先看是不是定时器频率太高或没有在 onSuspend 前清理再看是不是有长任务阻塞了事件循环最后确认是不是把不该放后台的逻辑放在 Service Worker 里了。我的建议是任何超过 5 分钟的任务都不要直接放在 Service Worker 里能拆就拆。确实需要持续工作的用 Offscreen Document 或 Web Worker 承载让 Service Worker 只负责接收结果、更新状态。5.2 “Extension context invalidated” 怎么救用户刚更新完插件老页面上还跑着旧版本的 content script旧 context 被浏览器回收这时 content script 再发消息就会报Extension context invalidated。这种情况在开发环境也常见改完代码一 reload原来的页面就废了。我处理这个问题的思路是在 content script 里统一做一层保护检测到 context 失效后自动清理监听器并给出可恢复的提示而不是让用户手动刷新页面。chrome.runtime.onMessage.addListener(() { if (chrome.runtime?.id undefined) { // extension context has been invalidated cleanup(); return false; } });还有一个经验插件更新后最好用chrome.runtime.onInstalled事件主动重载一些关键页面或者提示用户刷新。尤其是 options 页面如果用户很久没关更新后很容易出现白屏。5.3 消息丢失、sendResponse 未执行MV3 里最容易踩的一个坑是在 onMessage 监听器里用了 async/await但忘了返回true表示会异步调用 sendResponse。Chrome 在较新版本里支持让 async 监听器直接返回 Promise但为了兼容旧版本还是建议显式返回true。另一个常见坑是监听到消息后代码里执行了一个非常耗时的同步操作导致消息返回超时。我的建议是所有消息处理都走封装好的消息总线不要直接在监听器里写复杂逻辑。还有一点content script 和 background 之间的消息如果目标页面还没有加载 content script消息会静默失败。你必须在发送前确保 content script 已经注入可以通过chrome.scripting.executeScript主动注入或者用chrome.tabs.sendMessage的回调里判断chrome.runtime.lastError再决定是否注入后重试。5.4 模型加载不出来的原因端侧 AI 模型加载失败常见原因有四类我按概率排序第一CSP 限制了远程资源加载。扩展页面默认有比较严格的内容安全策略如果你想从 CDN 加载模型要在 manifest 的 CSP 里显式放行或者干脆把模型打包进扩展。第二wasm 文件路径找不到。ONNX Runtime Web、MediaPipe 都需要把 wasm 文件放到可访问的位置经常有人忘了在构建时拷贝这些文件。第三WebGPU 初始化失败。部分旧驱动或远程桌面环境里WebGPU context 创建失败要写回退到 wasm 的逻辑。第四模型输入张量 shape 不对。图片缩放、通道顺序、归一化方式稍微不对推理结果就全错。我建议先用静态图片在本地把推理流程跑通再接入业务。5.5 调试技巧插件调试比普通前端多几个隐藏入口Service Worker 的 console 不在页面里要去chrome://extensions找到你的扩展点击“Service Worker”链接单独打开。content script 的调试需要回到对应页面在 DevTools 里选择运行上下文切到扩展 ID 对应的 context。Offscreen Document 不能直接打开可以在 background 里临时打印它的页面 URL或者用chrome://inspect看扩展内部的页面。popup 页面单击右键选择“检查”才能打开 DevTools直接按 F12 很多时候只能看到页面本身的控制台。我会在代码里埋一个调试开关只有开发环境或带上?debug1时才输出详细日志生产环境默认静默。如果线上有问题再通过远程日志上报模块把关键节点打点回到服务端。这样既能保留现场又不会刷爆用户控制台。最后再分享一个小经验做插件工程化最重要的不是你会用多少框架而是能不能先画清楚模块边界和通信图。每加一个跨上下文的消息类型都要问自己一句这个消息谁发、谁收、接收方怎么校验、其他模块能否复用。第一次画的时候可能会觉得麻烦但项目超过三个月你就知道这张图有多值钱。
返回列表