浏览器插件开发:从 Hello World 到 Manifest V3 实战

浏览器插件看起来是工具栏上的一个小图标,实际上是几个运行环境协作的小应用。这门课先用四个文件做出一个能改变当前网页阅读样式的插件,再拆开解释 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: 2manifest_version: 3
browser_actionaction
后台页、background.scriptsbackground.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 的加载步骤

  1. Chrome 打开 chrome://extensions,Edge 打开 edge://extensions。
  2. 开启“开发者模式”,选择“加载已解压的扩展程序”,选中包含 manifest.json 的 reading-helper 文件夹。
  3. 在工具栏的扩展菜单里固定阅读助手,打开一个普通网页,例如 https://example.com/。
  4. 点击阅读助手图标,再点“切换阅读样式”。背景和段落行高应发生变化,再点一次恢复。

加载的是文件夹,不是 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
permissionsAPI 与特殊能力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 是一个扩展页面,但用户点击其他区域后通常就会关闭。下次打开时,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 面板记录操作前后,观察长任务与回调次数。先证明观察范围、频率或处理量有问题,再引入节流;不要用一个固定频率全页扫描来“保证生效”。

三个循序渐进的练习

  1. 把主题设置接入阅读开关。 让 Popup 保存 paper / light,通过注入函数的 args 或消息把经过校验的值传到页面。区分“已保存偏好”和“当前页面已应用”的状态。
  2. 做一个只在指定网站出现的工具条。 使用静态 Content Script,确保 SPA 导航后仍可用,重复初始化不产生两个工具条,并能完整移除自己的 DOM 与监听器。
  3. 增加用户主动触发的本地摘录。 只保存用户明确选择的文字与允许保存的来源信息,不自动收集整页内容;限制条目大小,提供清空入口,并使用 textContent 展示摘录。

到这里,你应该能说清楚四个关键问题:入口由谁声明、代码在哪执行、数据怎么传递、权限为何必需。 这比记住一组 API 名称更重要,也是在原生代码和 Plasmo 等框架之间迁移时最稳定的知识。

参考资料与继续阅读

评论

为保障内容质量,评论需站长审核通过后才会展示;提交后没立刻看到属正常,请勿重复提交。

还没有评论,来抢沙发~