ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MV3 下 30+ 工具浏览器扩展的架构设计与踩坑实践

MV3 下 30+ 工具浏览器扩展的架构设计与踩坑实践

1. 一个“什么都装”的扩展,在 MV3 落地时到底难在哪

浏览器扩展这个赛道,这两年最明显的变化不是功能变多,而是运行环境变严。以前做一个前端小工具合集,思路很简单:后台页常驻、随便引个 CDN 上的库、需要操作页面就直接往 DOM 里塞脚本。现在这套打法基本走不通了。FeHelper 这类“30+ 开发者工具全家桶”的扩展,恰好站在所有约束的交汇点上——它工具数量多、场景分散、既要处理页面内容又要独立计算,任何一个架构决策失误,都会在后续加第 31 个工具的时候集中爆发。

我把这类扩展的核心矛盾总结成一句话:用户要的是“点开就有、什么都能干”,平台给的却是“随时可能被回收、不许动态加载、权限要一项项申请”。这两者之间的落差,就是整个工程实践要填的坑。

适合读这篇的人大概分三类:正在维护或准备重写一个工具型扩展的开发者;想搞清楚 MV3 到底改变了什么、为什么老代码迁移过来会崩的前端;以及单纯想知道自己每天用的那个小图标背后跑着什么逻辑的技术好奇者。下面我不讲“扩展是什么”这种入门内容,直接按我实际拆解和复现这类项目时的顺序,把架构取舍、模块切分、注入链路和踩过的坑一条条摊开。

1.1 从 MV2 迁到 MV3,最疼的不是改 API 而是改思路

很多人以为迁移就是把手里的chrome.extension.getBackgroundPage()换成chrome.runtime.sendMessage(),把browser_action改成action,改完跑通就完事。真正动手之后会发现,API 层面的改动其实是最省事的部分,真正伤筋动骨的是生命周期假设被推翻了。

MV2 的后台页是一个真实的 HTML 页面,有window、有document、有自己的全局变量,你可以把它当成一个小型单页应用来写:初始化时把配置读进内存,之后所有工具都从这份内存里取数据,页面不关,状态就在。MV3 换成了 Service Worker,它是事件驱动 + 空闲回收的模型,官方文档里的说法是空闲一段时间后会被终止,实际上在调试时你会看到它反复启动、反复销毁。你在顶层写的let cache = {},下一次事件进来时很可能已经是undefined。

这个变化直接决定了工具类扩展的架构形态。我的处理原则是三条:

  • 任何需要跨事件存活的数据,一律落到chrome.storage,内存里的变量只作为单次事件内的临时变量。
  • Service Worker 只做路由和调度,不承担计算密集型任务,复杂逻辑要么放到 popup 页面,要么放到独立标签页,要么用 offscreen document。
  • 所有初始化逻辑必须是幂等的,因为同一个初始化函数在一天内可能被执行几十次。

举个具体的例子。FeHelper 里有一类功能是“记住上次用的工具”。在 MV2 时代这就是后台页的一个变量,切过去就读。到了 MV3,正确做法是把lastToolId写进chrome.storage.local,popup 打开时发一条消息或在本地直接读 storage。注意这里有个细节:popup 页面自己是有 DOM 的,可以直接调chrome.storage,不必绕道 Service Worker,绕道反而多了一次消息往返和一次 SW 唤醒,用户感知就是“点开图标要等一下才出来”。

1.2 后台页没了,30+ 工具的状态该寄存在哪

工具类扩展的状态其实分三种,混在一起存是灾难的开始。

状态类型典型内容存放位置理由
配置类主题、默认打开方式、快捷键、工具排序chrome.storage.sync需要跨设备一致,量小
缓存类上次输入的 JSON、历史记录、临时草稿chrome.storage.local量大、不需要同步、可随时清
会话类当前正在编辑的内容、滚动位置页面内存 /sessionStorage关掉就该消失,写盘纯属浪费

