1. 先想清楚:30 多个小工具为什么要住进同一个浏览器扩展
做前端这行干久了会发现,真正消耗时间的往往不是写业务代码,而是那些零碎到不值得打开 IDE 的小动作:接口返回一坨压缩过的 JSON 要格式化、日志里的时间戳要转成人能看懂的时间、一段中文要转成 URL 编码、设计给的色值要算对比度、二维码要生成一张贴到文档里、正则写完要立刻验证边界。这些操作单次只花几十秒,一天下来能积累出一两个小时,而且每次都要在十几个网页工具之间来回跳,粘贴内容、点按钮、复制结果,中间还可能把敏感数据贴到不知名的第三方站点上去。FeHelper 前端助手这类"工具全家桶"型扩展,本质上就是把这十几个高频动作收进浏览器的右上角,点一下就能用,且完全离线。这篇文章不打算写成产品说明书,而是把这类扩展从架构选型、MV3 约束、工具模块插件化、到具体工具的坑和工程化实践整个拆一遍,适合两类人看:一是想自己撸一个私人工具箱的前端,二是手上已经有一个几十个页面的扩展、正在被维护成本折磨的开发者。
1.1 先说清楚这类工具的用户画像和使用场景
我观察过自己和周围同事的使用习惯,这类扩展的真实使用场景高度集中在三类人身上。第一类是后端和全栈,他们最常用的是 JSON 格式化、时间戳转换、Base64 编解码、URL 编解码、SQL 参数拼接,频率高到可以形成肌肉记忆。第二类是前端和客户端,用得更多的是正则测试、颜色转换、CSS/JS 压缩美化、图片转 Base64、二维码生成、Markdown 预览。第三类是测试、运营和文档同学,他们其实只用到其中五六个工具,但恰恰是这五六个人最依赖"随手可用",一旦要求他们打开某个网站,使用率会掉一大半。
这个画像直接决定了架构:工具数量多但单个工具极轻,用户只常用其中一小部分,绝大多数工具在一年里可能被点开三次。所以设计的核心矛盾不是"功能怎么实现",而是"如何让 30 个工具的代码在用户不点它的时候完全不产生成本"。很多自研工具箱最后烂掉,都是因为一开始把所有工具写进了同一个 popup 里,首屏加载了 3MB 的 JavaScript,点开要等两秒,用户用了三次就再也不点了。
1.2 为什么是浏览器扩展,而不是一个工具站
这个问题我被问过很多次,答案其实很朴素:扩展能拿到网页上下文,工具站拿不到。举个具体例子,右键选中页面上一段 JSON 直接格式化、把当前页面所有图片地址抓出来、读取当前标签页的 URL 做编码、在页面里注入一个取色器,这些动作在纯网页工具站上要么做不了,要么需要用户手动复制粘贴。扩展天然拥有activeTab授予的临时权限,能在用户主动触发的那一刻访问当前页面,这是网页端无论如何都绕不过去的坎。
第二个原因是离线与私密。工具站的每一次粘贴都要经过一次网络请求,哪怕对方声明不记录,用户心里那道坎也在。扩展的所有逻辑都在本地 JavaScript 里跑,断网、内网环境、处理生产环境的数据都不需要额外心理建设。这一点对于处理日志和配置文件的场景极其重要,我在内网环境里就吃过工具站打不开的亏。
第三个原因是入口成本。扩展可以固定到工具栏,一次点击直达,还支持快捷键、右键菜单、选中文本直接调用。工具站需要切标签页或者开新窗口,多出来的这两步在一天几十次的频率下就是实打实的损耗。
1.3 三种架构方案的实际对比
在动手之前,我把当时能想到的方案摆在一起比了一遍,这里直接给结论性的对比表,方便你判断自己该走哪条路。
| 方案 | 首屏体积 | 工具扩展成本 | 维护难度 | 适合场景 |
|---|---|---|---|---|
| 单页全量打包(一个 popup 塞所有工具) | 2~5MB | 极低,加个组件就行 | 前期低,后期高 | 工具数 5 个以内 |
| 单页 + 动态 import 懒加载 | 首屏 200~400KB | 低,注册一行配置 | 中 | 工具数 10~40 个,推荐 |
| 每个工具独立 HTML 页 + 独立入口 | 首屏最小 | 中,要配构建多入口 | 中高,构建配置复杂 | 工具重、页面上限明确 |
| 独立扩展拆分(一个工具一个扩展) | 每个都最小 | 极高 | 极高 | 不推荐,用户体验割裂 |
我最终选的是第二种:一个主壳(popup 或侧边栏)加一个工具注册表,每个工具一个目录、一份配置、一个懒加载入口。第三个方案看起来更"工程化",但实际做下来会发现构建配置的复杂度增长远超收益,而且用户要在工具栏里放十几个图标,体验很糟。第二种方案的关键就是下一节要讲的 MV3 架构和注册表设计。
1.4 MV3 带来的硬约束,必须在设计阶段就认账
从 MV2 迁到 MV3,最疼的三件事:后台从常驻的 background page 变成随时可能被回收的 Service Worker;CSP 禁止eval和远程脚本,所有第三方库必须本地打包;webRequest的阻塞式能力被declarativeNetRequest取代。这三条约束对工具型扩展的影响是实实在在的。
第一条意味着你不能把"工具的运行状态"存在后台内存里,比如用户在一个工具里做到一半的草稿,必须落到chrome.storage。第二条意味着那些依赖new Function的模板引擎、老版本的 JSON 高亮库、动态加载远程 CDN 的图表库全部得换掉或者自己改。第三条对纯工具型扩展影响最小,因为我们几乎不做网络拦截,但如果你打算加一个"请求头修改"工具,那就得重新设计。
注意:MV3 的 Service Worker 没有
window,没有 DOM,没有localStorage,只有self、fetch、indexedDB和扩展 API。任何在后台里画图、解析 HTML、用 DOM 做正则辅助的代码,迁移时都会直接报错。
2. MV3 架构拆解:manifest、Service Worker 与运行上下文
2.1 manifest 关键字段逐条落地
manifest 是整个扩展的宪法,写错一个字段可能导致功能静默失效。下面这份是我实际在用的精简版结构,字段都做了删减但保留了关键项,可以直接对照修改。
{ "manifest_version": 3, "name": "前端助手", "version": "3.4.1", "description": "30+ 前端常用工具,全离线运行", "action": { "default_popup": "pages/popup.html", "default_icon": { "32": "icons/32.png", "128": "icons/128.png" } }, "background": { "service_worker": "sw.js", "type": "module" }, "side_panel": { "default_path": "pages/sidepanel.html" }, "permissions": ["storage", "contextMenus", "clipboardWrite", "activeTab", "scripting"], "optional_permissions": ["tabs", "downloads"], "host_permissions": [], "content_scripts": [ { "matches": ["<all_urls>"], "js": ["content/inject.js"], "run_at": "document_idle", "all_frames": false } ], "commands": { "open-panel": { "suggested_key": { "default": "Alt+Shift+F" }, "description": "打开工具面板" } }, "content_security_policy": { "extension_pages": "script-src 'self'; object-src 'self'" } }几个字段值得单独说。background.type设成module之后后台可以直接用 ES 模块语法,省掉打包器的部分工作,但要注意所有 import 路径必须带扩展名,浏览器不会帮你补.js。host_permissions我留空,因为工具型扩展在 95% 的场景下不需要预先声明任何站点权限,用activeTab就够了,用户在工具栏点击或按快捷键时才会临时获得当前标签页的访问权,这一条能显著降低商店审核阻力和用户的第一眼警惕。content_scripts匹配<all_urls>看起来权限很大,但如果脚本本身只是注册一个消息监听、不做任何 DOM 读写,实际风险是可控的;更保守的做法是完全不声明 content_scripts,需要时用scripting.executeScript按需注入,代价是多一次异步往返。
2.2 Service Worker 的"随时会死"到底怎么应对
这是踩得最深的一个坑。MV3 的后台在空闲约 30 秒后就会被终止,任何存在全局变量里的状态都会消失,包括定时器、缓存、连接。我第一次迁移时犯的错是把"当前选中的工具 ID"存在后台的一个模块变量里,结果用户切走标签页两分钟回来,工具状态莫名其妙重置了。
正确的处理方式是建立一条原则:后台只做无状态的消息中转和 API 调用,所有需要跨会话存活的数据一律写chrome.storage。具体到代码,我用一个小小的封装把所有持久化收口:
// shared/store.js const cache = new Map(); export async function get(key, fallback = null) { if (cache.has(key)) return cache.get(key); const res = await chrome.storage.local.get(key); const val = res[key] ?? fallback; cache.set(key, val); return val; } export async function set(key, value) { cache.set(key, value); await chrome.storage.local.set({ [key]: value }); } // 后台被唤醒时重新挂监听,而不是依赖内存 chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { if (msg.type === 'ping') { sendResponse({ ok: true, ts: Date.now() }); return false; } });注意这里有个细节:cache这个模块级 Map 在后台被杀掉后同样会丢,它只是用来减少同一生命周期内的重复读,不能当真相源用。真正需要一致性的时候直接读chrome.storage,不要偷懒。
关于"保活",网上流传的若干手法(定时chrome.runtime.getPlatformInfo、空setInterval、无限alarms)我都不推荐。chrome.alarms的最小周期在正式版里被限制在 1 分钟左右,用它强行保活属于滥用权限,而且商店审核时会被重点关照。工具型扩展根本没有需要常驻后台的场景:所有计算在页面上下文里跑,后台只负责转发消息,被杀掉就被杀掉,下次事件来了自动唤醒,用户完全无感。
2.3 权限最小化与按需申请
权限是这类扩展最容易翻车的地方。用户看到"读取和更改您在所有网站上的数据"会本能地犹豫,商店审核也会因为过宽的权限反复退回。我的做法是把权限分成三档:
| 权限档位 | 具体项 | 申请时机 | 说明 |
|---|---|---|---|
| 必备 | storage、contextMenus、clipboardWrite、activeTab | 安装时声明 | 工具型扩展的最小集合 |
| 可选 | tabs、downloads、scripting | 用户触发对应工具时申请 | 用chrome.permissions.request |
| 敏感 | <all_urls>站点权限 | 尽量不申请 | 除非做页面增强类功能 |
按需申请的写法很简单,但必须放在用户点击的事件回调里,否则浏览器会直接拒绝:
async function ensureTabsPermission() { const has = await chrome.permissions.contains({ permissions: ['tabs'] }); if (has) return true; return chrome.permissions.request({ permissions: ['tabs'] }); } document.querySelector('#btn-tab-info').addEventListener('click', async () => { if (!(await ensureTabsPermission())) { toast('未授权,无法读取标签页信息'); return; } const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); renderTabInfo(tab); });这套流程下来,安装时用户看到的权限提示只有三四项,接受率明显提高。代价是每个可选权限的工具第一次点击要多一次弹窗确认,这个体验损失是值得的。
2.4 四个运行上下文之间的消息设计
MV3 下同一份代码可能跑在四个地方:popup、side panel、content script、service worker。它们之间的通信是整个扩展最容易写出玄学 bug 的部分,因为 popup 关掉之后就不存在了,content script 可能根本没注入,后台可能刚好被杀掉重启。我的做法是定义一个统一的协议文件,所有消息类型集中管理,禁止在业务代码里手写字符串。
// shared/protocol.js export const MSG = { COPY_TEXT: 'copy:text', GET_ACTIVE_TAB: 'tab:active', RUN_IN_PAGE: 'page:run', OPEN_SIDE_PANEL: 'panel:open', }; // 统一的发送封装,处理"接收方不存在"的情况 export async function send(type, payload = {}) { try { const res = await chrome.runtime.sendMessage({ type, payload }); if (!res?.ok) throw new Error(res?.error || 'unknown'); return res.data; } catch (e) { console.warn('[msg failed]', type, e.message); return null; } }消息链路的走向我整理成了这样一张表,调试时按表排查基本不会迷路:
| 发起方 | 接收方 | 典型用途 | 注意事项 |
|---|---|---|---|
| popup / side panel | service worker | 申请权限、转发页面操作 | 后台可能在启动中,需容错重试 |
| popup / side panel | content script | 读写当前页面 DOM | 目标页面可能禁止注入,要降级 |
| content script | service worker | 上报数据、请求工具计算 | 用sendResponse时返回true |
| service worker | popup | 主动推送状态更新 | popup 关闭时消息会丢,需自行兜底 |
最后一行是很多人忽略的:popup 是一个随时会消失的页面,后台往它推消息,用户点了别处再点回来,那条消息就永远收不到了。所以凡是"状态",都要从storage里读,而不是靠消息推送。
3. 工具模块的插件化:让第 31 个工具只花 20 分钟接入
3.1 注册表的数据结构决定了扩展性
这一步是整个项目里性价比最高的设计。我在第二版重构时把 30 个工具全部拆成独立目录,每个目录有一个meta.js和一个入口模块,主壳只认注册表。注册表里每个工具的描述长这样:
// tools/registry.js export const TOOLS = [ { id: 'json-format', name: 'JSON 格式化', category: 'format', keywords: ['json', '格式化', '美化', '校验'], icon: 'braces', shortcut: 'mod+shift+j', loader: () => import('./json-format/index.js'), }, { id: 'timestamp', name: '时间戳转换', category: 'time', keywords: ['timestamp', 'unix', '时区'], icon: 'clock', loader: () => import('./timestamp/index.js'), }, { id: 'regex', name: '正则测试', category: 'dev', keywords: ['regex', '正则', '匹配'], icon: 'regex', loader: () => import('./regex/index.js'), }, ];这里有几个设计取舍值得说。category只是分类,不要拿它做路由,路由一律用id,因为工具很可能在两次重构之间换分类。keywords数组是为了搜索,用户输入"时间"能搜到时间戳工具,输入"ts"也能搜到,比只匹配名字好用得多。loader必须是函数返回import(),不能写成顶层 import,否则打包器会把所有工具打进主 chunk,懒加载就白做了。
每个工具模块统一导出mount(container, ctx)和可选的unmount():
// tools/json-format/index.js export function mount(root, ctx) { root.innerHTML = ` <div class="tool-layout"> <textarea class="input" placeholder="粘贴 JSON" spellcheck="false"></textarea> <div class="toolbar"> <button>// shell/context.js export function createContext(meta) { return { meta, async copy(text) { if (!text) return; try { await navigator.clipboard.writeText(text); } catch { // 降级:创建一个临时 textarea 走 execCommand const ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); } }, toast(msg, type = 'info') { /* 统一提示 */ }, async getInput() { return chrome.storage.local.get(`draft:${meta.id}`); }, async saveInput(v) { return chrome.storage.local.set({ [`draft:${meta.id}`]: v }); }, }; }ctx.copy里那段降级代码是我踩坑之后补上的。navigator.clipboard在扩展页面里可用,但前提是文档处于聚焦状态;用户从 popup 点出去再点回来,某些情况下writeText会抛NotAllowedError。用一个隐藏 textarea 配合execCommand兜底虽然老土,但稳定。
3.4 第三方库的本地化与体积控制
MV3 的 CSP 不允许远程脚本,所以所有依赖必须打包进扩展。这在工具型扩展里是个真问题:一个代码高亮库动辄几百 KB,一个图表库甚至上 MB。我的策略是"能自己写的绝不引库,必须引的做体积预算"。
| 需求 | 常见做法 | 我的做法 | 收益 |
|---|---|---|---|
| JSON 高亮 | 引完整高亮库 | 自己写 60 行 tokenizer + span 着色 | 体积从 300KB 降到 3KB |
| 二维码生成 | 引通用二维码库 | 保留一个精简实现,按需 load | 只在二维码工具 chunk 里 |
| Markdown 预览 | 引完整解析器 | 保留解析器,但严格懒加载 | 主 chunk 不受影响 |
| 图片压缩 | 引 canvas 封装库 | 直接用原生 Canvas API | 零依赖 |
一个具体的判断标准:如果这个库的使用场景只在一个工具里,它必须出现在那个工具的 chunk 里,绝不能出现在主 chunk。构建后跑一次体积分析,主 chunk 超过 500KB 就要开始质问了。
4. 几个典型工具的核心实现与真实坑点
4.1 JSON 格式化:大文本、错误定位和性能
看起来最简单的工具,实际上坑最多。第一版我直接JSON.stringify(JSON.parse(str), null, 2),然后被三件事打脸。
一是大文本卡死。用户粘贴一个 5MB 的 JSON,JSON.stringify加高亮渲染,整个 popup 直接冻结三秒。解决办法是分两步:先用JSON.parse验证,超过阈值(我设的是 1MB)就只做格式化不做语法高亮,并且用requestIdleCallback分段渲染或者干脆只渲染前若干行加"展开全部"按钮。
二是错误定位不友好。JSON.parse抛出的错误信息在不同浏览器里格式不一致,有的是Unexpected token } in JSON at position 1024,有的是带行列号的新格式。我写了一个兼容解析器,从错误信息里正则提取 position,再自己算出所在行和列,然后在输入框里高亮那一行。这个功能的实际使用频率高得惊人,尤其是对接方返回的 JSON 少了个逗号的时候。
function locateError(text, errMessage) { let pos = -1; let m = /position\s+(\d+)/i.exec(errMessage); if (m) pos = Number(m[1]); if (pos < 0) { m = /line\s+(\d+)\s+column\s+(\d+)/i.exec(errMessage); if (m) { const line = Number(m[1]); const col = Number(m[2]); const lines = text.split('\n'); pos = lines.slice(0, line - 1).reduce((acc, l) => acc + l.length + 1, 0) + col - 1; } } if (pos < 0) return null; const before = text.slice(0, pos); const line = before.split('\n').length; const column = pos - before.lastIndexOf('\n'); return { pos, line, column }; }三是键排序和保留顺序。JSON.parse之后对象键的顺序在多数引擎里是"整数键升序 + 字符串键插入序",用户看到格式化结果和自己原文顺序不一致就会怀疑工具坏了。所以我在设置里加了一个"按键名排序"的开关,默认关闭,默认行为就是保序。
4.2 编码解码与哈希:中文乱码的根因在哪里
Base64、URL 编码、Unicode 转义这几个工具,用户报的 bug 八成是同一类:中文乱码。根因在于btoa和atob只接受 Latin-1 范围内的字符,直接喂中文会抛InvalidCharacterError,很多教程给的补丁是btoa(unescape(encodeURIComponent(str))),能用但unescape本身已经废弃。我现在的写法统一走TextEncoder:
export function encodeBase64(str) { const bytes = new TextEncoder().encode(str); let bin = ''; const CHUNK = 0x8000; for (let i = 0; i < bytes.length; i += CHUNK) { bin += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK)); } return btoa(bin); } export function decodeBase64(b64) { const bin = atob(b64.replace(/\s/g, '')); const bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); return new TextDecoder('utf-8', { fatal: false }).decode(bytes); }那个CHUNK分片是关键。String.fromCharCode.apply的参数数量有上限(大约 65535),大文件一次性 apply 会抛Maximum call stack size exceeded。这个问题在只测短字符串的时候永远发现不了,上线之后处理日志文件立刻炸。
哈希工具(MD5、SHA-1、SHA-256)我全部走crypto.subtle,原生实现又快又不用引库。只有 MD5 没有原生支持,需要自己带一份小实现,注意它只用于校验场景,不要用在任何安全相关的场合。
4.3 时间戳与 Cron:时区是绕不过去的坎
时间戳工具的需求看似明确,实际用户分成两派:一派要"当前时间戳",一派要"把某个时间戳转成可读时间"。真正的坑在时区。new Date(ts * 1000).toLocaleString()会跟随系统时区,用户如果开着某个时区模拟,结果就不对。我的做法是同时展示三份:本地时区、UTC、以及用户手动指定的时区(默认给一个常用的东八区)。用Intl.DateTimeFormat带timeZone参数实现,不需要引任何库:
function formatInZone(ts, timeZone = 'Asia/Shanghai') { return new Intl.DateTimeFormat('zh-CN', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }).format(new Date(ts)); }Cron 表达式工具的价值在于"反向验证":用户写了一个0 */5 * * * ?,工具要告诉他接下来五次触发时间是什么。这个计算如果自己写会很难看,我的做法是做一个轻量解析器,把表达式拆成秒分时日月周五段或六段,只支持常用语法,遇到L、W、#这类高级语法直接提示"暂不支持",而不是给出错误结果。宁可诚实地说不支持,也不要算错,这一点在开发工具上是底线。
4.4 颜色、图片与剪贴板:Canvas 的边界在哪
颜色工具(HEX 与 RGB 互转、对比度计算、调色板生成)纯粹是数学,没什么难度,但对比度计算有个细节:WCAG 的相对亮度公式需要先把 sRGB 值做 gamma 校正,直接线性加权算出来的结果和标准答案差一截。公式是c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4,然后按0.2126R + 0.7152G + 0.0722B加权,最后用(L1 + 0.05) / (L2 + 0.05)求比值。这个数值直接影响无障碍检测是否通过,算错了会误导人。
图片工具(转 Base64、压缩、取色)依赖 Canvas,坑在于跨域图片会污染 Canvas。用户把网页上的图片拖进来,如果那张图来自另一个域且没有 CORS 头,toDataURL会抛安全错误。扩展里可以通过host_permissions加fetch绕过一部分限制,但更稳的做法是明确提示用户"该图片受跨域限制,请先下载到本地再上传"。
剪贴板的坑前面提过一次,这里补一个:往剪贴板写 HTML 富文本要用navigator.clipboard.write配合ClipboardItem,但扩展页面里这个 API 的支持度参差,且必须由用户手势触发。所以工具里所有"复制"按钮的行为都必须是同步的点击回调,不能在setTimeout或者await之后才调用,否则会失去手势上下文。
4.5 正则测试:别让用户在扩展里写死循环
正则测试工具的核心不是"能不能匹配",而是"会不会卡死"。用户写一个(a+)+b去匹配一长串 a,回溯爆炸会让页面直接无响应。浏览器主线程是单线程,popup 冻住之后用户只能强杀。
缓解手段有三层。第一层是长度限制,输入文本超过一定长度(比如 10 万字符)就提示"建议缩短",并仍然允许执行但加个警告。第二层是超时保护,把匹配放到requestIdleCallback里配合时间片检测做不到真正中断,因为正则匹配本身不可中断。第三层,也是最有效的:对明显危险的模式做静态检测,检测连续嵌套量词((x+)+、(x*)*)就在界面上给一个醒目的风险提示,让用户自己判断。这个"不完美但诚实"的取舍,比假装能兜住所有情况要靠谱。
5. 工程实践:构建、发布与长期维护
5.1 多入口构建的配置要点
这类扩展的构建有三件事必须做对:多 HTML 入口、manifest 单独拷贝并注入版本号、静态资源原样落地。我用现代打包器实现,配置思路是通用的,换个打包器也照搬逻辑。
// build.config.js(伪配置,按所用打包器调整字段名) export default { input: { popup: 'src/pages/popup.html', sidepanel: 'src/pages/sidepanel.html', sw: 'src/sw.js', inject: 'src/content/inject.js', }, output: { dir: 'dist', format: 'esm', entryFileNames: '[name].js', chunkFileNames: 'chunks/[name]-[hash].js', }, plugins: [ // 生成 manifest,动态注入 version manifestPlugin({ pkg: './package.json', template: 'src/manifest.json' }), // 拷贝图标、_locales 等静态资源 copyPlugin({ from: 'src/assets', to: 'dist/assets' }), ], };两个容易踩的点。一是content script 的输出格式。MV3 里 content script 默认不支持 ESM,如果直接打成esm会报Cannot use import statement outside a module,要么打成 IIFE,要么在 manifest 里给 content script 单独声明。二是chunk 路径。懒加载工具 chunk 会被放在chunks/目录下,import()的相对路径由打包器处理,但如果你同时用了web_accessible_resources暴露某些文件,路径必须和实际产物对上,否则运行时 404。
5.2 版本升级与数据迁移
扩展是会自动更新的,用户不会看更新日志,所以任何改动了存储结构的地方都必须写迁移。我的做法是在 storage 里存一个schemaVersion,启动时比对:
async function migrate() { const { schemaVersion = 0 } = await chrome.storage.local.get('schemaVersion'); if (schemaVersion >= CURRENT_SCHEMA) return; if (schemaVersion < 1) { // v0 -> v1: 旧的单一 settings 拆成 settings + drafts const { settings } = await chrome.storage.local.get('settings'); if (settings) await chrome.storage.local.set({ prefs: settings }); } if (schemaVersion < 2) { // v1 -> v2: 草稿 key 加前缀,清理无主草稿 const all = await chrome.storage.local.get(null); const stale = Object.keys(all).filter(k => k.startsWith('draft:') && !TOOLS.some(t => k === `draft:${t.id}`)); if (stale.length) await chrome.storage.local.remove(stale); } await chrome.storage.local.set({ schemaVersion: CURRENT_SCHEMA }); }还有一个血泪教训:不要在下个版本里删除或重命名工具的id。用户收藏了某个工具、你给了快捷键,id一改全部失效,还会留下无主草稿。要改就保留旧的id作为别名。
5.3 快捷键、右键菜单与暗色主题
快捷键用commands声明,但要注意两点:suggested_key只是建议,实际生效取决于用户系统里有没有冲突;chrome.commands.onCommand在后台里监听,后台被唤醒时会重新注册,无需额外处理。右键菜单比较有价值的是"选中文本 → 用某个工具处理",比如选中一段 Base64 直接解码,用户体验非常顺。注册时机要在runtime.onInstalled里,且记得先removeAll再创建,避免重复安装后菜单项翻倍。
暗色主题别用媒体查询硬编码颜色,定义一套 CSS 变量在:root和[data-theme="dark"]下切换,所有工具复用同一套 token。我一开始每个工具自己写颜色,后来加暗色模式时改了整整两天。
5.4 上线前的自查清单
| 检查项 | 判断标准 | 常见失败原因 |
|---|---|---|
| 首屏体积 | 主 chunk < 500KB | 注册表被同步 import |
| 无远程代码 | 全文搜索http无脚本引用 | 遗留的 CDN 引用 |
无eval/new Function | 构建产物搜索 | 第三方库内部实现 |
| 权限最小化 | 只声明实际用到的 | 模板复制没清理 |
| 后台无状态 | 强杀后台后功能正常 | 用了模块级变量 |
| 所有工具可离线 | 断网全量点一遍 | 某个工具偷偷请求了接口 |
6. 常见问题与排查实录
6.1 问题速查表
| 现象 | 最可能的原因 | 排查动作 |
|---|---|---|
| 扩展装上但点图标无反应 | popup 路径写错或产物未生成 | 看dist目录里有没有对应 HTML |
后台报Cannot use import statement | content script 被打成了 ESM | 检查输出格式 |
| 工具第一次点很慢,第二次正常 | 懒加载生效了,但无骨架占位 | 加 loading 态 |
| 复制按钮偶发失败 | 失去用户手势上下文 | 确保在点击回调同步链路里调用 |
| 中文 Base64 输出乱码 | 用btoa直接处理了中文 | 改用 TextEncoder |
| 大 JSON 粘贴后页面卡死 | 同步格式化 + 全量高亮 | 加大小阈值 |
| 暗色模式下部分工具还是白色 | 颜色写死没走 CSS 变量 | 全局搜索#fff |
| 更新后用户设置全丢 | 存储结构变更没写迁移 | 补 schemaVersion |
| 页面注入的工具在某些站点失效 | 该站点禁止注入或 CSP 严格 | 降级为复制粘贴模式 |
6.2 几个踩得比较深的坑
第一个是 popup 的高度问题。早期我把工具面板做成自适应高度,结果内容一多,popup 在屏幕上被截断,滚动条出不来,用户以为工具坏了。后来固定了最大高度(body { max-height: 600px; overflow-y: auto }),并且把重内容的工具引导到 side panel 打开。侧边栏是 MV3 里被低估的能力,它常驻在浏览器右侧,不会因为失焦而关闭,特别适合"边看页面边处理数据"的场景。
第二个是内容脚本的样式污染。早期版本往页面里注入一个取色器悬浮层,用的是普通 class 名,结果在某个后台管理系统的页面上被它自己的全局样式覆盖,整个悬浮层变形。解决办法是给所有注入的 DOM 加一个独特前缀的 class,并且用 Shadow DOM 隔离样式,同时把宿主元素的all: initial重置掉继承样式。这个改动看着简单,但如果没有出过事,很少有人会主动去做。
第三个是chrome.storage.sync的容量陷阱。我一开始把用户草稿存在 sync 里,想着换设备能同步,结果几个大 JSON 直接把配额撑爆,报QUOTA_BYTES_PER_ITEM quota exceeded。后来改成草稿一律local,只有偏好设置走sync,且偏好设置控制在几 KB 以内。sync 的单项配额是 8KB、总量 100KB 左右,拿来存文本草稿本身就选错了地方。
第四个是关于"要不要加请求拦截类工具"的取舍。这类工具箱很容易被用户要求"顺便加个改请求头的功能",但 MV3 下declarativeNetRequest的规则数量和表达能力都受限,做出来效果一般,还会引入大量权限申请。我的判断是这类需求属于另一个产品,不要往工具箱里塞,塞进去会让审核、权限、维护成本全部上一个台阶。
6.3 性能自查的三条土办法
不用上专业工具,三个土办法就能发现 80% 的性能问题。第一个是打开 devtools 的 Performance 面板录一次"点击图标到工具可用"的完整过程,看主线程有没有超过 100ms 的长任务,有就说明某个工具被打进了主 chunk。第二个是把dist目录按文件大小排序,看前五个大文件是不是都落在chunks/下,如果有落在根目录的,说明分包没做干净。第三个是断网后全量点一遍所有工具,任何需要网络的请求都会在这里暴露,顺带也验证了离线可用性。
提示:这三条自查建议放进发布前的固定流程里,比起上线后被用户反馈"卡"再回头查,成本低一个数量级。
7. 我在这类项目上的一些真实体会
真正把一个工具箱从 5 个工具做到 30 个,最后决定成败的不是功能数量,而是接入第 31 个工具的成本。我现在加一个新工具的标准流程是:建目录、写meta.js、注册一行、跑构建、发布,全程不超过 20 分钟,其中一半时间花在写界面上。这套流程能成立,靠的是前面那几层设计:注册表是纯数据、工具只依赖ctx、外壳统一处理工具栏和错误提示、构建自动分包。如果哪天我发现加一个工具要改主壳的代码,那就说明抽象漏了,得回头补。
另外一个体会是,工具型产品的"减法"比"加法"重要。我删掉过三个工具:一个是和系统自带功能重复的,一个是半年后台数据显示使用次数是 0 的,还有一个是虽然有用但维护成本极高的(依赖一个体积巨大的库)。删掉之后主包小了,构建快了,用户也根本没察觉。数据结构里的使用统计不用做多复杂,每次打开工具往storage里累加一个计数,一个月看一次就够了,比拍脑袋决定砍哪个靠谱得多。
最后一个体会偏主观:这类纯本地工具的价值,随着用户对数据流向越来越敏感而变得更高。一段含有内网地址的配置、一段带着用户信息的日志,很多人其实是不愿意往陌生网页上贴的。把工具做成本地跑的扩展,看似是技术选择,本质上是在替用户守一条线。我后来把这个判断标准写进了 README 的第一行:任何需要把用户输入发出去才能实现的功能,一律不做。