ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

从零手写Chrome扩展:Manifest V3核心概念与实战避坑指南

从零手写Chrome扩展:Manifest V3核心概念与实战避坑指南

浏览器地址栏右侧那一排小图标,大概是很多人每天点得最多、却从来没想过自己也能做的东西。我最早起这个念头,是因为一个很具体的烦人场景:每天要在十几个标签页之间反复横跳,手动复制链接、粘贴到表格里做记录,一天下来手指都酸了。当时第一反应是找现成的插件,翻了一圈发现要么功能太重、要么权限要得离谱、要么干脆收费。折腾半天之后我意识到,这东西的门槛其实没有想象中高——一个能解决自己小问题的扩展,核心代码可能就几十行。

所以这篇东西想聊的,就是从零把一个 Chrome 扩展做出来、装进浏览器、跑起来、再打磨到能用的完整过程。它适合两类人:一类是写过一点前端、想找个轻量项目练手的开发者;另一类是完全没碰过浏览器扩展、但被某个重复操作折磨得受不了、想自己动手解决的人。我会把 Manifest 配置、内容脚本、后台逻辑、弹窗界面、调试排错这些环节拆开讲,重点放在"为什么这么设计"和"实际会踩哪些坑"上,而不是照抄官方文档。看完你应该能独立做出一个结构完整、权限克制、能长期用下去的小扩展。

1. 先想清楚扩展到底在浏览器里扮演什么角色

很多人一上来就打开编辑器写 manifest.json,结果写到一半发现不知道该用哪种脚本、权限该申请哪些,来回返工。问题出在没先建立心智模型。Chrome 扩展本质上不是"一个网页",也不是"一个独立程序",它是寄生在浏览器进程里、通过一套约定接口和浏览器对话的一组文件。你写的 HTML、CSS、JS 还是那套东西,但运行环境被切成了几个互不相通的沙箱,每个沙箱能干什么、能碰什么,都由清单文件提前声明。

1.1 四个运行环境,各管一摊事

理解扩展的第一步,是把它的运行环境拆开看。一个典型扩展里通常有这么几块:

运行环境生命周期能访问什么典型用途
弹窗页面(popup)点图标才创建,关掉即销毁自己的 DOM、扩展 API设置面板、快捷操作入口
内容脚本(content script)跟随匹配的网页加载网页 DOM、受限的扩展 API读取/修改页面内容
后台脚本(service worker)事件驱动,空闲会被回收全部扩展 API,无 DOM跨标签协调、网络请求、状态管理
选项页(options)打开设置时加载自己的 DOM、扩展 API长期配置项

这张表建议贴在脑子里。我见过太多新手把"需要长期保存的状态"写在弹窗里,结果用户一点别处弹窗销毁,数据全没了;也见过把网络请求塞进内容脚本,被页面的跨域策略卡死。每个环境的能力边界是硬约束,不是风格偏好,选错了不是写得丑,是根本跑不通。

1.2 为什么后台脚本现在叫 service worker

如果你翻过几年前的教程,会看到"background page"这个说法,那是一个常驻的后台页面。现在的 Manifest V3 把它换成了 service worker,核心变化是它不再常驻:没有事件时会被浏览器回收,有事件再唤醒。这个改动直接影响写法——你不能在全局变量里存状态然后指望它一直在,因为下一次唤醒时全局变量已经重置了。

正确的做法是把需要持久化的东西放进chrome.storage,每次事件触发时读出来、处理完再写回去。这个模式一开始会觉得啰嗦,但习惯之后反而更清晰:状态有明确的家,不依赖某个进程活着。我在第一个项目里就栽过这个跟头,把登录态存在全局变量里,测试时一切正常,上线后用户反馈"过一会儿就掉线",查了半天才反应过来是 service worker 被回收了。

1.3 权限声明是一份契约,不是许愿池

清单里的permissions和host_permissions决定了扩展能碰什么。新手常见的两个极端:要么一个权限都不申请,结果 API 调用全报错;要么把能想到的权限全勾上,安装时吓跑用户,审核也容易卡。

我的原则是按需最小化。比如你只是要读当前标签页的标题,activeTab就够了,不需要tabs全量权限;只是要在特定站点注入脚本,就把匹配规则写精确,别用<all_urls>。权限越少,用户安装时的心理门槛越低,出问题时排查范围也越小。后面第 4 节会具体讲怎么根据功能反推权限清单。

