浏览器插件看起来是工具栏上的一个小图标,实际上是几个运行环境协作的小应用。这门课先用四个文件做出一个能改变当前网页阅读样式的插件,再拆开解释 Popup、Content Script 和 Service Worker,最后补齐消息通信、存储、工程化与发布。
学习目标:不用脚手架也能写出一个可加载的 Manifest V3 插件,知道代码在哪运行、需要什么权限、报错去哪看。 前置知识是 HTML、CSS、JavaScript,以及 Promise 和 async/await;还不熟悉时,可以先看本站对应教程。
内容组织参考枫枫知道的浏览器插件开发课程中课程介绍、Hello World、Content Script、Popup、Service Worker、Plasmo 的主题顺序,讲解和示例为重新编写。本文以桌面版 Chrome / Edge 的 Manifest V3(MV3) 为基线,不把旧教程中的 Manifest V2 写法当作当前方案;Firefox 的兼容差异另行说明。
示例运行边界: 浏览器扩展 API 不能在普通网页或本站的沙箱 iframe 中直接调用。因此本文提供完整文件代码和本地加载步骤,不用模拟按钮冒充真正安装的插件。主线示例不连接后端、不上传网页内容,也不需要安装 npm 依赖。
一、先认识插件:不是一个权限更大的网页
插件由哪些部分组成
| 部分 | 运行在哪里 | 有什么能力 | 不能假设什么 |
|---|---|---|---|
| Manifest | 浏览器读取的 JSON 配置 | 声明入口、权限、资源与版本 | 不是 JavaScript,不能写注释 |
| Popup | 点击工具栏图标打开的扩展页面 | 展示按钮、读取配置、调用获准的扩展 API | 不能直接拿到当前网页的 DOM |
| Content Script | 注入目标标签页的脚本环境 | 读写该页面 DOM,发送扩展消息 | 默认不共享网页脚本的 JS 变量 |
| Service Worker | 扩展的事件驱动后台 | 响应事件、处理消息、协调任务 | 不是常驻进程,也没有 document |
| Options Page | 独立的扩展设置页面 | 管理较复杂的配置 | 不是必需入口 |
用后端开发的经验来理解:Popup 像控制台,Content Script 像部署在网页现场的执行器,Service Worker 像事件处理器。它们通过消息和存储协作,不是靠共享一个全局变量协作。
Manifest V2 与 V3 的常见对照
| 旧资料中的写法 | 本文采用的 MV3 写法 |
|---|---|
manifest_version: 2 | manifest_version: 3 |
browser_action | action |
后台页、background.scripts | background.service_worker |
chrome.tabs.executeScript() | chrome.scripting.executeScript() |
把网站访问权限混在 permissions 中 | 使用 host_permissions 等明确声明 |
| 依赖后台一直存在 | 按可随时停止、下次事件再启动设计 |
MV3 不只是改几个字段。后台生命周期、远程代码限制和权限模型都会影响架构。看到一份教程先检查 manifest_version,再判断代码能否直接用。
本章的坑
插件不等于万能脚本。 chrome://、edge://、扩展商店等页面受到保护;有权限不意味着能在任何页面注入。先用一个普通 HTTPS 页面验证,再排查特殊页面。
chrome 对象存在不代表扩展 API 可用。 普通网页里可能也有名为 chrome 的对象,但没有插件上下文与授权,不能据此判断可调用 chrome.storage。
二、Hello World:四个文件做出阅读助手
建立目录与 Manifest
在电脑上新建 reading-helper 文件夹,保持以下四个文件位于同一层。下面四段代码共同组成第一版完整插件,不需要后台文件。
reading-helper/
├── manifest.json
├── popup.html
├── popup.js
└── content.js
manifest.json:
{
"manifest_version": 3,
"name": "阅读助手",
"version": "1.0.0",
"description": "由用户点击,为当前页面切换阅读样式。",
"permissions": ["activeTab", "scripting"],
"action": {
"default_title": "阅读助手",
"default_popup": "popup.html"
}
}
这里只有两个权限:activeTab 在用户主动调用插件时提供对当前标签页的临时访问能力,scripting 允许使用脚本注入 API。我们不需要开局就申请所有网站访问权限,也不需要为了查当前标签页 ID 额外申请 tabs。
编写 Popup 页面
popup.html:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>阅读助手</title>
<style>
body { width: 280px; margin: 0; padding: 20px; font: 14px/1.6 sans-serif; }
h1 { margin: 0 0 12px; font-size: 20px; }
button { width: 100%; padding: 10px; cursor: pointer; }
#status { min-height: 44px; margin-bottom: 0; overflow-wrap: anywhere; }
</style>
</head>
<body>
<h1>阅读助手</h1>
<button id="toggle" type="button">切换阅读样式</button>
<p id="status" role="status" aria-live="polite">请先打开一个普通网页。</p>
<script src="popup.js"></script>
</body>
</html>
脚本放在单独的本地文件中,不写 onclick="...",也不放内联 JavaScript。扩展页面的内容安全策略(CSP)比普通练习页面更严格,外链 CDN 脚本也不是这里的默认方案。
编写点击逻辑
popup.js:
const toggleButton = document.querySelector('#toggle')
const statusElement = document.querySelector('#status')
/**
* 向用户主动选择的当前标签页注入阅读样式切换脚本。
* 这里只接受普通 HTTP/HTTPS 页面;受保护页面即使是 HTTPS 也可能拒绝注入。
* 注入失败时保留浏览器的具体错误,方便用户在扩展管理页定位问题。
* @returns {Promise<void>} 注入结束并更新弹窗状态后完成。
* @example
* // 用户点击“切换阅读样式”,成功时显示当前是开启还是恢复状态。
* toggleButton.addEventListener('click', toggleReading)
*/
async function toggleReading() {
toggleButton.disabled = true
statusElement.textContent = '正在切换…'
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
if (!Number.isInteger(tab?.id) || !/^https?:\/\//i.test(tab.url || '')) {
throw new Error('请在普通 HTTP/HTTPS 网页上使用。')
}
const results = await chrome.scripting.executeScript({
target: { tabId: tab.id },
files: ['content.js']
})
statusElement.textContent = results[0]?.result
? '已开启阅读样式,再点一次即可恢复。'
: '已恢复原始样式。'
} catch (error) {
statusElement.textContent = `操作失败:${error.message || String(error)}`
} finally {
toggleButton.disabled = false
}
}
toggleButton.addEventListener('click', toggleReading)
chrome.tabs.query() 查的是浏览器标签页,不是网页里的 DOM。executeScript() 才负责把 content.js 送到指定标签页执行。默认只注入主框架,页面里的 iframe 不会因此全部执行一遍。
编写页面脚本
content.js:
/**
* 在当前文档切换一个带唯一标识的样式节点,返回切换后的开启状态。
* 使用立即执行函数避免重复注入时出现顶层 const 重复声明;
* 只增删自己创建的样式,不覆盖网页原本的 style 属性。
* @returns {boolean} true 表示已开启,false 表示已恢复。
* @example
* // 第一次注入返回 true,第二次移除样式并返回 false。
*/
(() => {
const styleId = 'reading-helper-style-v1'
const existing = document.getElementById(styleId)
if (existing) {
existing.remove()
return false
}
const style = document.createElement('style')
style.id = styleId
style.textContent = `
body { background-color: #f5f0e6 !important; }
p, li { line-height: 1.9 !important; }
article, main { max-width: 76ch !important; margin-inline: auto !important; }
`
;(document.head || document.documentElement).append(style)
return true
})()
这是“切换几条阅读样式”,不是完整的正文提取器。复杂网站的深色主题、Shadow DOM、内联样式、特殊布局都可能影响效果。先保持功能边界小,后面再考虑站点适配。
本章的坑: 注入文件的路径相对于扩展根目录,不是相对于网页 URL;代码保存后也不会自动更新已加载的插件,下一章会说明完整刷新顺序。
三、本地加载与调试:先找对控制台
Chrome 与 Edge 的加载步骤
- Chrome 打开
chrome://extensions,Edge 打开edge://extensions。 - 开启“开发者模式”,选择“加载已解压的扩展程序”,选中包含
manifest.json的reading-helper文件夹。 - 在工具栏的扩展菜单里固定阅读助手,打开一个普通网页,例如
https://example.com/。 - 点击阅读助手图标,再点“切换阅读样式”。背景和段落行高应发生变化,再点一次恢复。
加载的是文件夹,不是 ZIP 文件。若提示找不到清单文件,先检查是不是选到了外层目录,或文件被编辑器保存成了 manifest.json.txt。
三个环境对应三个调试入口
| 出问题的位置 | 去哪里看 | 典型错误 |
|---|---|---|
| Manifest / 加载 | 扩展管理页的错误详情 | JSON 格式错误、文件路径错误 |
| Popup | 打开弹窗后,在弹窗内右键“检查” | 找不到按钮、注入 Promise 被拒绝 |
| Content Script | 目标网页的 DevTools,必要时切换执行上下文 | DOM 不存在、选择器错误 |
| Service Worker | 扩展管理页中对应的 Service Worker 检查入口 | 消息校验失败、异步任务失败 |
Popup 控制台和网页控制台不是同一个地方。把日志写进 content.js 后,只盯着 Popup 检查窗口会误以为代码没有执行。
修改代码后的正确顺序
一般采用:保存文件 → 扩展管理页重新加载 → 刷新测试网页 → 重新打开 Popup。
- 改 Manifest、后台或静态注入规则:重新加载扩展。
- 改已注入的 Content Script:刷新网页,旧页面里已经运行的代码不会自动替换。
- 改 Popup:重新打开弹窗;为了排除缓存与上下文问题,调试时仍建议完整刷新。
第一版按点击重新注入文件,但旧的页面样式节点仍可能保留,所以开发时刷新目标页面最省事。以后出现 Extension context invalidated,也优先检查是不是扩展重载后网页还留着旧脚本。
本章验收清单
| 操作 | 预期 |
|---|---|
| 普通网页第一次点击 | 添加 #reading-helper-style-v1 |
| 同页第二次点击 | 移除该节点,不破坏原有内联样式 |
| 关闭再打开 Popup | 页面样式仍在;弹窗本身重新初始化 |
| 刷新网页 | 样式恢复,因为当前版本没有自动重新应用 |
在 chrome://extensions 上点击 | 弹窗给出普通网页使用提示 |
开发工具打开时可能改变 Popup 的关闭行为或后台生命周期。功能调试完成后,关掉检查窗口再走一次正常用户流程。
四、Manifest 与权限:需要什么才申请什么
常用字段速查
| 字段 | 用途 | 示例 |
|---|---|---|
manifest_version | 清单格式版本 | 3 |
name / version | 插件名称与发布版本 | 阅读助手 / 1.0.0 |
action.default_popup | 工具栏弹窗入口 | popup.html |
permissions | API 与特殊能力 | storage、scripting、activeTab |
host_permissions | 持续访问目标站点的范围 | https://api.example.com/* |
content_scripts | 按匹配规则静态注入 | 指定 matches、js、run_at |
background.service_worker | 后台事件脚本 | background.js |
options_ui | 设置页面 | page 和 open_in_tab |
icons | 扩展自身图标 | 按尺寸映射图片路径 |
教程为方便复制省略图标,开发时可用默认图标。发布前准备商店要求的图片资源;不要在 Manifest 中声明一个不存在的图标文件。
activeTab、tabs 与 host_permissions 的区别
| 权限 | 解决的问题 | 注意点 |
|---|---|---|
activeTab | 用户调用扩展后临时访问当前标签页 | 并非永久访问所有站点;跨源导航或关闭标签页会撤销授权 |
tabs | 读取标签页的 URL、标题等敏感属性 | 不等于允许往任意网页注入脚本 |
scripting | 使用脚本/CSS 注入与注册 API | 注入还需 activeTab 或相应站点权限 |
host_permissions | 为指定站点授予持续访问能力 | 应尽量限制到确实需要的域名 |
storage | 使用扩展存储 API | 不等于可以读取网站的 Cookie 或存储 |
第一版使用 activeTab,所以用户通过工具栏图标调用扩展后,可以读取当前页相关属性并注入。不能把这段代码搬进一个未获授权的定时任务,再期待它任意访问所有标签页。
可选权限与网站匹配
不是每个人都会用到的站点能力,可以声明在 optional_host_permissions,在用户点击相应功能时调用 chrome.permissions.request()。要处理用户拒绝的分支,不要反复强迫授权。
网站匹配模式不是正则表达式。https://example.com/* 表示 HTTPS 的指定主机和路径;https://*.example.com/* 用于匹配该域及其子域。正式声明前用官方匹配模式文档核对范围。
本章的坑: 为了让示例“什么网站都能跑”直接申请 <all_urls>,会扩大权限、增加授权提示和审核解释成本。阅读助手的手动开关并不需要它。
五、Content Script:能改 DOM,不等于能读网页变量
隔离世界与共享 DOM
默认的 Content Script 运行在隔离世界(ISOLATED)。它和网页脚本共享页面 DOM,但拥有分离的 JavaScript 执行环境。
假设网页脚本执行 window.appToken = 'page-value',Content Script 中读 window.appToken 不会因此自动得到该值;反过来,Content Script 声明的普通变量也不会自动暴露给网页脚本。
| 操作 | 默认 Content Script |
|---|---|
document.querySelector('h1') | 可以,访问当前注入框架的 DOM |
| 修改段落文字或插入按钮 | 可以,但要避免破坏网站交互 |
| 读取网页 JS 的局部变量 | 不可以直接读 |
访问所有 chrome.* API | 不可以,仅有一部分 API 可直接使用 |
| 读取跨源 iframe 的 DOM | 不能绕过浏览器的跨源限制 |
把脚本改为 MAIN world 会让它进入网页的执行环境,也会改变安全边界和 API 可用性。仅仅为了读写 DOM 不需要这样做,更不应该把凭证放进网页可读的属性中。
动态注入与静态注入怎么选
第一版采用动态注入:用户点击按钮才执行一次,适合阅读开关、截取选中文本等明确由用户触发的功能。
如果产品需求变为“打开指定网站就展示工具条”,可以使用以下独立的 Manifest 片段,合并到自己的清单中;不要与主线的切换脚本直接叠加使用,否则自动执行和手动执行会互相切换状态。
{
"content_scripts": [
{
"matches": ["https://example.com/*"],
"js": ["content.js"],
"run_at": "document_idle"
}
]
}
静态声明按匹配规则注入,规则本身涉及站点访问授权。document_idle 适合一般 DOM 操作,但不保证 SPA 的异步数据已经渲染完。
SPA 与重复注入
单页应用切换路由可能不产生新的文档,因此不会每次都重新触发静态 Content Script。应根据业务选择事件委托、特定容器的 MutationObserver,或必要的导航事件,而不是每隔几毫秒扫描整个文档。
观察 DOM 时要限制范围,并在不再需要时 disconnect();插件自己插入节点也可能触发观察器,处理不当就会产生循环。
本章的坑: 重复注入脚本可能重复绑定事件、重复插入按钮,或遇到顶层变量重声明。主线示例的立即执行函数解决作用域问题,唯一的样式节点解决可逆切换问题;更复杂的工具条应设计明确的初始化标记和销毁逻辑。
六、Popup 与设置:弹窗关了,变量就没了
Popup 的生命周期
Popup 是一个扩展页面,但用户点击其他区域后通常就会关闭。下次打开时,HTML 和 JavaScript 重新执行,局部变量也重新创建。
因此不要用 let enabled = true 作为长期配置来源,也不要让 Popup 承担“必须持续完成”的后台任务。第一版不在 Popup 里保存开启状态,而是根据实际注入结果展示提示。
另外,声明 action.default_popup 后,点击工具栏图标会打开弹窗,不会同时触发该扩展的 chrome.action.onClicked 事件。想走后台点击处理路线,就不要给同一个 action 配置 Popup。
使用 chrome.storage 保存偏好
如需保存阅读主题,先在 permissions 中加入 storage。下面是可选的扩展示例,不影响前四个文件运行;只演示保存配置,不会自动把主题应用到网页。
/**
* 保存经过白名单校验的阅读主题。不要在这里保存密码或服务端密钥。
* @param {'paper'|'light'} theme 用户选择的主题。
* @returns {Promise<void>} 写入扩展本地存储后完成。
* @example
* await saveTheme('paper')
*/
async function saveTheme(theme) {
if (!['paper', 'light'].includes(theme)) throw new Error('不支持的主题')
await chrome.storage.local.set({ readingTheme: theme })
}
/**
* 读取主题;首次安装或数据异常时回退到 paper。
* @returns {Promise<'paper'|'light'>} 可直接用于界面选择器的主题标识。
* @example
* const theme = await loadTheme()
*/
async function loadTheme() {
const { readingTheme } = await chrome.storage.local.get({ readingTheme: 'paper' })
return readingTheme === 'light' ? 'light' : 'paper'
}
| 存储区域 | 适合放什么 | 不适合放什么 |
|---|---|---|
storage.local | 本机偏好、适量缓存 | 无上限的大文件、明文机密 |
storage.sync | 小体积、需要跨设备同步的设置 | 高频写入日志、大量网页内容 |
storage.session | 浏览器会话内的临时状态,支持后台重启后读取 | 重启浏览器仍必须存在的数据 |
storage.sync 受配额、登录及同步状态影响,不要把它当作即时数据库。storage.session 默认不暴露给 Content Script;不要把不同区域的访问规则混为一谈。
什么时候需要 Options Page
配置只有一个开关时放 Popup 就够了;有站点白名单、快捷键说明和较多表单时再加设置页。下面配置中的 options.html 必须是实际存在的扩展文件。
{
"options_ui": {
"page": "options.html",
"open_in_tab": true
}
}
本章的坑: localStorage 和 chrome.storage.local 不是一回事。Service Worker 没有 DOM,也不能使用 window.localStorage;Content Script 使用网页存储时又涉及宿主页的上下文,不能用来冒充统一的扩展配置中心。
七、Service Worker:按事件唤醒,不是常驻后台
什么时候才需要后台
第一版没有后台,因为“点按钮 → 改当前页面”不需要中转。出现右键菜单、定时事件、统一网络访问、跨页面消息协调时,再引入 Service Worker。
MV3 后台没有 window、document,不能直接改网页 DOM。后台读写页面要通过 Content Script 或 scripting API,而不是写 document.querySelector()。
为阅读助手加入后台
从本节开始做第二版。在第一版基础上新增 background.js,并把 Manifest 完整替换为下面内容。其他三个文件保持不变;新增后台能力用于下一章的消息练习,原来的阅读开关仍然可用。
{
"manifest_version": 3,
"name": "阅读助手",
"version": "1.1.0",
"description": "切换当前网页阅读样式,并保存本地阅读偏好。",
"permissions": ["activeTab", "scripting", "storage"],
"action": {
"default_title": "阅读助手",
"default_popup": "popup.html"
},
"background": {
"service_worker": "background.js"
}
}
background.js:
/**
* 处理本扩展发来的主题读取请求。
* 在顶层同步注册监听器,使后台被事件唤醒时即可接收消息。
* 用 return true 保持异步响应通道,避免依赖监听器返回 Promise 的版本差异。
* @param {{type?: string}} message 调用方提交的消息;运行时仍需校验。
* @param {chrome.runtime.MessageSender} sender 浏览器提供的发送者身份。
* @param {function(Object): void} sendResponse 向调用方回传 JSON 可序列化对象。
* @returns {boolean} true 表示本监听器稍后会异步响应。
* @example
* // 在 Popup 中调用:chrome.runtime.sendMessage({ type: 'GET_THEME' })
*/
function handleMessage(message, sender, sendResponse) {
if (sender.id !== chrome.runtime.id || message?.type !== 'GET_THEME') {
return false
}
chrome.storage.local.get({ readingTheme: 'paper' })
.then(({ readingTheme }) => {
sendResponse({ ok: true, theme: readingTheme === 'light' ? 'light' : 'paper' })
})
.catch((error) => {
sendResponse({ ok: false, error: error.message || String(error) })
})
return true
}
chrome.runtime.onMessage.addListener(handleMessage)
这里用存储的默认值避免依赖某个只运行一次的初始化动作。即使后台被停止后重新启动,也能重新读取主题,而不是依赖已经丢失的全局变量。
生命周期带来的设计约束
| 错误假设 | 更稳妥的方式 |
|---|---|
| 全局变量永远在 | 可恢复状态放 storage 或 IndexedDB |
setInterval 可以永久运行 | 定时需求考虑 chrome.alarms,处理延迟与重建 |
先 await 读取配置,再注册监听 | 顶层先注册,事件处理函数内部再读取 |
| 一个端口能确保后台永不结束 | 不把连接当作永久保活保证 |
| 检查窗口里正常就说明生命周期没问题 | 关闭 DevTools 后重新验证后台停止与唤醒 |
浏览器通常会在空闲一段时间后终止后台,还存在单次任务时间等限制;具体规则会随浏览器版本变化。不要把某个观察到的秒数写成业务计时保证,详见官方生命周期说明。
本章的坑: 不要试图用无意义的循环或持续请求保持后台存活。应该让事件可以重入、状态可以恢复;下载、较长流程等需求则选择对应的浏览器 API 和可恢复的任务设计。
八、消息通信:先弄清楚收件人是谁
三条常见通信路线
| 调用方式 | 典型方向 | 适合场景 |
|---|---|---|
chrome.runtime.sendMessage() | Popup / Content Script → 后台等扩展监听器 | 一次请求,一次响应 |
chrome.tabs.sendMessage(tabId, message) | Popup / 后台 → 指定标签页的 Content Script | 读取页面状态、执行页面操作 |
runtime.connect() / tabs.connect() | 创建长期消息端口 | 多次通信,但要处理断线与重连 |
runtime.sendMessage() 不是“自动把消息发给当前网页”。给页面中的 Content Script 发消息时,使用 tabs.sendMessage(),并保证目标页面已经注入且注册了监听器。
调用刚才的后台
在第二版 popup.js 末尾追加下面代码。弹窗打开后,开发者工具会打印保存的主题;这只是后台通信练习,原有按钮逻辑保持不变。
/**
* 请求后台读取主题,并检查业务层响应。
* 只把结果写到 Popup 控制台,避免与阅读开关共用提示区域造成覆盖。
* @returns {Promise<void>} 读取完成;失败时输出可定位的错误。
* @example
* // 弹窗初始化时执行一次,在 Popup 的检查窗口中查看结果。
* printSavedTheme()
*/
async function printSavedTheme() {
try {
const response = await chrome.runtime.sendMessage({ type: 'GET_THEME' })
if (!response?.ok) throw new Error(response?.error || '后台未返回有效结果')
console.log('当前保存的主题:', response.theme)
} catch (error) {
console.error('读取主题失败:', error)
}
}
printSavedTheme()
同一个功能在 Popup 内直接调用 chrome.storage 也能完成。这里刻意经过后台是为了演示协议,不代表所有存储读写都必须转发一次。
异步响应与序列化
本文后台使用普通函数监听,异步调用 sendResponse(),并同步 return true。这样可以避开不同浏览器版本对“直接返回 Promise”支持情况的差异。return true 不是业务成功,而是“响应稍后到达”。
消息应使用普通 JSON 数据,例如 { type: 'GET_THEME' }、{ ok: true, theme: 'paper' }。不要发送 DOM、函数或假设 Map、Date 在所有浏览器中保持原类型;Chrome 和其他实现的序列化行为存在差异。
多个监听器存在时,只处理自己认识的 type。不要无条件使用 async 监听器接管所有消息,也不要期望多个响应自动汇总成一个结果。
消息安全边界
即使 sender.id 是本扩展,也不能把消息里的任意 URL、HTML 或命令直接执行。来自 Content Script 的数据可能受网页影响,应继续校验操作类型、字段类型、长度和目标域名。
本例只允许读取一个固定配置键,不接受“读取任意存储键”或“请求任意 URL”的指令。以后如果功能只允许由 Popup 触发,还应按业务约束检查发送者 URL、是否来自标签页等,而不只是比较扩展 ID。
本章的坑: Could not establish connection. Receiving end does not exist. 往往意味着目标没有监听器、脚本尚未注入,或重载扩展后标签页仍保留旧上下文。先查接收方,再改消息格式。
九、扩展能力:网络请求、资源与安全
请求后端不等于绕过所有限制
扩展页面或后台访问指定远程主机时,可以通过 host_permissions 获得相应跨源访问能力;Content Script 的请求仍受到其运行上下文的跨源规则约束,不会因为清单里写了域名就变成任意跨域代理。
例如需要访问自己的 API,可增加下面的配置片段。api.example.com 是占位域名,不是教程提供的可用接口。
{
"host_permissions": ["https://api.example.com/*"]
}
后台只应调用预先限定的接口与参数,并处理超时、非 2xx 状态和数据格式异常。不要设计“Content Script 传入任意 URL,后台原样 fetch”的通用转发器,否则网页可能借扩展的权限访问本不应访问的资源。
浏览器权限也不能绕过服务端鉴权、限流或业务授权。插件包内的代码和常量可以被查看,服务端密钥不应打包进去。
远程代码、CSP 与 DOM 注入
| 做法 | 建议 |
|---|---|
在 Popup 内写 onclick 或内联脚本 | 改成本地 JS 文件与 addEventListener |
下载 JS 后 eval() / new Function() 执行 | 不采用;扩展逻辑随包发布 |
| 从接口读取 JSON 配置 | 可以设计,但校验结构和取值,不把数据当代码执行 |
把网页选中文本写入 innerHTML | 优先 textContent,不要把页面输入当可信 HTML |
| 把长期令牌放在 DOM 属性中 | 不采用;网页脚本可以读取共享 DOM |
MV3 对远程托管代码有严格限制。即使某种方式在开发环境暂时运行,也不代表满足商店政策。
web_accessible_resources 不是文件总清单
Popup 直接引用扩展包里的本地脚本,不需要把它们全部声明为网页可访问资源。只有普通网页确实需要加载的扩展资源,才考虑 web_accessible_resources 并限制匹配范围。
例如 Content Script 往页面插入一张扩展自带图片时,用 chrome.runtime.getURL('images/badge.png') 获取地址,并按需声明该图片的访问规则。不要简单公开整个扩展目录。
本章的坑: 插件可以接触用户浏览内容,所以“仅本地处理”“保存多久”“是否上传”必须与实现一致。阅读助手不需要收集浏览历史,也不应该为了调试而长期记录完整网页正文。
十、从原生到 Plasmo:框架解决工程问题
先理解原生,再选择工具
原生四文件版本已经可以工作。框架主要解决热更新、打包、TypeScript、UI 组件、资源处理和多浏览器构建,并不会取消 Manifest、隔离环境或权限限制。
| 方案 | 适合谁 | 需要承担什么 |
|---|---|---|
| 原生 HTML / CSS / JS | 入门、小型工具、理解扩展机制 | 自己管理入口与打包 |
| Plasmo | 希望使用 React / TypeScript 快速搭建扩展 UI | 学习文件约定与构建产物 |
| WXT 等扩展框架 | 希望比较 Vue 等 UI 技术与多浏览器流程 | 核对当前版本支持与迁移成本 |
Plasmo 官方文档突出 React 与 TypeScript 支持。不要因为自己使用 Vue,就假设所有 Plasmo 示例可以直接改成 .vue 文件运行;选型时先确认框架的当前能力。
Plasmo 的基本开发流程
下面是另建工程的可选路线,不要在前面的 reading-helper 文件夹里混用。需要 Node.js 和 pnpm;实际版本要求及脚手架提示以Plasmo 官方文档为准。
# 在你自己的项目目录下执行,创建一个新的扩展工程
pnpm create plasmo
# 进入脚手架实际创建的目录,再运行开发构建
cd your-extension
pnpm dev
# 完成功能后生成生产包
pnpm build
your-extension 是占位目录名,不是固定生成的名称。创建命令会下载并运行第三方脚手架,执行前确认包来源;教程本身不需要执行这些命令。
典型入口包括 popup.tsx、content.ts 和 background.ts,更复杂的 Content Script 也有目录式组织方式。框架把这些入口转换为清单和构建文件;修改目录结构时要对照对应版本文档。
加载产物,不是加载源代码目录
Plasmo 开发构建通常输出到 build/chrome-mv3-dev,生产构建通常输出到 build/chrome-mv3-prod,以实际终端输出和项目配置为准。
扩展管理页应加载产物中包含 manifest.json 的目录,不是只有 popup.tsx 和 package.json 的源码目录。生产发布前运行 pnpm build,不要把带开发连接与调试逻辑的包直接上传。
本章的坑: 框架热更新不保证已打开网页里的旧 Content Script 总能无缝替换。遇到异常仍然可以使用“重新加载扩展 → 刷新页面 → 重开 Popup”的排查基线。
十一、发布与兼容:能加载不代表能上架
打包前检查什么
对于原生版本,把 manifest.json 和它引用的文件放在 ZIP 根目录;不要多套一层 reading-helper/。框架版本则打包生产产物,而不是 node_modules 与源码仓库。
| 检查项 | 为什么要做 |
|---|---|
递增 Manifest 的 version | 新版本上传需要有效的版本标识 |
| 清理开发地址、日志与测试账号 | 防止开发配置进入正式包 |
| 所有声明的脚本、图标和页面存在 | 避免本地遗漏或路径大小写问题 |
| 权限对应可见功能 | 降低不必要的授权与审核疑问 |
| 准备商店图标、截图和说明 | 用户需要知道插件做什么 |
| 按实际数据行为填写隐私披露 | 收集、传输与保存行为必须一致 |
| 使用干净环境完成一次安装测试 | 不能依赖开发机遗留存储才能运行 |
Chrome Web Store 和 Microsoft Edge Add-ons 的账号、素材与审核规则分别维护,提交前查各自官方文档。不要把某一时期的费用、审核时长或图片要求当作永远固定的事实。
Chrome、Edge 与 Firefox
Chrome 与 Edge 都基于 Chromium,大量 MV3 API 相近,但仍需要分别验证目标浏览器版本、商店规则和受限页面。
Firefox 的 WebExtensions 与 Chromium 有许多相似 API,但不能承诺同一个 Manifest 原样通用。尤其要检查后台声明、API 支持、权限提示,以及 browser.* / chrome.* 与 Promise 行为。Polyfill 可以改善部分调用差异,不能补齐所有平台能力。
升级与卸载场景
以后改变存储结构时,应为旧版本数据设计迁移与默认值,而不是直接假设字段存在。并行事件中的“读 → 改 → 写”也不具备数据库事务保证,不适合用来维护高频精确计数。
升级后保留设置、关闭浏览器后恢复偏好、用户撤销站点访问权限,都是正常使用场景。不要仅测试“开发者工具一直开着、所有权限已同意”的理想路径。
本章的坑: 本地加载成功只说明浏览器能识别当前包,不代表商店审核通过,也不代表隐私合规已经完成。
十二、排错速查与进阶练习
按症状定位问题
| 症状 | 优先检查 | 处理方式 |
|---|---|---|
| 插件无法加载 | JSON、文件名、选中的目录 | 检查是否存在根目录 Manifest,无注释与尾逗号 |
| Popup 按钮没反应 | Popup 控制台、JS 路径、CSP | 确认外部脚本加载成功,事件正确注册 |
Cannot access contents of url | 页面类型、站点权限、用户是否调用扩展 | 换普通网页,核对 activeTab / 站点授权 |
chrome.scripting 不可用 | 是否在扩展上下文、是否声明权限 | 不要在普通网页控制台直接运行 Popup 代码 |
| 找不到消息接收端 | 注入和监听是否完成 | 重载插件并刷新页面,再确认收件人 |
Extension context invalidated | 页面脚本是否来自旧扩展版本 | 刷新目标页面 |
| 后台变量突然变初始值 | Worker 是否被停止并重启 | 使用可恢复存储,不依赖全局状态 |
| Content Script 读不到网页变量 | 默认隔离世界 | 优先基于 DOM 工作,不盲目切 MAIN world |
| SPA 切页后功能不生效 | 是否发生真正文档加载 | 按需监听局部 DOM/路由变化,避免全页轮询 |
| 生产包报远程脚本或 CSP 错误 | CDN、内联脚本、开发构建产物 | 将代码随包构建,检查生产 CSP |
| 主题保存了但网页没变 | 读取配置与应用样式是否接通 | 存储不会自动驱动网页 DOM 更新 |
如何测量,而不是凭感觉说“没问题”
注入次数: 在测试网页控制台检查 document.querySelectorAll('#reading-helper-style-v1').length。第一版反复点击时应在 0 与 1 之间切换,而不是越点越多。
恢复能力: 使用第二版,在 Popup 检查窗口执行 chrome.storage.local.set({ readingTheme: 'light' })。关闭检查窗口与 Popup,等待后台进入非活动状态后重新打开 Popup,再检查打印的主题;应该仍为 light。随后删除该键再试,应回退为 paper。
权限边界: 分别测试普通网页、受保护页面和跨源导航后的标签页。临时授权失效时,应允许用户重新调用扩展,而不是无限重试。
性能: 若加入 DOM 观察器,在网页 Performance 面板记录操作前后,观察长任务与回调次数。先证明观察范围、频率或处理量有问题,再引入节流;不要用一个固定频率全页扫描来“保证生效”。
三个循序渐进的练习
- 把主题设置接入阅读开关。 让 Popup 保存
paper/light,通过注入函数的args或消息把经过校验的值传到页面。区分“已保存偏好”和“当前页面已应用”的状态。 - 做一个只在指定网站出现的工具条。 使用静态 Content Script,确保 SPA 导航后仍可用,重复初始化不产生两个工具条,并能完整移除自己的 DOM 与监听器。
- 增加用户主动触发的本地摘录。 只保存用户明确选择的文字与允许保存的来源信息,不自动收集整页内容;限制条目大小,提供清空入口,并使用
textContent展示摘录。
到这里,你应该能说清楚四个关键问题:入口由谁声明、代码在哪执行、数据怎么传递、权限为何必需。 这比记住一组 API 名称更重要,也是在原生代码和 Plasmo 等框架之间迁移时最稳定的知识。
参考资料与继续阅读
- 枫枫知道:浏览器插件开发课程:本文选题与核心模块的参考入口。
- Chrome:Manifest 文件格式:清单字段与 MV3 配置。
- Chrome:activeTab:用户调用与临时站点访问。
- Chrome:Content Scripts:隔离环境、注入方式与脚本能力。
- Chrome:消息通信:一次性消息、端口与安全注意事项。
- Chrome:Service Worker 生命周期:后台停止、唤醒与版本差异。
- Chrome:Storage API:存储区域、配额与访问规则。
- MDN:runtime.onMessage:异步响应与跨浏览器兼容提示。
- Plasmo 官方文档:工程化、文件约定和构建流程。
- Chrome Web Store 发布指南:提交包、素材与审核流程。
- Microsoft Edge 扩展发布文档:Edge 商店的发布流程。