这里最容易踩的是第二类。很多实现图省事,把用户粘贴的一大段 JSON 直接写进storage.sync,结果单个 key 超过 8KB 就报QUOTA_BYTES_PER_ITEM quota exceeded,而且这个错误在代码里是异步抛的,没加chrome.runtime.lastError检查的话,表现为“功能偶尔不保存”,极难排查。storage.sync的总配额是 100KB 左右,单条 8KB,这个数字记住没坏处。

还有一个更隐蔽的点:storage.local在 MV3 下的默认配额比很多人以为的要小,装了大量数据后会写入失败。如果确实要存大对象(比如一堆历史快照),可以在 manifest 里申请unlimitedStorage权限,但要注意这个权限在商店审核时可能会被问用途,能不用就不用,改成“只保留最近 N 条”更稳妥。

1.3 远程代码被禁之后,工具逻辑只能全部打进包里

MV2 的时候,有些扩展会在运行时从网络上拉一段脚本进来执行,用来做插件化。MV3 的 CSP 直接把这条路封死了:不允许eval,不允许new Function,不允许加载远程脚本。这条规则对“工具全家桶”影响巨大,因为它意味着你不能再靠“云端工具列表 + 动态加载”来做扩展性。

那怎么办?答案是编译期注册。所有工具在构建时就已经确定,运行时的“动态”只体现在 UI 层——显示哪些、隐藏哪些、排序如何,这些都是数据,不是代码。这实际上是一件好事:包体可控、行为可预测、审核风险低。代价是每加一个工具都要重新发版,所以工具的定义必须做得足够“薄”,让新增成本降到只写一个配置对象 + 一个组件。

我见过一些实现试图用chrome.scripting.executeScript的字符串形式来绕开限制,把函数体当字符串传进去。这种写法在 MV3 下仍然可用(func参数是函数引用,序列化后注入),但它注入的是页面世界的脚本,不能拿来加载你自己的业务逻辑,而且调试体验极差,栈信息对不上。工具逻辑老老实实打包进 bundle,是唯一可持续的方案。


2. 工具模块的切分逻辑:哪些该做成常驻,哪些只能按需

30 多个工具塞进一个扩展,最怕的不是写不出来,而是写出来之后互相拖累。我见过一个扩展,启动时要初始化全部工具,结果点开图标要等两秒。工具切分的核心问题不是“功能怎么分类”,而是“谁在什么时候被加载”。

按加载时机切,FeHelper 这类工具箱大致能分出四层,这个分层比按“格式化 / 转换 / 生成 / 查看”这种功能分类更有工程价值。

2.1 四类工具的划分与各自的加载时机

第一层是入口层,也就是点图标弹出的那个面板。它必须极轻,只包含工具列表、搜索框和最近使用记录。这一层的目标是把首屏时间压到 100ms 以内,所以它是唯一必须随扩展常驻加载的 UI 代码。

第二层是瞬时计算类,比如 JSON 格式化、Base64 编解码、URL 编解码、时间戳转换、哈希计算、UUID 生成。这些工具的共同特点是:输入即输出、无副作用、单次计算在几十毫秒内完成。它们适合做成同一个页面内的多个 Tab,用动态import()按需加载各自的组件,用户点哪个 Tab 才加载哪个。

第三层是有状态编辑类,比如正则测试器、Markdown 预览、Crontab 表达式生成、SQL 格式化。这类工具有复杂的交互状态,用户可能会在里边待很久,而且需要较大的编辑区域。它们不适合塞在 popup 里,应该打开独立标签页。

第四层是与页面强交互类,比如取色器、元素信息查看、页面二维码生成、清除页面缓存。这类工具必须注入到当前页面上下文里,依赖 content script,加载时机完全由用户在那个页面上的操作触发。

这四层的界线一旦划清楚,后面加工具就是“填格子”而不是“重新设计”。

2.2 JSON 格式化这类“重工具”的性能红线

JSON 格式化看起来是最简单的工具,实际上是整个扩展里最容易出性能事故的地方。原因很直接:用户会往里粘贴几 MB 的接口返回。

朴素实现是JSON.stringify(JSON.parse(str), null, 2)然后塞进<pre>。10KB 以内没问题,500KB 开始页面卡顿,2MB 直接让标签页无响应。问题的根源不在解析,在渲染——几万个 DOM 节点,浏览器排版就崩了。