2. 从一份能跑起来的最小清单开始

心智模型有了,就可以动手了。我强烈建议第一个版本不要贪多,先做一个"点图标弹出一句话"的扩展,把从写文件到装进浏览器的整条链路跑通,再往上加功能。这样任何环节出问题,你都能确定是那一个环节的事,而不是在一堆代码里大海捞针。

2.1 目录结构与 manifest.json 的骨架

一个最小可运行扩展只需要三个文件,放在同一个文件夹里:

my-extension/ ├── manifest.json ├── popup.html └── popup.js

manifest.json是整个扩展的身份证,浏览器靠它认识你。Manifest V3 的最小版本长这样:

{ "manifest_version": 3, "name": "标签页小助手", "version": "1.0.0", "description": "一个用来练手的最小扩展", "action": { "default_popup": "popup.html", "default_title": "点我" } }

几个字段值得单独说。manifest_version必须是 3,写 2 的话新版本浏览器会直接拒绝加载。version是四段式以内的数字加点,不能带字母,我见过有人写1.0.0-beta结果加载失败,报错信息还不明显。action定义了工具栏图标的行为,default_popup指向点击后弹出的页面。

popup.html就是普通 HTML,注意不能写内联脚本,MV3 的内容安全策略禁止内联<script>和onclick这类属性,所有 JS 必须放在独立文件里用<script src>引入。这个限制卡过不少人,页面打开一片空白,控制台报 CSP 错误,其实就是这个原因。

<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <style> body { width: 240px; padding: 12px; font-family: sans-serif; } </style> </head> <body> <p id="msg">你好,扩展</p> <button id="btn">点一下</button> <script src="popup.js"></script> </body> </html>

popup.js里就能调用扩展 API 了:

document.getElementById('btn').addEventListener('click', () => { document.getElementById('msg').textContent = '按钮被点了'; });

2.2 加载未打包扩展的正确姿势

