
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 WorkerCSP 禁止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: AltShiftF }, 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 panelservice worker申请权限、转发页面操作后台可能在启动中需容错重试popup / side panelcontent script读写当前页面 DOM目标页面可能禁止注入要降级content scriptservice worker上报数据、请求工具计算用sendResponse时返回trueservice workerpopup主动推送状态更新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: modshiftj, 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 classtool-layout textarea classinput placeholder粘贴 JSON spellcheckfalse/textarea div classtoolbar 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 的 JSONJSON.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)\scolumn\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本身已经废弃。我现在的写法统一走TextEncoderexport 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-themedark]下切换所有工具复用同一套 token。我一开始每个工具自己写颜色后来加暗色模式时改了整整两天。5.4 上线前的自查清单检查项判断标准常见失败原因首屏体积主 chunk 500KB注册表被同步 import无远程代码全文搜索http无脚本引用遗留的 CDN 引用无eval/new Function构建产物搜索第三方库内部实现权限最小化只声明实际用到的模板复制没清理后台无状态强杀后台后功能正常用了模块级变量所有工具可离线断网全量点一遍某个工具偷偷请求了接口6. 常见问题与排查实录6.1 问题速查表现象最可能的原因排查动作扩展装上但点图标无反应popup 路径写错或产物未生成看dist目录里有没有对应 HTML后台报Cannot use import statementcontent 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 的第一行任何需要把用户输入发出去才能实现的功能一律不做。