我的处理经验是设几条硬阈值:

  • 小于 200KB:直接语法高亮 +<pre>渲染,够用。
  • 200KB 到 5MB:关闭语法高亮,改用纯文本渲染,或者做虚拟滚动,只渲染视口内的行。
  • 超过 5MB:不渲染全文,只做结构校验 + 显示节点数量统计,提示用户“内容过大,仅做格式校验”。

这些阈值不是拍脑袋定的,是我在不同机器上实测出来的拐点,机器性能差异会有浮动,但量级差不多。判断依据很简单:打开开发者工具,看一次粘贴后的Recalculate Style和Layout耗时,超过 200ms 就必须降级。

还有一个常被忽略的点:格式化操作要不要放进 Worker。如果只格式化一次,主线程几十毫秒能接受;但如果做成“输入框实时格式化”,那就必须放进 Web Worker,否则每敲一个字符就解析一遍,输入框会明显掉帧。扩展里用 Worker 有个额外好处:它不占用 Service Worker 的生命周期,独立运行。

2.3 编码转换、哈希、UUID 这类“无状态工具”为什么最好做成独立页面

这类工具有个共同特征:用户经常需要“打开一个干净的环境”。比如要算一段文本的 MD5,不希望它和别的内容混在一起;要生成一批 UUID,希望结果是可复制、可保存的。

如果把它们做成 popup 里的 Tab,会遇到两个现实问题:一是 popup 一旦失去焦点就自动关闭,用户切到编辑器复制内容再切回来,输入全没了;二是 popup 的尺寸有上限(宽高都受限),展示长列表或者批量结果非常难受。

所以我的做法是把这类工具统一收敛到一个tool.html?id=hash这样的页面里,用查询参数决定渲染哪个工具。好处是:

  • 用户可以开多个标签页同时用不同工具,互不干扰。
  • 页面可以用书签收藏、可以用浏览器前进后退。
  • 不受 popup 尺寸限制,布局自由度大。
  • 关闭时才销毁,不会因为失焦丢状态。

代价是多了一次页面加载,但工具页本身很轻,加上浏览器缓存,二次打开几乎是瞬时的。这个取舍在实际使用中是被验证过的:高频轻交互放 popup,低频重交互开页面。


3. 页面注入链路:从点击图标到工具界面出现之间发生了什么

用户点一下工具栏图标,看起来只是弹出一个面板,实际上背后是一条完整的调用链。搞清楚这条链,才能解释为什么有些扩展“点了没反应”,以及为什么某些工具在特定网站上会失灵。

3.1 popup 的尺寸天花板与“浮层化”改造

manifest 里声明的 popup 是一个独立的 HTML 页面,它的可用宽高有硬性限制,经验值是宽度 800px、高度 600px 以内,超出部分会被裁掉或者出现滚动条。这对工具面板来说很尴尬:工具一多,列表就要滚动;设置项一多,就要挤在很小的空间里。

一个常见的改法是把 popup 做成“入口菜单”,把内容做成“注入式浮层”——点击图标后,popup 只显示工具列表,用户选中某个工具时,通过chrome.scripting.executeScript往当前页面注入一个浮层 UI。这样视觉上工具是“贴在页面上”的,不受 popup 尺寸限制,还能拖动。

但这个方案有个明显的副作用:浮层属于页面,用户切换标签页它就没了。所以它适合“一次性操作”的工具(比如取色、测量元素尺寸),不适合“需要长时间编辑”的工具。我见过一些实现把 JSON 格式化也做成浮层,体验其实很糟糕——用户想复制结果去别的地方,一切换标签就丢了。

提示:浮层方案的另一个坑是样式隔离。注入到页面的 DOM 会继承页面自己的 CSS,很可能变成一团乱。解决办法是给浮层根节点加一个高优先级的重置样式,或者直接用 Shadow DOM 把样式封起来。

3.2 content script、注入脚本与隔离世界的边界

这里有个概念必须先分清,否则后面全是糊涂账。