写完文件,打开浏览器的扩展管理页(地址栏输入chrome://extensions),右上角打开"开发者模式",点"加载已解压的扩展程序",选中你的文件夹。成功的话工具栏会出现图标,点开就是你的弹窗。

这里有个高频坑:改完代码必须手动点扩展卡片上的刷新按钮,弹窗不会自动热更新。很多人改了半天没反应,以为代码错了,其实是没刷新。另外,如果加载时报"清单文件缺失或不可读",八成是文件夹层级选错了——你要选的是包含 manifest.json 的那一层,而不是它的父目录。

提示:开发阶段建议把扩展固定到工具栏(点拼图图标再点图钉),否则图标会被折叠进菜单里,每次找起来很烦。

2.3 为什么先跑通再扩展

这一步看起来简单,但它验证了整条链路:文件结构对不对、清单能不能被解析、弹窗能不能渲染、脚本能不能执行。这四件事任何一件出问题,后面的功能都无从谈起。我自己的习惯是每加一个功能就重新加载一次、点一遍,确认没破坏已有逻辑。扩展开发没有热重载,靠的就是这种小步验证的节奏,图快反而慢。

3. 内容脚本:让扩展真正"看见"网页

弹窗只能做入口,真正让扩展有价值的,往往是它能读取或改动网页内容——这就是内容脚本的活。内容脚本是一段注入到目标页面的 JS,它和页面共享 DOM,但运行在隔离的沙箱里,拿不到页面自己的 JS 变量,也碰不到大部分扩展 API。这个隔离设计是为了安全,理解它能帮你少走弯路。

3.1 声明式注入与编程式注入的取舍

内容脚本有两种注入方式。声明式是在清单里写死匹配规则,页面加载时自动注入:

{ "content_scripts": [ { "matches": ["https://example.com/*"], "js": ["content.js"], "run_at": "document_idle" } ] }

编程式是通过chrome.scripting.executeScript在运行时按需注入,需要scripting权限和对应的主机权限。两者怎么选?我的经验是:功能固定绑定某几个站点,用声明式,省心;需要用户手动触发、或者注入目标动态决定,用编程式,灵活。

run_at这个字段容易被忽略。默认是document_idle,等页面基本加载完再注入;如果你要操作的元素在页面早期就存在、且后续会被脚本覆盖,可能需要document_start,但那时 DOM 还没建好,得配合监听。选错时机,脚本要么找不到元素,要么改动被页面自己的逻辑冲掉。

3.2 内容脚本与页面脚本的通信边界

内容脚本虽然能改 DOM,但它和页面里的 JS 是两个世界。页面里window.someVar你在内容脚本里读不到,反之亦然。如果确实需要和页面脚本交换数据,标准做法是通过window.postMessage做桥接,或者往页面注入一段<script>标签。后者要小心,注入的脚本运行在页面上下文,权限和风险都更高,能不用就不用。

内容脚本和扩展其他部分(后台、弹窗)通信则走chrome.runtime.sendMessage和chrome.runtime.onMessage。这里有个细节:消息是异步的,且发送方不一定还活着。比如内容脚本发消息给后台,后台处理完回消息时,如果那个标签页已经关了,回调就永远不触发。所以发消息的地方最好加超时兜底,别让 UI 卡在"加载中"。

3.3 一个真实点的例子:抓取页面里的链接

假设我们要做一个"把当前页面所有外链收集起来"的功能。内容脚本负责扫描 DOM:

// content.js function collectLinks() { const links = Array.from(document.querySelectorAll('a[href]')) .map(a => a.href) .filter(href => href.startsWith('http')); return [...new Set(links)]; } chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { if (request.type === 'COLLECT_LINKS') { sendResponse({ links: collectLinks() }); } return true; // 保持消息通道开放,支持异步响应 });

注意最后那个return true。如果sendResponse是同步调用的,其实不写也行;但只要响应逻辑里有可能异步(比如先请求再回),就必须返回true,否则通道会提前关闭,发送方收到undefined。这个坑我踩过不止一次,表现是"偶尔能拿到数据、偶尔拿不到",非常难查。

弹窗那边发起请求:

// popup.js chrome.tabs.query({ active: true, currentWindow: true }, ([tab]) => { chrome.tabs.sendMessage(tab.id, { type: 'COLLECT_LINKS' }, (response) => { if (chrome.runtime.lastError) { console.warn('内容脚本未就绪:', chrome.runtime.lastError.message); return; } console.log(response.links); }); });

chrome.runtime.lastError这个检查必须做。当目标页面没有注入内容脚本(比如匹配规则没覆盖到),sendMessage会失败,不检查的话控制台会冒红字,而且回调里的response是undefined,直接访问属性就崩了。

4. 权限设计:少要一点,麻烦少一点

权限这块值得单独拎出来讲,因为它同时影响用户体验、审核通过率和后期维护成本。我见过功能很简单、却申请了"读取所有网站数据"的扩展,安装页面的警告文案能把人吓退。反过来,权限申请得精准,用户信任度高,出问题时也更容易定位。

4.1 从功能反推权限清单

与其先勾权限再想功能,不如反过来:列出每个功能需要碰什么,再映射到权限。下面这张对照表是我自己常用的:

想做的事需要的权限说明
读当前标签页 URL/标题activeTab用户点击图标后才临时授权,最克制
遍历/管理所有标签页tabs权限较重,安装时会提示"读取浏览历史"
在指定站点注入脚本host_permissions精确匹配别用通配所有站点
跨域请求数据host_permissions对应域名请求哪个域就声明哪个域
本地存配置storage轻量,几乎无副作用
右键菜单contextMenus按需

activeTab是个被低估的权限。它不需要在安装时吓用户,只有用户主动点击扩展图标、或触发你注册的快捷键/菜单时,才临时获得当前标签页的访问权。如果你的功能是"对当前页面做点什么",优先考虑它,而不是一上来就要tabs。

4.2 host_permissions 写宽了会怎样

host_permissions里写"<all_urls>"或者"*://*/*",意味着扩展能读写你访问的任何网站。这在安装时会触发最严厉的警告,而且审核时如果功能对不上,容易被驳回。更实际的问题是:权限越大,出安全事件时的影响面越大。万一你的扩展被恶意利用,宽权限就是放大器。

我的做法是把匹配规则收窄到真正需要的域名,甚至用路径前缀进一步限制。比如只处理某个站点的搜索页,就写"https://example.com/search/*"。这样既满足功能,又把暴露面压到最小。

4.3 可选权限与运行时申请

有些功能不是所有用户都用,这时可以用optional_permissions,在用户主动开启某功能时再调chrome.permissions.request申请。好处是安装时的权限列表更干净,坏处是代码里要多处理"权限没给"的分支。我的判断标准是:核心功能必需的权限放清单里,锦上添花的功能用可选权限。别为了安装页好看,把核心功能也做成可选,结果用户点了没反应,体验更差。

5. 状态管理与后台逻辑的持久化写法

功能一旦超过"点一下变个文字"的复杂度,就绕不开状态管理。前面提过 service worker 会被回收,所以状态不能放全局变量。这一节把持久化的几种方案和适用场景理清楚。

5.1 chrome.storage 的三种区域

chrome.storage分local、sync、session三块。local存在本机,容量大(默认 10MB 左右,可申请更大),适合存缓存和大块数据;sync会跟着用户账号同步到其他设备,容量小(约 100KB,单项 8KB),适合存配置项;session只在当前浏览器会话有效,关浏览器就清,适合存临时状态。

选错区域的典型症状:把大段抓取结果塞进sync,写的时候直接报配额超限;或者把"当前这次会话的临时标记"放进local,下次打开浏览器发现状态还留着,逻辑错乱。我的习惯是配置放sync、缓存放local、会话级标记放session,边界清晰。

5.2 读写都是异步的,别当同步用

chrome.storage的 API 全是异步的,早期是回调风格,现在也支持 Promise。新手常犯的错是写完set立刻get,以为能读到新值,实际上set还没落盘。正确做法是await或者放进回调:

async function bumpCounter() { const { count = 0 } = await chrome.storage.local.get('count'); await chrome.storage.local.set({ count: count + 1 }); return count + 1; }

并发场景还要注意竞态:两个地方同时读-改-写,可能互相覆盖。简单计数器问题不大,但涉及关键数据时,要么加锁(用一个内存标志串行化),要么把状态收敛到单一入口处理,别让多处代码各自读写同一份数据。

5.3 事件监听要注册在顶层

service worker 被唤醒时,会重新执行你的脚本。Chrome 要求事件监听器在脚本顶层同步注册,不能放在异步回调或条件分支里,否则唤醒后监听器没挂上,事件就丢了。这个规则很硬,违反了表现是"第一次能用,过一会儿就不响应"。

// 正确:顶层同步注册 chrome.runtime.onInstalled.addListener(() => { console.log('扩展已安装或更新'); }); chrome.action.onClicked.addListener((tab) => { // 注意:如果配置了 default_popup,onClicked 不会触发 console.log('图标被点击', tab.id); });

顺带提一个容易混淆的点:action里配了default_popup之后,点图标是弹窗,onClicked不会触发。想要"点图标直接执行动作、不弹窗",就别配default_popup,改用onClicked监听。两者只能选一个,别指望同时生效。

6. 调试与排错:那些文档里不写的坑

扩展开发的调试体验和普通网页差别很大,因为代码分散在几个环境里,报错也各在各的控制台。这一节把我实际踩过的、以及帮别人排查时反复遇到的坑整理出来,按"症状—原因—解法"组织,方便对照。

6.1 三个控制台,别找错地方

弹窗的日志在弹窗自己的开发者工具里(右键弹窗点"检查");内容脚本的日志在宿主页面的控制台里,但要注意它和页面日志混在一起,可以在控制台顶部的上下文下拉框里切到你的扩展;service worker 的日志要点扩展管理页里对应卡片的"Service Worker"链接,会单独开一个调试窗口。

最常见的困惑是"我明明打了 log 却看不到",九成是看错了控制台。内容脚本的 log 不会出现在扩展管理页,service worker 的 log 也不会出现在页面控制台。养成习惯:先确认这段代码跑在哪个环境,再去对应的控制台找。

6.2 改了代码没生效的几种可能

按可能性从高到低排:一是没点扩展卡片的刷新按钮;二是改了manifest.json但没重新加载扩展(清单变更必须重载);三是内容脚本注入到了旧页面,需要刷新目标网页;四是浏览器缓存了旧文件,硬刷新或清缓存。我一般按这个顺序排查,基本能覆盖。

还有一种隐蔽情况:代码确实更新了,但逻辑分支没走到。比如你改的是onInstalled里的逻辑,而扩展已经安装过,不会再次触发onInstalled。这种时候要么手动卸载重装,要么把逻辑挪到别的事件里测试。

6.3 消息通信失败的排查链路

消息相关的 bug 最难查,因为失败往往是静默的。我的排查顺序是这样的:

  1. 确认接收方是否真的注册了监听器(在接收方环境打 log)。
  2. 确认发送时接收方是否已就绪(内容脚本可能还没注入)。
  3. 检查sendResponse是否异步,异步时有没有return true。
  4. 检查chrome.runtime.lastError,把错误信息打出来。
  5. 确认消息的type字段两边拼写一致(大小写、下划线都算)。

第 5 条听起来很蠢,但我真的因为COLLECT_LINKS和COLLECTLINK不一致查了半小时。消息通信没有类型检查,全靠字符串约定,建议把 type 抽成常量集中管理,别到处手写。

6.4 内容脚本拿不到页面变量的真相

前面提过隔离沙箱,这里展开说。内容脚本和页面共享 DOM,但 JS 执行环境是隔离的。页面里window.__DATA__这种挂在 window 上的数据,内容脚本读不到;反过来内容脚本定义的变量,页面也看不到。这不是 bug,是设计。

如果确实需要读页面脚本产生的数据,可行的路子有两条:一是通过 DOM 间接拿(比如数据最终渲染到了某个元素的属性或文本里);二是用window.postMessage和页面脚本约定通信协议,让页面脚本主动把数据发出来。后者需要你能控制或注入页面脚本,复杂度更高,非必要不用。

7. 从能跑到好用:发布前的收尾清单

功能跑通只是及格线,真正决定一个扩展能不能长期用下去的,是那些收尾细节。这一节列的是我每次发布前都会过一遍的检查项,按重要性排序。

7.1 图标、名称与描述的打磨

工具栏图标建议准备 16、32、48、128 四个尺寸,在清单的icons和action.default_icon里分别声明。只给一个大图让浏览器缩放,小尺寸下会糊。名称控制在十几个字以内,描述一句话说清"解决什么问题",别堆关键词。这些细节直接影响用户第一眼要不要装。

7.2 错误处理与降级

网络请求要加超时和失败提示,别让用户对着转圈干等。内容脚本注入失败要有兜底文案,而不是静默无反应。存储读写失败(比如配额满)也要捕获,至少给个提示。扩展不像网页可以整页刷新重来,一个没处理的异常可能让某个功能永久卡死,用户只能卸载重装。

7.3 版本号与更新策略

version每次发布必须递增,浏览器靠它判断是否需要更新。开发阶段可以随便跳,正式发布后建议遵循语义化:修 bug 加末位,加功能加中位,大改加首位。更新逻辑放在onInstalled里,通过details.reason区分是首次安装还是更新,做不同的初始化。

7.4 打包与分发的基本认知

开发时用"加载已解压的扩展程序",分发时要把文件夹打成 zip 上传到扩展商店。打包前记得删掉调试用的 log、测试数据和多余的权限声明。商店审核对权限和隐私政策有要求,如果扩展会收集数据,必须如实声明用途。这块规则会变,提交前以商店后台的最新说明为准。

8. 我在这条路上攒下的几条经验

做了几个扩展之后,有些体会是文档里不会写、但确实影响效率的。第一条是先做减法:一个扩展只解决一个明确问题,功能越聚焦,权限越少,代码越好维护,用户也越容易理解它是干嘛的。我早期总想做一个"什么都能干"的工具,结果每个功能都半吊子,最后自己都不用了。

第二条是把调试成本算进设计里。扩展没有热重载,每次改动都要手动刷新、重新点一遍,所以我会尽量把逻辑拆成可独立测试的小函数,能脱离浏览器环境验证的先验证,减少在浏览器里反复试错的次数。消息通信的 type 抽成常量、存储的 key 集中定义,这些看似琐碎的习惯,长期看省下的时间很可观。

第三条是权限和隐私上别耍小聪明。用户装扩展时看到的权限警告是真实的,你申请了什么就能做什么。与其想办法绕过提示,不如把功能设计得不需要那些权限。一个权限克制、行为透明的扩展,哪怕功能简单,也更容易被信任和长期保留。这行做久了会发现,能活下来的往往不是功能最多的,而是最让人放心的那个。

RELATED READING

延伸阅读

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