浏览器插件这个方向,我从 Manifest V2 一路写到现在,手里攒下来的小工具有二十多个,有自己用的,也有给团队内部做的。浏览器插件的开发门槛其实不高,一个 manifest.json 加上几个 JS 文件就能跑起来,但真正难的是后面那一段——打包、签名、上架、企业内网分发、版本升级后老用户不生效,这些环节里的坑远比写业务逻辑多。这篇文章就按"开发到部署"的完整链路走一遍,把我踩过的坑、验证过的方案、以及每个选择背后的判断依据都摊开讲。不管你是第一次接触浏览器插件,还是已经写过几个但一直卡在分发环节,应该都能从里面挑到能直接用的东西。整篇涉及的技术栈以 Chrome/Edge 的 Manifest V3 为主,Firefox 的差异我会单独拎出来说,代码片段都可以直接抄。
1. 开工之前:先把浏览器插件的能力边界摸清楚
动手写代码之前,我最想劝的一件事是:先花二十分钟判断这个需求到底该不该做成插件。我见过太多团队,花了两个月做完插件,最后发现用一个网页端页面就能解决,白白多背了一个分发渠道的维护成本。
1.1 插件、油猴脚本、桌面软件,三者怎么选
浏览器插件本质上是运行在浏览器沙箱内的一个小型应用,它能拿到一套浏览器暴露出来的扩展 API:读写页面 DOM、监听标签页状态、操作 Cookie、发起跨域请求、读写浏览器存储、改请求头、管理下载。这套能力的共同点是——都和"浏览器会话态"强相关。
用户脚本(也就是常说的油猴脚本)是另一个层级的东西,它依赖第三方管理器运行,能力受限于管理器开放的那部分 API,好处是不用上架、改完即生效、分发一条链接就够。桌面软件则是完全另一个路子,能力最强但也最重,安装包、自动更新、跨平台打包都要自己做。
判断标准可以简化成三个问题:
- 功能是否强依赖页面 DOM 或登录态?如果是,插件是首选。
- 是否需要长期后台运行、跨设备同步大数据?如果是,老老实实做服务端。
- 是否只需要在少数几个页面上改点样式或加个按钮?如果是,油猴脚本足够,别上插件。
我个人的经验是:面向企业内部系统做增强的,插件最合适,因为内部系统的登录态往往不好跨系统迁移,插件直接在用户已登录的页面上下文里工作,省掉一整套鉴权设计。
1.2 Manifest V2 到 V3 的迁移,哪些设计必须重做
Manifest V3 不是一个可选项,现在新提交的扩展只能走 V3。它带来的变化会直接影响你的架构设计,我把最关键的四条列一下:
- background page 变成 service worker。原来的常驻页面没了,service worker 会在空闲约 30 秒后被回收,全局变量不再可靠。所有需要持久化的状态必须落到
chrome.storage。需要定时任务的,要把setInterval换成chrome.alarms。 - 远程代码被禁止。不能再从 CDN 拉一段 JS 来 eval,所有逻辑必须打包进产物里。这条对习惯了动态加载的团队冲击最大,意味着更新逻辑只能靠发新版。
- 网络请求拦截能力收窄。原来的
webRequest阻断式监听基本退出历史舞台,改成了声明式的declarativeNetRequest,规则要提前写成静态配置或者通过 API 动态注册。 - 权限拆分成两块。
permissions和host_permissions分开声明,用户能更清楚地看到你要访问哪些站点。
注意:如果你的项目还在 V2 上,别急着一次性迁移完。我的做法是先只改 background 那一块,让插件在 V3 下能跑,再逐步把网络拦截逻辑搬过去。一次性全改,调试成本会翻倍。
1.3 什么样的需求适合做成插件
结合这几年做过的项目,我把适合做插件的场景归成五类,你可以对号入座。
| 场景类型 | 典型需求 | 主要依赖的 API | 注意点 |
|---|---|---|---|
| 页面内容提取 | 结构化采集、剪藏、导出表格 | content script + storage | 页面改版后选择器容易失效,要做兜底 |
| 表单自动化 | 内部系统重复录入、批量填充 | content script + scripting | 要处理动态渲染,用 MutationObserver |
| 页面增强 | 阅读模式、对照翻译、暗色适配 | content script + CSS 注入 | 样式冲突是最常见的问题 |
| 请求辅助 | 内网地址映射、请求头补全 | declarativeNetRequest | 权限说明必须写清楚用途 |
| 内部系统桥接 | 页面里唤起审批、同步工单 | background + fetch | 跨域请求放 background 做 |
反过来,需要长时间后台计算、需要处理 GB 级数据、需要绕过页面自身权限体系的需求,都不适合塞进插件。插件是一个"贴着页面跑"的东西,让它干重活只会又慢又不稳。
2. 从空目录到第一个能跑的插件
环境这块其实没什么玄学,一个目录、四五个文件就能跑起来。但我建议一开始就把工程化的底子打好,不然等你写到第三个功能模块时会非常难受。
2.1 最小可运行的目录结构
先看一个能在浏览器里加载起来的最小结构:
my-extension/ ├── manifest.json ├── background.js ├── content/ │ ├── index.js │ └── index.css ├── popup/ │ ├── index.html │ └── index.js └── icons/ ├── icon16.png ├── icon48.png └── icon128.pngmanifest.json是整个插件的入口描述,浏览器只认这一个文件。background.js是后台服务,content/下的东西会被注入到目标网页里,popup/是点扩展图标弹出来的那个小面板,icons/里的三张图分别是不同场景下的展示尺寸,16 用在工具栏,48 用在扩展管理页,128 用在上架商店。
2.2 manifest.json 字段逐个拆开讲
下面这份配置是我常用的模板,字段都保留注释意义的说明:
{ "manifest_version": 3, "name": "页面信息采集助手", "version": "1.0.0", "description": "在指定页面提取结构化信息并整理到本地", "minimum_chrome_version": "114", "icons": { "16": "icons/icon16.png", "48": "icons/icon48.png", "128": "icons/icon128.png" }, "action": { "default_popup": "popup/index.html", "default_title": "采集当前页面" }, "background": { "service_worker": "background.js", "type": "module" }, "content_scripts": [ { "matches": ["https://*.example.com/*"], "js": ["content/index.js"], "css": ["content/index.css"], "run_at": "document_idle", "all_frames": false } ], "permissions": ["storage", "activeTab", "scripting", "alarms"], "host_permissions": ["https://api.example.com/*"], "web_accessible_resources": [ { "resources": ["assets/panel.html", "assets/*.png"], "matches": ["https://*.example.com/*"] } ] }几个容易被忽略的点值得单独说。minimum_chrome_version设成 114 是因为侧边栏 API 从那个版本开始稳定,如果你要兼容更老的浏览器版本,把依赖新特性的功能做成渐进增强。background.type设成module之后就能在 service worker 里用import,这个非常有用,否则所有代码得塞一个文件。all_frames默认是 false,只有主框架会被注入,如果你的目标内容在 iframe 里,这个必须开。
web_accessible_resources是个高频踩坑项。content script 里如果想要引用插件内部的图片、HTML 或者字体文件,必须把这些资源列进这个字段,否则浏览器会直接拦截请求,控制台报错但不会告诉你原因。
2.3 权限申请的取舍原则
权限声明是最影响审核通过率的地方。我的原则是:能用activeTab就不用tabs,能用精确域名就不用<all_urls>。
activeTab是一个很聪明的设计,它只在用户主动点击扩展图标、右键菜单或者使用快捷键时,临时授予当前标签页的访问权限,不需要在安装时提示"读取您在所有网站上的数据"。如果你的功能是"点一下才生效"的类型,用activeTab完全够,用户体验和审核通过率都会好很多。
host_permissions里也别图省事写通配。写https://*.example.com/*就够了,别写https://*/*。多写的每一行,都是审核员要问你的问题。
2.4 热更新:别再手动点刷新了
原生开发最痛苦的就是改一行代码要点一次扩展管理页的刷新按钮,还要刷新目标页面。这个循环一天下来能重复几百次。两个方案可以解决:
方案一是用@crxjs/vite-plugin,它会把 manifest.json 当作配置输入,自动生成构建产物,并且支持 background 和 popup 的热更新。配置大概是这样:
import { defineConfig } from "vite"; import { crx } from "@crxjs/vite-plugin"; import manifest from "./manifest.json" with { type: "json" }; export default defineConfig({ plugins: [crx({ manifest })], build: { rollupOptions: { input: { popup: "src/popup/index.html" } }, sourcemap: false, minify: "esbuild" } });方案二是自己写一个小的监听脚本,用chokidar盯着 dist 目录,一有变化就调用浏览器调试协议去 reload 扩展。这个方案更轻,但要自己处理端口和协议,适合不想引额外依赖的场景。
实操心得:无论用哪种方案,content script 的改动都不会自动生效,因为它是注入到页面里的,注入之后就跟插件产物脱钩了。你必须手动刷新目标页面。所以开发大部分逻辑时,我会先把核心逻辑写在 background 里,content script 只做 DOM 采集和转发,这样改逻辑不用刷页面。
2.5 三个调试入口要记牢
第一次写插件的人经常找不到日志。这里明确一下:popup 的日志不在主控制台,你要在弹出的面板上右键选择"检查",才会打开属于 popup 的开发者工具。background 的日志要在扩展管理页找到这个扩展,点"服务工作进程"旁边的链接。content script 的日志在目标页面的控制台里,但因为运行在隔离世界,你在 Sources 面板里要展开 "Content scripts" 分组才能找到源码打断点。
3. 核心功能开发:三块主力模块的写法
插件的主体逻辑基本都分布在 content script、background service worker 和 UI 层这三块。每一块都有自己的脾气,我按实际开发顺序讲。
3.1 content script:注入时机决定你能拿到什么
run_at这个字段决定你的脚本在页面生命周期的哪个点执行,选错了就等于白写。
document_start:DOM 还没构建,这时候只能挂事件监听、改 document 上的属性,拿不到任何元素。document_end:DOM 构建完成但图片等资源可能还没加载完,适合大部分需要操作 DOM 的场景。document_idle:默认值,浏览器自己判断在 document_end 和 window.onload 之间挑一个时机,最保险。
我现在的默认选择是document_idle,遇到那些首屏就渲染完、后续不再变化的老系统,会往前挪到document_end抢一点时间。真正需要拦截初始化的场景,比如要在页面自己的脚本跑之前改掉某个全局变量,才用document_start。
隔离世界是另一个必须理解的概念。content script 运行在一个和页面 JS 隔离的环境里,你能操作 DOM,但读不到页面 JS 定义的变量,比如页面上有个window.__INITIAL_STATE__,你在 content script 里直接访问是 undefined。要拿到它有两个办法:
// 办法一:往页面里注入一个 script 标签,让它跑在页面的世界里 const script = document.createElement("script"); script.src = chrome.runtime.getURL("inject/hook.js"); script.onload = () => script.remove(); (document.head || document.documentElement).appendChild(script);办法二是用 Chrome 111 之后支持的world: "MAIN"配置,直接在 manifest 里声明某段脚本跑在主世界。办法一兼容性更好,办法二更干净,看你团队的目标浏览器版本。
还有一类经典问题是样式冲突。你注入的 CSS 会和页面原有样式互相污染,尤其是body、div这种通配选择器,很容易把页面搞花。我的做法是所有注入节点都包一层 Shadow DOM,样式写在里面完全隔离;如果页面结构不允许,就把所有类名加个固定前缀,比如ext-collector-,并且给关键样式加上!important兜底。
3.2 background service worker:生命周期是最大的坑
V3 的 service worker 会在空闲约 30 秒后被浏览器回收,下次有事件时再唤醒。这意味着两件事:第一,全局变量不靠谱,你存进去的值下次唤醒就没了;第二,任何"启动时执行一次"的逻辑,实际会在每次唤醒时都执行一遍。
所以状态必须落到存储里。我在项目里会统一封装一层:
const KEY = "collected_items_v1"; async function saveItems(items) { const { [KEY]: old = [] } = await chrome.storage.local.get(KEY); const merged = old.concat(items).slice(-2000); await chrome.storage.local.set({ [KEY]: merged }); }slice(-2000)是刻意做的上限控制,插件的存储虽然不小,但无限增长迟早会出问题,尤其是采集类插件。
消息通信这块有一个几乎人人都会踩的坑:异步响应必须return true。
// background.js chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { if (msg.type === "SAVE_ITEMS") { saveItems(msg.payload).then( () => sendResponse({ ok: true }), (err) => sendResponse({ ok: false, error: String(err) }) ); return true; // 关键,保持消息通道打开 } return false; });不写return true的话,sendResponse在异步回调里调用时通道已经关闭,发送方会收到undefined,然后在控制台看到 "The message port closed before a response was received"。这个错误信息完全没有指向性,我第一次遇到时查了整整一个下午。
反过来,发送端也要处理接收端不存在的情况:
// content.js chrome.runtime.sendMessage({ type: "SAVE_ITEMS", payload: items }, (res) => { if (chrome.runtime.lastError) { console.warn("扩展未响应:", chrome.runtime.lastError.message); return; } console.log("写入结果", res); });Could not establish connection. Receiving end does not exist.是最高频的报错之一,通常有三个原因:service worker 正在被回收或还没唤醒;目标标签页已经关闭;你在 popup 里发消息但 popup 已经关了。处理方式就是在发送端判断lastError,不要让它变成未捕获异常。
需要频繁双向通信的场景,比如一个浮层面板要持续和后台交换数据,用长连接chrome.runtime.connect建 Port 更合适,这样连接建立一次就持续复用,也方便在onDisconnect里做清理。
3.3 存储选型:local、sync、session 三者别用混
chrome.storage下面有三个区,用途完全不同,用错了会出各种诡异问题。
storage.local:容量默认约 10MB,申请unlimitedStorage权限后可以更大。适合放采集数据、缓存、配置。写入是异步的,速度快。storage.sync:跟着账号同步到其他设备,但配额极小,单条 8KB、总量 100KB、写入频率也有限制。只适合放用户偏好这类小配置。我一般不碰它,因为配额超了之后是静默失败,排查很麻烦。storage.session:只在当前浏览器会话内有效,关闭浏览器就清空,适合放临时态。
跨模块同步数据靠onChanged监听,比轮询优雅得多:
chrome.storage.onChanged.addListener((changes, area) => { if (area !== "local" || !changes[KEY]) return; render(changes[KEY].newValue || []); });3.4 跨域请求到底该在哪一层发
这是个非常容易搞错的地方。content script 里发fetch,用的是目标页面的源,会受页面 CSP 和同源策略约束;background 里发fetch,用的是扩展自己的源,只要目标域名写在host_permissions里,就能正常跨域,并且可以带上 Cookie。
所以结论很明确:所有对外请求放 background 做,content script 只负责把参数通过消息传过去,拿回结果后渲染。这样还有一个额外好处,请求逻辑集中在一处,将来改接口只改一个文件。
如果你的需求是改写请求本身,比如给内部系统的请求补一个特定请求头,那就得用declarativeNetRequest,在 manifest 里声明静态规则或者运行时动态注册,权限说明里要如实写清楚改写的是什么。
3.5 UI 形态怎么挑
四种 UI 载体各有适用场景,选错的代价是用户体验很差。
| UI 形态 | 打开方式 | 适合的交互 | 不适合的 |
|---|---|---|---|
| popup | 点扩展图标 | 一次性操作、简单开关 | 多步骤流程、需要保持打开 |
| options page | 扩展管理页点选项 | 复杂配置、账号设置 | 高频操作 |
| side panel | 常驻侧边栏 | 持续对话式交互、列表浏览 | 占用横向空间的场景 |
| 页面浮层 | content script 注入 | 与页面内容强关联的操作 | 与页面样式冲突风险高 |
popup 有个特性一定要记住:鼠标点出去它就关闭,里面的所有状态都会丢。所以需要多步确认的流程绝对不能放 popup,要么放 side panel,要么在页面里注入浮层。
4. 打包、上架与分发:部署这一步才是真坑
代码写完只算完成了一半。打包和分发环节的坑,数量不比开发少。
4.1 构建产物处理
上架前一定要检查构建产物,我的检查清单是这样的:
- zip 包解压后,
manifest.json必须在根目录,不能多套一层文件夹。这是最常见的驳回原因,没有之一。 - 关掉 sourcemap,
.map文件不要打进包里。既减小体积,也避免源码泄露。 version字段必须比线上版本高。数字比大小,1.0.10比1.0.9大,但比1.0.2也大,所以别用1.0.9之后写1.0.10这种混淆视听的版本号,老老实实用1.0.9→1.1.0。- 图标尺寸齐全且清晰,128 那张不要用拉伸的低分辨率图,商店会驳回。
- 产物里不要出现
node_modules、测试文件、.env这类东西。
打包用命令行最稳:
cd dist zip -r ../release-1.1.0.zip . -x "*.map" "*.DS_Store"注意zip -r ../release.zip .里的那个点,它表示打包当前目录的内容而不是目录本身,这样解压出来manifest.json才在根目录。
4.2 上架审核常见驳回原因速查
我把这几年遇到的驳回原因整理成一张表,很多问题其实在提交前自查一遍就能避免:
| 驳回原因 | 具体表现 | 处理方式 |
|---|---|---|
| 权限过宽 | 申请了<all_urls>但只有少数页面用得上 | 收窄到具体域名,或用activeTab |
| 缺少用途说明 | 隐私政策没写清楚数据怎么用 | 在商店描述和隐私页面里逐条说明 |
| 违反单一用途 | 一个插件里塞了采集、翻译、广告屏蔽三个功能 | 拆成多个插件,或明确主功能 |
| 远程代码 | 产物里发现有动态加载脚本的逻辑 | 全部打包进本地产物 |
| 描述与实际不符 | 截图和功能对不上 | 截图用真实运行画面 |
| 未说明数据存储位置 | 采集的数据传到服务器但没说明 | 明确写清存哪、存多久、是否共享 |
审核这块我的经验是:把审核员当成一个完全不懂你业务的人,他看到的每一个权限申请,你都要在那个权限旁边给出一句话解释。商店后台很多字段是支持补充说明的,别嫌麻烦。
4.3 企业内网和离线分发方案
内部工具通常不上公开商店,有三种分发方式,我按推荐程度排一下。
第一种:通过策略强制安装。这是最省事的方式,管理员在域内统一下发策略,把扩展 ID 和一个 crx 下载地址写进配置,用户端会在浏览器启动时自动安装并锁定,用户无法卸载。Windows 上通过组策略或者注册表下发,配置项的关键字是ExtensionInstallForcelist,值格式是扩展ID;更新地址。
第二种:自建更新服务。你需要一个固定 URL 提供 crx 文件,再提供一个 update.xml 描述版本信息:
<?xml version='1.0' encoding='UTF-8'?> <gupdate xmlns='http://www.google.com/update2/response' protocol='2.0'> <app appid='aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'> <updatecheck codebase='https://intranet.example.com/ext/app.crx' version='1.0.1' /> </app> </gupdate>然后把这个 xml 的地址写进 manifest 的update_url字段(这个字段只对企业内网分发有效,公开商店上架的扩展不允许带)。用户端浏览器会定期拉取这个 xml 比对版本,发现新版本就自动更新。
第三种:直接加载解压目录。只适合开发和临时验证,重启浏览器后可能被提示禁用,用户每次都要手动确认,绝对不能作为正式分发方式。
注意:这三种方式都需要先把扩展 ID 固定下来。开发模式下加载的扩展每次可能拿到不同的 ID,解决办法是在 manifest 里加一个
key字段,或者在开发者模式下从扩展详情页复制公钥并写进 manifest。ID 不固定的话,策略配置和更新 xml 全部会失效。
4.4 版本升级:老用户为什么没生效
这是被问得最多的问题。用户装了新版但感觉没变化,通常有三个原因。
第一,content script 没有重新注入。旧标签页里跑的还是老版本的脚本,必须刷新页面才行。如果是强制安装的场景,你可以在onInstalled事件里检测版本变化,然后用chrome.tabs.reload主动刷新符合条件的标签页,但要注意这会打断用户操作,得做得克制一点。
第二,存储里的数据结构变了但没做迁移。我在onInstalled里会拿到details.reason和details.previousVersion,根据这两个值判断是不是升级,然后执行对应的迁移逻辑:
chrome.runtime.onInstalled.addListener(async (details) => { if (details.reason !== "update") return; const from = details.previousVersion; if (from && from.startsWith("1.0")) { // 老版本的数据结构转换 await migrateV1ToV2(); } });第三,新增了host_permissions。这是最容易被忽略的一条:当扩展更新后需要新的站点权限时,浏览器会把这个扩展临时禁用,直到用户手动重新授权。用户界面上的表现就是"这个扩展已关闭",用户完全不知道发生了什么。所以如果你的版本升级涉及权限变更,一定要配合引导提示,或者干脆把新权限做成可选权限,在功能真正用到时再通过chrome.permissions.request弹窗申请。
5. 踩坑记录:文档里不会写的问题排查
这一节全部来自实际调试现场,每条都对应一个真实发生过的问题。
5.1 加载失败与运行报错速查表
| 报错信息 | 常见原因 | 排查方向 |
|---|---|---|
| Manifest file is missing or unreadable | zip 多套了一层目录 | 解压确认 manifest 在根目录 |
| Could not load background script | 路径写错或文件没打进包 | 检查 manifest 里的相对路径 |
| Could not establish connection | 接收端不存在或未唤醒 | 判断 lastError,确认标签页存在 |
| The message port closed | 异步响应没写 return true | 给 onMessage 回调补上 return true |
| Uncaught ReferenceError: chrome is not defined | 脚本跑在页面世界 | 检查是否用了 MAIN world 注入 |
| Denying load of chrome-extension:// | 资源没列进 web_accessible_resources | 补充资源声明 |
| Refused to execute inline script | 页面 CSP 拦截了内联脚本 | 改成外部脚本文件引入 |
5.2 几个反直觉的调试技巧
service worker 被回收后,你可能看不到任何日志。判断它有没有在运行,去扩展管理页看"服务工作进程"那一行,如果是灰色的说明已休眠,点一下会唤醒并打开控制台。有时候我怀疑是 service worker 没醒导致消息丢失,就在发送消息前先做一个空操作来唤醒它,虽然有点脏但很管用。
另一个技巧是给 content script 加一个全局标记,比如window.__MY_EXT_VERSION__,注入时写进去。这样在页面控制台敲一下就能确认脚本有没有注入、注入的是哪个版本,比翻 Sources 面板快得多。
还有一个:调试存储数据时,可以直接在 service worker 的控制台里执行await chrome.storage.local.get(null),把整个 local 区的内容打出来,比在 Application 面板里一层层展开快。
5.3 内容脚本重复注入的问题
如果你用了chrome.scripting.executeScript动态注入,同时又配了静态content_scripts,同一段代码可能被注入两次,表现为按钮出现两个、事件触发两次。解决办法是在脚本入口处加一个幂等判断:
if (window.__EXT_INJECTED__) { // 已经注入过,直接退出 } else { window.__EXT_INJECTED__ = true; init(); }如果是模块化的写法,还可以用import()的缓存特性做一层封装,但从可读性来说,显式的标记变量更直观。
5.4 权限变更导致扩展被静默禁用
前面提过一次,这里再展开说处理方式。chrome.permissions.request必须在用户手势的上下文里调用,也就是说不能在页面加载时自动弹,必须由点击触发。我的做法是在设置页放一个"开启增强功能"的按钮,用户点了之后才申请权限,并在按钮旁边写清楚这个权限用来做什么。用户同意了就正常用,拒绝了就降级到基础功能,不要死缠烂打地重复弹窗。
5.5 多浏览器适配的差异点
Edge 基本是 Chromium 内核,Manifest V3 的扩展大多能直接用,但上架要走 Edge 自己的开发者后台,审核标准略有差异。Firefox 的差异更大一些:它支持browser.*命名空间的 Promise 风格 API,而且对chrome.*的支持是部分兼容。我的处理方式是在构建时用webextension-polyfill做一层适配,业务代码统一用 Promise 写法,打包成不同目标时切换对应的 manifest 配置。Firefox 对 MV3 的支持也在推进中,但 service worker 的模型和 Chrome 不完全一样,涉及后台常驻逻辑的功能需要单独测。
6. 几个提升长期维护效率的做法
写到最后一个部分,聊几个跟具体功能无关、但决定这个插件能不能活过两年的习惯。
6.1 把选择器做成配置而不是硬编码
页面改版是插件维护成本的最大来源。我现在的做法是把所有选择器集中在一个配置文件里,用语义化的键名映射:
export const SELECTORS = { listContainer: "[data-testid='item-list']", itemTitle: "h3.title", itemMeta: ".meta .time", pagination: ".pager .next" };这份配置我还会尽量提供多个候选,写一个pick()函数按顺序尝试,第一个能拿到元素的就用。这样页面小改版时,往往不用发新版就能兼容。
6.2 加一层轻量的自检与上报
插件最怕的是"悄悄坏了没人知道"。我会在关键路径上加一层自检:采集到 0 条数据、接口连续失败三次、选择器全部失配,这些情况都算异常,触发时在插件图标上打一个红色角标,提示用户。内部工具的话,还可以把异常摘要发到内部日志服务,这样我能主动发现问题,而不是等用户来投诉。上报内容只包含错误类型和版本号,不带任何页面数据。
6.3 发布节奏和版本管理
我的发布节奏是这样的:功能改动攒到一定量再发,不要在商店里一天推三个版本。每次发版前跑一遍固定的回归清单,包括在三种典型页面上验证基本功能、验证存储读写、验证升级路径。版本号用语义化版本,major.minor.patch,只在 manifest 结构或权限变更时动 major,这个规则团队里要写死,否则版本号很快就会乱。
6.4 后续可以扩展的方向
插件做稳定之后,有几个方向值得考虑。一是把采集到的数据做成本地可检索的索引,用 IndexedDB 而不是 storage,能支撑更大的数据量。二是引入一个轻量的本地模型服务做数据加工,但要特别注意,任何本地服务都必须是用户主动开启、本地运行的,插件本身不能捆绑任何远程服务。三是把核心逻辑抽成独立的 npm 包,这样同一套采集规则可以同时用在插件、浏览器脚本和 Node 端脚本上,维护成本会低很多。
我个人在做这类项目时最深的体会是:写功能只占三成时间,剩下七成都花在跟浏览器的生命周期、权限模型和分发渠道打交道。所以一开始就把 service worker 的无状态设计、存储的迁移逻辑、产物的打包流程搭好,后面每加一个功能的成本都会明显下降。反过来,如果前期图快把状态挂在全局变量上、把逻辑散在各个文件里,到第三个版本基本就得推倒重写。