1. 从一个浏览器书签文件夹说起:这个扩展到底装了什么
我电脑的浏览器书签栏里曾经有一个叫“工具”的文件夹,里面塞了几十个网址:JSON 格式化、时间戳转换、二维码生成、正则在线测试、颜色转换、图片转 Base64、Cron 表达式解析……每次要处理一个临时需求,就得先打开书签、等页面加载、忍受广告、还要担心把公司的一段接口报错粘到别人的服务器上。
FeHelper 前端助手这类浏览器扩展,本质就是把这一整个文件夹的能力收进浏览器本体,用一次点击或一个快捷键调出来,数据全程留在本地。这个项目标题里提到三个关键词:30+ 开发者工具、MV3 架构、工具模块与工程实践。前两个说的是“它能干什么、它建在什么地基上”,第三个才是真正值得深挖的部分——一个装了三十多个小工具的扩展,怎么做到不臃肿、不卡、权限不乱要、后续还能持续加工具。
这篇文章适合三类人看:一是天天和 JSON、时间戳、正则打交道的后端、前端、测试、数据同学,想知道这类工具该怎么用出效率;二是想自己做一个浏览器扩展、或者想给已有扩展加功能的开发者,会看到 MV3 的具体配置、模块注册、内容脚本注入这些硬细节;三是团队里负责内部工具建设的人,可以参考“工具全家桶”这种产品形态的取舍逻辑。我不会只告诉你“点这里能格式化 JSON”,而是把每个模块背后的实现思路、参数怎么选、坑在哪里,一并摊开讲。
2. 整体设计与思路拆解:为什么做成扩展,又为什么做成全家桶
2.1 需求原点:临时性、高频、低复杂度
先想清楚一件事:开发者日常处理的工具需求,绝大多数是“临时、高频、单次几十秒”的类型。粘一段 JSON 看结构、把一个时间戳换成日期、验证一条正则匹配不匹配、把一张设计稿切图转成 Base64 塞进 CSS。这类需求的特点是:单次操作极短,但一天可能触发十几次;每次都要切换上下文,成本远高于操作本身。
在线工具站的痛点正好卡在这个“切换上下文”上。打开新标签页、等首屏加载、关掉、再切回来,一次操作的实际耗时可能是纯操作的五六倍。更要命的是隐私边界模糊——很多人在排查线上问题时,会把带有用户信息、内部字段名、甚至密钥片段的报文直接贴进第三方页面。扩展把这个流程压缩成“点图标、粘贴、看结果”,数据不出浏览器进程,这个价值比“功能多”重要得多。
所以选型的第一层结论是:这类工具的正确形态是常驻在手边的轻量入口,而不是功能大而全的独立应用。它不需要账号、不需要云同步、不需要协作,只需要“秒开”。
2.2 为什么是全家桶而不是一堆小扩展
有人会问:为什么不做成十几个各管一件事的小扩展,按需安装,不是更干净吗?理论上更符合“单一职责”,实践中有三个问题。
第一是权限与安装成本。每装一个扩展都是一次决策,普通用户对扩展的容忍度很低,装到第五个就开始嫌工具栏挤。第二是重复造轮子。JSON 格式化、编码解码、文本对比这些工具,底层的剪贴板读写、历史记录、快捷键分发、主题适配是同一套代码,拆开就意味着复制十几遍。第三是发现成本。用户往往不知道自己需要什么,把工具摆在一个面板里,他看到“哦这里还能算 Cron”,需求才被激活。这个逻辑和瑞士军刀一样:你不会为了用剪刀随身带一把剪刀,但你会因为刀上有剪刀而在需要时用它。
当然全家桶有代价:包体积、启动性能、代码耦合。这三点就是后面工程实践要重点解决的对象,也是这篇文章后半段的主线。
2.3 MV3 带来的地基变化:能做什么、不能做什么
Manifest V3 是这个项目绕不开的前提。和旧的 V2 相比,几个变化直接决定架构:
| 能力 | V2 的做法 | MV3 的做法 | 对工具类扩展的影响 |
|---|---|---|---|
| 后台常驻 | 持久化 background page | Service Worker,空闲被回收 | 不能把状态放全局变量,必须落存储 |
| 网络拦截 | blocking webRequest | declarativeNetRequest | 工具类基本用不上,影响小 |
| 远程代码 | 相对宽松 | 明令禁止执行远程脚本 | 所有工具逻辑必须打包进扩展 |
| 页面脚本 | 内联脚本可用 | CSP 限制,只能引本地文件 | 弹窗页面要拆成独立 js 文件 |
| 定时任务 | setInterval 常驻 | alarms API,最小间隔受限 | 需要定时刷新类工具要重新设计 |
这里最关键的一条是Service Worker 会被回收。它空闲几十秒就可能被浏览器干掉,下次事件来了再重新拉起,全局变量全部清零。很多从 V2 迁过来的扩展在这里翻车:把“用户上次用的工具”“剪贴板历史”存在模块顶层的let变量里,刚开始测没问题,放一会儿再点,数据没了。正确做法是把这类状态放到chrome.storage.session或chrome.storage.local,读写都走异步接口。
另一条是禁止远程代码。这意味着你不能像网页那样从 CDN 拉一个格式化库下来用,所有依赖必须打进包里。好处是安全性提升、离线可用;坏处是包体积需要精打细算,后面会讲怎么用动态导入拆分。
2.4 模块化的核心思路:注册表 + 懒加载
三十多个工具如果全部打进一个 popup.js,文件会膨胀到几兆,点开图标要等一秒以上,体验直接崩。这个项目的结构思路基本是:一个极轻的壳 + 一份工具注册表 + 按需加载的工具模块。
壳负责渲染左侧工具列表、路由切换、主题、快捷键;注册表是一份描述“有哪些工具、叫什么、图标是什么、入口在哪”的元数据数组;具体工具的实现放在各自目录里,用户点开某个工具时才import()它。这套设计的好处很直白:首屏只需要加载壳和注册表,通常几十 KB;用户常用的三五个工具首次点开有微小延迟,之后被浏览器缓存。这就是把“全家桶”的体积压力摊薄到使用路径上的典型做法。
注意:MV3 的扩展页面支持 ES Module 动态导入,但 Service Worker 里用动态导入要注意路径必须是扩展内的相对路径,且不能跨越打包产物之外,构建工具需要显式配置多入口,否则运行时会报找不到模块。
3. 核心工具模块拆解与实操要点
3.1 文本数据类:JSON 格式化不只是“缩进两格”
JSON 工具是这个全家桶里使用频率最高的一块,看起来最简单,实际细节最多。
基础功能是格式化与压缩。格式化用JSON.stringify(obj, null, 2)就够了,但真正影响体验的是错误定位。用户粘进来的往往是一段有语法错误的报文,原生JSON.parse抛出的错误信息在不同浏览器里措辞不一,只告诉你 “Unexpected token } in JSON at position 128” 这种。做得好一点的实现会做两件事:一是从错误信息里正则抽出行号列号(新版引擎会带line X column Y),二是自己在解析失败时做一次逐字符扫描,把出错位置附近的内容高亮出来。
我自己的实现习惯是这样:
function safeParse(text) { try { return { ok: true, data: JSON.parse(text) }; } catch (err) { const m = /position (\d+)/.exec(err.message || ''); let index = m ? Number(m[1]) : -1; return { ok: false, index, raw: err.message }; } } function locate(text, index) { if (index < 0) return null; const before = text.slice(0, index); const line = before.split('\n').length; const column = index - before.lastIndexOf('\n'); return { line, column }; }几个实操要点值得说清楚。第一,大文本要防卡死。几百 KB 的 JSON 在主线程做stringify加上语法高亮,能让弹窗卡住一两秒。我的处理是超过一定阈值(比如 200KB)就跳过语法高亮,只做纯文本格式化,或者干脆提示用户“内容较大,已切换为纯文本模式”。第二,不要迷信自动修复。市面上有些工具会对缺引号、多逗号的 JSON 做“智能修复”,这个功能在排查真实问题时是危险的——它可能把你的错误掩盖掉,让你以为接口返回是正常的。我倾向于只标错、不改写。第三,注意大整数精度。JSON 里的长整型 ID 超过Number.MAX_SAFE_INTEGER后parse会失真,如果你的场景涉及订单号、雪花 ID,格式化工具展示出来的值就是错的。这种情况要么提示用户,要么用带reviver的解析或专门的 bigint 解析库。
JSON 之外,这个分类里还有文本对比和字符统计。文本对比的核心是差异算法,行级 diff 用 Myers 算法实现即可,关键是渲染时要处理超长行的横向滚动,以及忽略空白差异的选项——对比两段格式化过的 JSON 时,缩进差异会产生大量噪音,勾上“忽略空白”才有意义。
3.2 编码转换类:Base64、URL、Unicode 的边界在哪
编码解码工具看起来是纯函数,其实最容易被误用。
Base64部分要区分三个场景:普通 ASCII 文本、含中文的 UTF-8 文本、二进制文件。直接用btoa()处理中文会直接抛错,必须先转 UTF-8:
function encodeBase64(str) { const bytes = new TextEncoder().encode(str); let bin = ''; bytes.forEach(b => { bin += String.fromCharCode(b); }); return btoa(bin); } function decodeBase64(b64) { const bin = atob(b64); const bytes = Uint8Array.from(bin, c => c.charCodeAt(0)); return new TextDecoder().decode(bytes); }反过来,解码时如果输入的 Base64 内容其实是二进制(比如一张图片),用TextDecoder解出来会是乱码。合格的实现会在解码失败或者出现大量替换字符时提示“内容可能不是文本,请使用图片转 Base64 工具”。这个细节很多人不做,结果用户拿着图片的 Base64 反复解码,看到一堆问号,以为是工具坏了。
URL 编解码的坑在“要不要编整个 URL”。encodeURI会保留:/?#[]@这些结构字符,适合处理整条地址;encodeURIComponent会把这些也编码掉,适合处理参数值。工具里最好给两个按钮,并在旁边用小字标注用途,否则用户拿encodeURIComponent处理整条 URL,得到的地址贴到浏览器里打不开,然后来投诉。
Unicode 转换(\u4f60\u597d与中文互转)在调试接口返回、看某些序列化数据时很有用。实现上注意代理对(emoji 这类四字节字符)要用codePointAt而不是charCodeAt,否则会拆成两个乱码。
3.3 时间、表达式与正则:开发者的三件日常兵器
时间戳转换的使用频率仅次于 JSON。核心逻辑是判断单位:10 位按秒处理,13 位按毫秒处理,再多就是微秒或纳秒,需要额外处理。这个判断不能靠位数硬猜,因为 10 位的秒级时间戳在 2286 年才会变成 11 位,而毫秒级从 2001 年起就是 13 位,短期内不会冲突,所以按位数判断是可靠的。
真正需要设计的是展示维度:本地时间、UTC 时间、ISO 8601、相对时间(“3 小时前”)。排查跨时区问题时,只看本地时间会误判,我习惯把 UTC 和 ISO 一起显示出来。另外要提供一个“当前时间戳实时刷新”的开关,写日志、埋点、拼请求头的时候直接复制,比手算快得多。
Cron 表达式解析的价值在于“人话翻译”。0 2 * * 1-5这种表达式,老手能读,新手要查。好的工具会在下方用自然语言写出“每周一至周五凌晨 2 点执行”,并给出未来五次的触发时间。实现上要注意 Quartz 和 Unix 两套语法的差异——Quartz 有秒字段、支持?和L,Unix 只有五个字段。如果工具只有一个输入框,至少要能自动识别字段数是 5 还是 6,并提示当前按哪套规则解析,否则用户会拿着 Quartz 表达式在 Unix 规则下算出错误的下次执行时间,然后在生产环境踩坑。
正则测试是这三件里最容易做偏的一个。基本功能是输入表达式和测试文本,高亮匹配结果,展示捕获组。进阶要点有三:一是标志位(g、i、m、s、u、y)要能勾选,很多人调试时忘了加g,然后疑惑为什么match只有一个结果;二是替换预览,给出替换模板(如$1-$2)并实时显示替换后结果,这个功能在批量改代码时极其好用;三是灾难性回溯的提示。像(a+)+$这种表达式遇到长串输入会把主线程挂死。浏览器端没法真正设置正则执行超时,比较现实的做法是对输入文本长度做限制,并在检测到嵌套量词结构时给出警告文案,提醒用户表达式可能存在性能风险。
提示:正则测试里粘贴真实业务数据时,建议先脱敏。工具本身不上传数据,但截图分享、录屏演示时容易带出去,这是很多团队出过事故的地方。
3.4 图像、颜色与前端调试类:那些“顺手就好用”的小工具
图片转 Base64是前端切图时的刚需。实现路径有两条:FileReader.readAsDataURL直接读文件,或者画到 canvas 上用toDataURL输出。前者简单,后者能顺带做压缩和格式转换。我的建议是给用户三个选项:是否带data:image/png;base64,前缀、输出为 CSS 的background-image还是<img src>、以及一个体积提示。因为 Base64 编码后体积会比原图大约三分之一,一张 200KB 的图塞进 CSS 会让样式文件暴涨,这个代价必须让用户看见,而不是闷头生成。
二维码生成与解析在联调移动端页面时非常实用:把当前页面 URL 生成二维码,手机扫一下就能打开,省去手输地址。生成用成熟的二维码库即可,注意纠错等级的选择——L 级容量大但脏了扫不出,M 级是默认平衡点,做印刷物料才需要 H 级。解析则要处理摄像头权限和图片上传两条路径,前者需要用户授权,后者纯本地处理。
颜色工具包括格式互转(HEX、RGB、HSL)和取色。取色这块,现代浏览器提供了 EyeDropper API,可以从屏幕任意位置吸色,比让用户截图再用取色器方便得多。但要注意这个 API 需要用户手势触发,且部分环境不支持,得做降级:不支持时提供图片上传取色。
UA 解析、UUID 生成、哈希计算(MD5、SHA 系列)、SQL 格式化、Markdown 转 HTML这些都属于同一档位的工具:单次逻辑不复杂,但每次都要用。它们的共同工程要求是别让用户等——所有计算在本地同步完成,结果即时呈现,一键复制。这类工具做得好不好,差别几乎全在“复制按钮是否顺手”“输入框是否自动聚焦”“结果是否自动选中”这些微交互上。
4. 实操过程:从零搭一个同类的扩展骨架
4.1 清单文件与权限设计
一切从manifest.json开始。这是一个精简但可用的 MV3 配置:
{ "manifest_version": 3, "name": "前端助手", "version": "1.0.0", "description": "面向开发者的本地工具箱", "action": { "default_popup": "popup.html", "default_title": "前端助手" }, "background": { "service_worker": "background.js", "type": "module" }, "permissions": ["storage", "contextMenus", "activeTab", "scripting", "clipboardWrite"], "host_permissions": [], "content_security_policy": { "extension_pages": "script-src 'self'; object-src 'self'" }, "commands": { "open-panel": { "suggested_key": { "default": "Alt+Shift+F" }, "description": "打开工具箱" } } }这里每一个字段都有讲究。permissions里没有tabs、没有webRequest、没有<all_urls>,这是刻意为之。工具类扩展的大部分功能不需要触碰页面内容,权限要得越多,商店审核越慢,用户看到权限提示越犹豫。只有在用户主动对页面做操作(比如右键“用工具箱分析选中文本”)时,才用activeTab拿到临时授权,这个授权在用户离开页面后自动失效,是最小权限原则的标准用法。
content_security_policy这一项在 MV3 里其实有默认值,但显式写出来能避免打包工具或第三方库引入内联脚本时出错。很多从网页项目直接搬代码的人会卡在这里:页面里写了个<script>console.log(1)</script>,扩展加载后控制台报 CSP 错误,页面一片空白。解决办法只有一个:所有脚本移到独立的.js文件,通过<script src>引入。
4.2 工具注册表与懒加载实现
壳的入口就是一个渲染函数加一份元数据。注册表长这样:
// tools/registry.js export const toolGroups = [ { name: '数据文本', items: [ { id: 'json', title: 'JSON 格式化', icon: 'braces', load: () => import('./json/index.js') }, { id: 'diff', title: '文本对比', icon: 'diff', load: () => import('./diff/index.js') }, { id: 'base64', title: '编码解码', icon: 'code', load: () => import('./codec/index.js') } ] }, { name: '时间与表达式', items: [ { id: 'timestamp', title: '时间戳转换', icon: 'clock', load: () => import('./timestamp/index.js') }, { id: 'cron', title: 'Cron 解析', icon: 'calendar', load: () => import('./cron/index.js') }, { id: 'regex', title: '正则测试', icon: 'regex', load: () => import('./regex/index.js') } ] } ];每个工具模块导出一个统一的接口,这样壳不需要知道任何具体实现:
// tools/json/index.js export default { mount(container) { container.innerHTML = ` <textarea class="input" placeholder="粘贴 JSON"></textarea> <div class="actions"> <button>let current = null; async function switchTo(item) { if (current?.unmount) current.unmount(); const mod = await item.load(); current = mod.default; const view = document.getElementById('view'); view.innerHTML = ''; current.mount(view); }首次点击某个工具会有一次模块请求(本地文件,几毫秒),之后浏览器缓存,几乎无感。这个方案让首屏只加载注册表,三十个工具的体积被摊到用户真实的使用路径上。
4.3 内容脚本注入与右键菜单
工具类扩展的一个加分项是“就地处理”——选中页面上的一段 JSON,右键直接格式化,不用复制粘贴。实现分三步。
第一步,在 Service Worker 里注册菜单,注意注册动作要放在onInstalled里,重复注册会报错:
chrome.runtime.onInstalled.addListener(() => { chrome.contextMenus.create({ id: 'analyze-selection', title: '用工具箱分析选中文本', contexts: ['selection'] }); }); chrome.contextMenus.onClicked.addListener(async (info, tab) => { if (info.menuItemId !== 'analyze-selection') return; const [result] = await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: () => String(window.getSelection()) }); const text = result?.result ?? ''; await chrome.storage.session.set({ pendingText: text }); chrome.action.openPopup(); });第二步,popup 打开时读取chrome.storage.session里的待处理文本,自动填入对应工具。用session而不是local,是因为这份数据只需要活到用户看完结果,关掉浏览器就该消失,这既是隐私考虑也是状态管理的清晰边界。
第三步要注意权限。chrome.scripting.executeScript需要scripting权限,并且目标页面得是当前活动页——activeTab权限在用户通过右键菜单触发时自动授予,所以上面配置里不需要<all_urls>。这一点很多教程没讲清楚,导致新手直接申请全站权限,审核被退回。
4.4 存储、快捷键与设置同步
用户偏好(主题、默认工具、编辑器字号、历史记录开关)要跨设备同步的话,用chrome.storage.sync。它有几个硬限制必须知道:单项最大 8KB,总量 100KB,写入频率有配额。存偏好绰绰有余,千万别拿它存格式化历史,几次大报文就爆了。历史记录用chrome.storage.local,MV3 下容量通常在 10MB 量级,同时要自己做上限控制——保留最近 50 条,超出的按时间淘汰,否则几个月后用户会发现扩展占了几百兆。
快捷键走commands字段,注意两点:一是浏览器可能把某些组合键占用,用户按不出来是常态,所以在设置页要显示当前生效的组合键,并提供跳转到浏览器快捷键管理页的入口;二是chrome.commands.onCommand监听要在 Service Worker 顶层注册,不能放在异步回调里,否则 Worker 被回收重启后会丢监听。
5. 常见问题与排查技巧实录
5.1 MV3 迁移与运行时的典型故障表
下面这张表基本覆盖了这类扩展 90% 的报错,可以当速查。
| 现象 | 根因 | 处理方式 |
|---|---|---|
| 放几分钟后功能失效,状态丢失 | Service Worker 被回收,全局变量清零 | 状态写chrome.storage.session/local,每次用前读 |
| 页面一片空白,控制台报 CSP | 页面里有内联<script>或onclick属性 | 拆成独立 js 文件,事件用addEventListener绑定 |
chrome.xxx is not defined | 在 content script 里调用了只有扩展页能用的 API | content script 用chrome.runtime.sendMessage转给后台 |
动态import()报找不到模块 | 构建产物路径与实际输出目录不一致 | 检查构建工具的base和入口配置,用相对路径 |
| 右键菜单重复出现 | contextMenus.create被重复调用 | 只在onInstalled里注册,或先removeAll |
| 大文本格式化后弹窗卡死 | 主线程做语法高亮 | 超过阈值降级为纯文本,或分片处理 |
| 拷贝到剪贴板失败 | 未聚焦文档或缺少权限 | 在用户手势内调用,确认clipboardWrite已声明 |
| 打包体积过大 | 依赖全量引入 | 按工具拆分入口,用动态导入 |
举一个真实排查过程。有次用户反馈“工具打开后按钮全部没反应”,本地跑得好好的。打开控制台一看,报错是Refused to execute inline event handler。原因是有个工具模块为了方便,直接在模板字符串里写了onclick="doIt()",而 MV3 的 CSP 禁止内联事件处理器。这类问题在开发阶段很容易被忽略,因为如果你加载的是未打包的源文件,某些环境可能侥幸跑通。解决方案只有一个:全部改成addEventListener,或在渲染后遍历[data-action]统一委托。我现在写扩展时会把这条当成硬规矩——模板字符串里只允许出现结构和>