content script是扩展向页面注入的脚本,它运行在“隔离世界”(isolated world)里。它能访问页面的 DOM,但访问不到页面自己的 JS 变量和函数——页面的window.jQuery在 content script 里是undefined。这个设计是为了安全和避免冲突,但很多工具恰恰需要读页面的全局对象。

要突破这个边界,就得再往外注入一层页面世界脚本。MV3 提供了两种方式:

一种是传统的,通过web_accessible_resources暴露一个 JS 文件,然后在 content script 里创建一个<script src="chrome-extension://.../inject.js">标签插进页面。缺点是需要在 manifest 里声明资源,而且脚本加载是异步的,时序不好控。

另一种是现在更推荐的方式,用chrome.scripting.executeScript并指定world: "MAIN":

await chrome.scripting.executeScript({ target: { tabId }, world: "MAIN", func: () => { // 这里的代码运行在页面世界,可以读到页面的全局变量 return { title: document.title, hasJQuery: typeof window.jQuery !== "undefined" }; } });

这两种方式的选择标准很简单:需要和页面 JS 通信就选 MAIN,只需要操作 DOM 就用 content script。绝大多数工具其实只需要后者,别为了省事一律上 MAIN,那样容易和页面的脚本打架。

3.3 消息通道设计:一次性请求还是长连接

扩展里各个部件之间的通信,最容易写乱。我见过一个项目里同时存在chrome.runtime.sendMessage、chrome.tabs.sendMessage、chrome.runtime.connect三种通道,同一个数据从 popup 传到页面要走三层转发,出问题根本没法定位。

我的建议是按通信模式分,不要按模块分:

通信场景推荐方式说明
popup 请求一次数据sendMessage+ Promise 封装一问一答,用完就断
向 content script 下发指令tabs.sendMessage必须带tabId,注意目标页可能未注入
持续的双向通信(如实时同步编辑内容)runtime.connect长连接注意端口断开要重连
Service Worker 主动通知页面sendMessage广播接收方要判断消息来源

这里有个非常容易踩的坑:tabs.sendMessage在目标页面没有 content script 时会抛Could not establish connection. Receiving end does not exist。这个错误在开发时经常出现,因为用户可能是在扩展刚安装、页面还没刷新的状态下操作。正确做法是调用前先ping一次,失败就手动注入再重试:

async function ensureContentScript(tabId) { try { await chrome.tabs.sendMessage(tabId, { type: "PING" }); } catch (e) { await chrome.scripting.executeScript({ target: { tabId }, files: ["content.js"] }); } }

这段代码不长,但能省掉大量“用户反馈说点了没反应”的工单。


4. 30+ 模块怎么管:一份注册表撑起整个工具箱

工具多了之后,真正的敌人是维护成本。如果每加一个工具都要改五个地方(manifest、路由、菜单、构建配置、权限),那这个项目很快就会没人愿意动。

4.1 用配置驱动 UI,而不是写 30 个页面

我的做法是所有工具共用一份注册表,一个工具就是一个对象:

export const tools = [ { id: "json-format", name: "JSON 格式化", category: "format", keywords: ["json", "格式化", "压缩", "校验"], mode: "page", // popup | page | inject entry: () => import("./tools/json-format/index.js"), permissions: [] }, { id: "color-picker", name: "取色器", category: "page", keywords: ["颜色", "吸管", "rgb", "hex"], mode: "inject", entry: () => import("./tools/color-picker/index.js"), permissions: ["activeTab"] } ];

这份注册表同时承担了五个职责:渲染工具列表、提供搜索关键字、决定加载方式、生成懒加载入口、汇总权限需求。加一个新工具,只需要往数组里加一项,再写一个导出mount(el)的模块,其他都不用动。

权限汇总这一条值得单独说。扩展的权限是静态声明的,用户安装时就看到了,不能运行时增加(除了optional_permissions)。所以把每个工具需要的权限写在注册表里,构建时用一个脚本扫一遍,自动生成 manifest 的permissions和host_permissions,能有效避免“加了新工具忘了改 manifest”这种低级错误。我甚至建议在 CI 里加一条检查:注册表里声明的权限集合必须和 manifest 里的完全一致,不一致就构建失败。

4.2 打包策略:多入口、代码分割与体积预算

这类扩展的打包配置,核心是三件事:多入口、按需分割、体积可控。

多入口是因为 popup、独立工具页、content script、Service Worker 是四个完全不同的运行环境,不能打成一个 bundle。content script 尤其要注意,它在 MV3 下默认不支持 ESM(除非显式声明"type": "module"),保险起见打成 IIFE 格式。

代码分割主要针对工具模块。上面注册表里用的是() => import(...),打包器会自动把每个工具拆成独立的 chunk,用户不点就不加载。这一步对首屏速度影响很大——30 个工具的代码全打进主包,主包轻松超过 2MB,加载和解析都要时间。

体积预算我给自己定的线是:主包(popup + Service Worker + 公共依赖)控制在 300KB 以内,单个工具 chunk 不超过 150KB。超了就要查是谁把大库引进来了。最常见的体积杀手是语法高亮库、图表库和日期库,这几个基本都能找到轻量替代或者按需引入的写法。

还有一个细节:公共依赖要提取成 shared chunk。如果不提,每个工具 chunk 里都会重复打包一份工具函数,30 个工具加起来能多出好几 MB。这个配置在 webpack 里是splitChunks,在 Vite 里是build.rollupOptions.output.manualChunks,写法不同但思路一样。

4.3 权限收敛与发布前的自查清单

权限这块,我的态度是能不申请就不申请。用户看到“读取您在所有网站上的数据”这种提示,安装意愿会掉一大截。几个实用的收敛手段:

  • 用activeTab代替宽泛的<all_urls>。用户主动点击图标才临时获得当前页权限,安装时不会弹吓人的提示。
  • 需要常驻注入的场景,把matches收窄到具体域名,而不是*://*/*。
  • 用optional_host_permissions把不常用的站点权限做成运行时申请。
  • 别申请tabs权限,如果只是想拿当前标签页的 URL,activeTab就够。

发布前我会过一遍这张表:

检查项目的
manifest 里是否有未使用的权限减少审核风险和安装阻力
是否有硬编码的eval或new FunctionMV3 下会直接报错
content script 的matches是否过宽影响性能和权限提示
图标尺寸是否齐全(16/32/48/128)缺了在部分场景显示模糊
是否有内联脚本或内联事件处理器MV3 CSP 会拦截
Service Worker 是否处理了重复初始化避免状态错乱

5. 踩坑实录:几个只会发生在扩展环境里的报错

有些问题在普通网页开发里根本遇不到,只会在扩展这个特殊容器里冒出来。这几个坑我印象最深,排查过程也最能说明扩展环境的特殊性。

5.1 Service Worker 被回收导致的“状态丢失”

现象是:用户在工具页里操作了一会儿,回来点扩展图标,发现之前的设置回到了默认值。日志里看不到任何报错。

排查链路是这样的:先怀疑 storage 没写进去,加了日志发现写入是成功的;再怀疑读取时 key 不对,检查了也正常;最后在 Service Worker 的顶层加了一行启动日志,才发现它在这一小时内重启了十几次。

根因很清楚:那个实现把配置缓存在 SW 的模块级变量里,写入时更新了缓存也写了 storage,但读取时优先读缓存。SW 一被回收,缓存清空,读到的就是初始值——而初始值的赋值逻辑写在模块顶层,重新执行时覆盖了本该从 storage 读出来的数据。

修复方案有两层:一是读的时候永远以 storage 为准,缓存只作为同一次事件内的加速;二是顶层不要写有副作用的初始化逻辑,改成懒加载函数。

let cache = null; async function getConfig() { if (cache) return cache; const { config } = await chrome.storage.local.get("config"); cache = config ?? DEFAULT_CONFIG; return cache; }

这个写法看着简单,但它把“缓存失效”这件事变成了不可能出错——因为缓存从来不假设自己长期有效。

5.2 存储超限与 sync 配额

前面提过storage.sync的配额问题,这里补一个更隐蔽的场景:并发写入导致的覆盖。

有些实现会在用户每次输入时都写一次 storage,做“自动保存”。如果两次写入间隔很短,后一次读到的是旧值(因为前一次还没写完),就会出现“改了半天只保存了最后一小段”的现象。这不是配额问题,是读-改-写不是原子操作。

解决办法是加一个写入队列,把同一 key 的写操作串行化:

const queues = new Map(); function enqueue(key, task) { const prev = queues.get(key) ?? Promise.resolve(); const next = prev.then(task, task); queues.set(key, next.catch(() => {})); return next; }

另外,自动保存要做防抖,输入停止 500ms 再写,既省 IO 又避免大量小写入把配额撑爆。这一条在任何有自动保存功能的产品里都成立,但在扩展里尤其重要,因为 storage 的写入是跨进程的,比 localStorage 慢得多。

5.3 打包后某些工具在特定站点失效

这个问题的表现很迷惑:本地开发时一切正常,打包发布后,在部分网站上点工具没反应,其他网站上完全正常。

第一反应是权限问题,检查了matches没问题;第二反应是注入失败,加了onerror监听,发现注入脚本确实没执行;最后打开那个网站的开发者工具,在 Console 里看到了 CSP 报错——那个网站自己设置了严格的Content-Security-Policy,把外来的脚本标签拦掉了。

这是通过<script src>注入页面世界脚本的固有缺陷。修复方式是改用chrome.scripting.executeScript的func方式,它走的是扩展的注入通道,不受页面 CSP 限制:

// 不推荐:受页面 CSP 影响 const s = document.createElement("script"); s.src = chrome.runtime.getURL("inject.js"); document.documentElement.appendChild(s); // 推荐:走扩展注入通道 await chrome.scripting.executeScript({ target: { tabId }, world: "MAIN", files: ["inject.js"] });

顺带说一句,这个坑还牵连到另一个问题:如果你在 content script 里用了fetch请求扩展自己的资源,某些站点的 CSP 也会拦。遇到这种情况,把请求挪到 Service Worker 里发,再把结果传回 content script,是最稳的绕法。


6. 如果你想自己搭一个同类的工具箱

看完上面这些,如果打算动手做一个自己的工具集合,我给一条尽量少走弯路的路径。

6.1 先定边界,别一上来就想做 30 个

第一个版本只做三个工具,但要选得讲究:一个纯计算的(比如时间戳转换)、一个需要页面的(比如取色)、一个需要常驻状态的(比如便签)。这三个刚好覆盖三种运行环境,能把注入链路、消息通道、存储方案全部跑通。等这套骨架稳定了,后面加工具就是复制粘贴的事。

同时把注册表、manifest 生成脚本、打包配置这三件事在第一天就定下来。这三样东西后期改的成本极高,前期做对能省下几十个小时。

6.2 开发调试时值得开的几个开关

chrome://extensions页面上有几个选项,实际开发时非常有用:

  • 开发者模式:必须开,否则看不到“加载已解压的扩展程序”。
  • Service Worker 的独立调试窗口:点“检查视图 service worker”,能单独看它的日志,不然 SW 里的报错你根本看不到。
  • 扩展程序错误收集:页面顶部会汇总所有部件的报错,包括 content script 注入失败的提示。
  • 重新加载按钮:改完 manifest 必须点这个,改业务代码则看情况——popup 和工具页刷新页面就生效,content script 必须刷新目标页面。

还有一个实用技巧:给扩展的各个部件统一加一个DEBUG前缀的日志函数,只在开发构建里输出。扩展的日志分散在四五个不同的控制台里,没有统一前缀的话,排查时会在多个窗口之间反复横跳。

最后分享一个我自己踩出来的经验:别在 popup 里做耗时操作。popup 的生命周期和它的可见性绑定,一旦用户点到别处,页面就销毁,正在跑的异步任务全部中断。任何超过 100ms 的操作都应该挪到独立页面或者 Service Worker 里,popup 只负责发指令和展示结果。这个约束听起来烦,但它逼着我把每个工具的职责切得更清楚,反而让整个架构比以前干净多了。扩展这个环境就是这样,限制越多的地方,逼出来的设计往往越经得起用。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进