从零开发浏览器插件:我的第一个 Chrome 扩展实战笔记
前言
最近我在 AI 的帮助下完成了一个浏览器插件的开发——TextNova,一个选中网页文字就能调用 AI 进行翻译、解释、总结的工具。作为一个浏览器插件开发的新手,整个开发过程让我对 Chrome 扩展的架构和运作机制有了深入的理解。
今天把这段经历整理成文,希望给同样想入门浏览器插件开发的同学一些参考。
一、项目简介
TextNova 的功能很简单:在任意网页选中文字后,会弹出一个悬浮按钮,点击后调用 AI API,结果以弹窗形式显示在页面上。
核心特点:
-
零依赖:纯原生 JavaScript,没有使用 React/Vue 等框架 -
流式输出:支持 AI 响应的流式显示 -
自定义菜单:用户可以添加自己的 AI 处理模板 -
多 API 支持:兼容智谱 AI 和其他 OpenAI 格式接口
二、浏览器插件的核心架构
Chrome 扩展采用三层分离架构,这是理解插件开发的第一把钥匙。你可以将它想象成一个高效协作的团队,各司其职:
┌─────────────────────────────────────────────────────┐│ 用户网页 ││ (content.js 注入到这里运行) ││ 负责与页面内容和用户交互 │└─────────────────────────────────────────────────────┘ ▲ │ chrome.runtime.sendMessage / chrome.tabs.sendMessage ▼┌─────────────────────────────────────────────────────┐│ background.js (Service Worker) ││ 处理 API 调用、管理菜单、协调消息 ││ 插件的“幕后总指挥” │└─────────────────────────────────────────────────────┘ ▲ │ chrome.storage.sync ▼┌─────────────────────────────────────────────────────┐│ sidepanel.html/js (侧边栏) ││ 配置界面、测试入口 ││ 用户的“操作控制台” │└─────────────────────────────────────────────────────┘
各层的职责
|
|
|
|
|
|---|---|---|---|
|
|
background.js |
|
|
|
|
content.js |
|
|
|
|
sidepanel.html |
|
|
三、Manifest V3 配置文件详解
manifest.json 是插件的”身份证”,定义了插件的基本信息和权限:
{"manifest_version": 3,"name": "TextNova","version": "1.0.0","description": "选中文字,AI帮你翻译、总结、解释","action": {"default_icon": {"16": "assets/icon16.png","48": "assets/icon48.png","128": "assets/icon128.png" } },"permissions": ["sidePanel", // 侧边栏 API"storage", // 存储 API"activeTab", // 活动标签页"tabs", // 标签页管理"scripting", // 脚本注入"contextMenus"// 右键菜单 ],"host_permissions": [ // 声明需要访问的外部 API 域名,遵循最小权限原则"https://open.bigmodel.cn/*""https://*.openai.com/*" ],"background": {"service_worker": "background.js","type": "module" },"content_scripts": [{"matches": ["https://*/*"],"js": ["content.js"],"css": ["content.css"] }]}
关键配置说明
-
manifest_version:必须是 3,表示使用 Manifest V3 标准。 -
permissions:定义插件需要使用的 Chrome API 权限。遵循最小权限原则,只申请必需的权限,增强用户信任和安全性。 -
host_permissions:声明插件需要访问的特定域名。这对于需要跨域请求的 API 调用至关重要。 -
background.service_worker:指定后台 Service Worker 脚本。Service Worker 是非持久的,按需唤醒,有效节省资源。 -
content_scripts.matches:控制 content script 注入到哪些页面。此处配置为 "https://*/*"表示注入到所有HTTPS 页面。
四、开发 SOP:从零到上线的完整流程
步骤 1:创建基础文件结构
一个清晰的项目结构是高效开发的基础:
TextNova/├── manifest.json # 插件的配置文件,必不可少├── background.js # 后台 Service Worker 脚本,处理核心逻辑├── content.js # 内容脚本,与网页进行交互├── content.css # 内容脚本的样式文件├── sidepanel.html # 侧边栏的 HTML 页面└── sidepanel.js # 侧边栏的 JavaScript 逻辑└── icons/ # 存放插件图标的目录 ├── icon16.png ├── icon48.png └── icon128.png
步骤 2:加载插件进行调试
将你的代码加载到 Chrome 中进行调试是开发的第一步:
-
打开 Chrome,访问 chrome://extensions/ -
开启右上角的「开发者模式」 -
点击「加载已解压的扩展程序」 -
选择项目根目录

步骤 3:开发调试循环
在开发过程中,你将频繁地修改代码并测试。遵循这个循环可以提高效率:
# 修改了 background.js 或 manifest.json 后:1. 去 chrome://extensions/ 页面,点击你的插件卡片上的「刷新」图标。# 修改了 content.js 或 content.css 后:2. 回到你正在测试的网页,按 F5 刷新页面(**非常重要!**)。3. 重新测试你的功能。
踩坑提醒:修改 content.js 或 content.css 后,必须刷新所有已打开的、注入了该内容脚本的网页,否则你可能会遇到「Extension context invalidated」错误。这是因为旧的 content script 仍在运行,但它与 background Service Worker 的连接已断开,导致通信失效。
五、核心技术点与代码解析
5.1 消息通信机制
Chrome 扩展的各个部分(background、content script、sidepanel 等)运行在独立的上下文中,它们之间不能直接访问彼此的变量或函数,必须通过消息通信。主要有两种方式:
(1)一次性请求:chrome.runtime.sendMessage
适用于短生命周期、一次性的消息传递,通常用于 content script 向 background 发送请求。
// content.js 发送请求:将选中的文本发送给后台进行 AI 处理chrome.runtime.sendMessage( { type: 'AI_REQUEST', menuId: 'translate', text: 'Hello' }, (response) => { // 接收来自 background 的响应if (response && response.content) {console.log(response.content);// 在页面上显示 AI 处理结果 } });// background.js 监听并响应:处理来自 content script 的 AI 请求chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {if (message.type === 'AI_REQUEST') {// 异步调用 AI 服务 callAI(message.menuId, message.text).then(result => { sendResponse(result); // 将 AI 处理结果发送回 content script }).catch(error => {console.error('AI call failed:', error); sendResponse({ error: error.message }); });returntrue; // 必须返回 true,表示 sendResponse 将会异步调用 }});
(2)标签页通信:chrome.tabs.sendMessage
适用于 background 或其他扩展页面向指定标签页的 content script 发送消息。
// background.js 向指定标签页发送消息:将 AI 结果推送到当前页面显示chrome.tabs.sendMessage(tabId, {type: 'SHOW_POPUP', // 消息类型result: { content: '翻译结果...' } // 消息内容}, response => {if (chrome.runtime.lastError) {console.warn("Error sending message to tab:", chrome.runtime.lastError.message); }});
5.2 悬浮按钮的智能定位
当用户选中文字后,TextNova 的悬浮按钮需要显示在合适的位置,既要靠近选区,又不能超出屏幕边界,保证良好的可见性。
// 监听鼠标松开事件,处理文本选择后的浮窗显示document.addEventListener('mouseup', async (e) => {// 检查扩展是否有效(未禁用或卸载)if (!checkExtensionValid()) {if (state.floatButton) state.floatButton.style.display = 'none';return; }// 获取用户选中的文本const selection = window.getSelection();const text = selection.toString().trim();// 只在选中文本长度为 1-5000 字符时显示浮窗if (text.length > 0 && text.length < 5000) {// 创建或获取浮窗按钮 state.floatButton = await createFloatButton(); state.floatButton.style.display = 'flex';// 检查是否需要重新定位(非手动拖动过的浮窗才自动定位)if (needsPositioning(state.floatButton)) {// 获取选中文本的位置信息const range = selection.getRangeAt(0);const rect = range.getBoundingClientRect();const buttonWidth = 130;const buttonHeight = 50;// 默认显示在选中文本下方 8px 处let top = window.scrollY + rect.bottom + 8;let left = window.scrollX + rect.left;// 如果浮窗超出右侧边界,向左调整if (left + buttonWidth > window.innerWidth) { left = window.innerWidth - buttonWidth - 20; }// 如果浮窗超出底部边界,尝试显示在选中文本上方if (top + buttonHeight > window.innerHeight + window.scrollY) {const alternativeTop = window.scrollY + rect.top - buttonHeight - 8;if (alternativeTop >= 0) { top = alternativeTop; } }// 设置浮窗位置,至少距离边缘 10px state.floatButton.style.top = `${Math.max(10, top)}px`; state.floatButton.style.left = `${Math.max(10, left)}px`; } } else {// 未选中文本或文本过长,隐藏浮窗if (state.floatButton) { state.floatButton.style.display = 'none'; } }});
5.3 流式响应实现
为了提供更流畅的用户体验,TextNova 支持 AI 响应的流式显示,即结果会像打字一样逐字呈现,而不是等待全部结果返回。这通常通过处理 ReadableStream 和服务器发送事件 (SSE) 实现。
// background.jsconst response = await fetch(url, { ... });const reader = response.body.getReader();const decoder = new TextDecoder('utf-8');while (true) {const { done, value } = await reader.read();if (done) break;const chunk = decoder.decode(value, { stream: true });// 解析 SSE 格式数据const lines = chunk.split('\n');for (const line of lines) {if (line.startsWith('data: ')) {const data = JSON.parse(line.slice(6));const content = data.choices[0].delta.content;// 实时发送到前端 chrome.tabs.sendMessage(tabId, {type: 'AI_STREAM_CHUNK', content }); } }}
六、踩坑记录与解决方案
在开发过程中,遇到问题是常态。以下是我在开发 TextNova 时遇到的一些典型“坑”,以及它们的解决方案。
坑 1:Extension context invalidated
现象:当你在 chrome://extensions/ 页面刷新或重新加载插件后,如果之前打开的网页中存在运行中的 content script,当你尝试与其通信或操作时,会报错 Extension context invalidated。
原因:旧的 content script 实例仍然存在于网页中,但它与 background Service Worker 的连接已经断开(因为 Service Worker 被重新加载了)。它所依赖的扩展上下文已不再有效。
解决:在 content script 中,每次尝试与 background 通信前,先检查扩展上下文是否仍然有效。如果失效,则提示用户刷新页面。
// 检测扩展是否有效functioncheckExtensionValid() {try {return !!(chrome.runtime && chrome.runtime.id); } catch (e) {returnfalse; }}// 使用前检查if (!checkExtensionValid()) { showPopup('error', null, '', {error: '扩展已更新,请刷新页面 (F5)' });return;}
坑 2:异步消息处理未返回 true 导致 sendResponse 失效
现象:在 background.js 的 onMessage 监听器中,执行了异步操作(如 fetch 请求、setTimeout 等),并在异步操作完成后调用 sendResponse,但 content script 却收不到响应。
原因:如果 onMessage 监听器没有返回 true,Chrome 扩展系统会认为消息处理是同步的,并在监听器函数执行完毕后立即关闭消息通道。这样,异步操作完成后再调用的 sendResponse 将无法发送数据。
解决:当你的消息处理器中包含异步操作,并且你需要在异步操作完成后调用 sendResponse 时,必须在监听器函数中返回 true。这会告诉 Chrome 扩展系统,消息通道需要保持打开,等待异步响应。
// background.js:确保异步响应能被正确接收chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {// 异步处理 AI 请求if (message.type === 'AI_REQUEST') { someAsyncOperation(message.text).then(result => { sendResponse(result); // 在异步操作完成后发送响应 }).catch(error => {console.error("Async operation failed:", error); sendResponse({ error: error.message }); });returntrue; // !!! 必须返回 true,表示 sendResponse 将被异步调用 !!! }// 其他同步消息处理,不需要返回 true// return false; // 或不返回任何值,默认即为 false});
坑 3:Storage API 数据不同步
现象:在 sidepanel.js 中修改了 chrome.storage.sync 中的设置后,background.js 立刻去读取,却发现读到的不是最新值。
原因:chrome.storage.sync.set 和 chrome.storage.sync.get 都是异步操作,它们不会立即使数据同步完成。在 set 操作返回后,数据可能还没有完全写入或同步。
解决:始终使用 await 或 .then() 来确保存储操作的完成,再进行读取。在不同上下文之间,可以通过监听 chrome.storage.onChanged 事件来实时同步数据变化,这是更健壮的方式。
// sidepanel.js:写入数据时等待完成asyncfunctionsaveSettings(key, value) {await chrome.storage.sync.set({ [key]: value });console.log(`Settings for ${key} saved.`);}// background.js:读取数据时等待完成asyncfunctiongetSetting(key) {const result = await chrome.storage.sync.get([key]);return result[key];}// 更健壮的数据同步方式:监听 storage 变化chrome.storage.onChanged.addListener((changes, namespace) => {if (namespace === 'sync' && changes.myKey) {console.log('My key has changed:', changes.myKey.newValue);// 在这里更新 background 中的内部状态 }});
七、给初学者的建议
学习路径
-
先理解 Manifest V3 架构:这是基石。搞清楚 background、content script、UI Pages 各自的角色、运行环境以及它们之间的通信方式,能让你对插件的整体运作有一个清晰的认识。 -
从简单功能开始:不要一开始就想做一个大而全的插件。可以先实现一个简单的功能,例如一个右键菜单,点击后在控制台打印信息,逐步深入到 content script 交互、sidepanel 页面等。 -
善用 Chrome DevTools: -
background Service Worker 调试:访问 chrome://extensions页面,找到你的插件卡片,点击「Service Worker」旁边的链接,即可打开独立的开发者工具窗口进行调试。 -
content script 调试:直接在注入了 content script的网页上打开开发者工具,然后在「Sources」面板中找到你的content script文件进行断点调试。
开发技巧
-
使用本地存储: chrome.storage.sync是一个非常有用的 API,它不仅能存储用户配置,还能在用户登录同一 Chrome 账号的不同设备间自动同步这些数据。对于不敏感的配置数据,它是绝佳的选择。 -
错误处理要充分:在进行 API 调用、消息通信以及任何可能失败的操作时,都要使用 try-catch进行错误捕获和处理。友好的错误提示能极大提升用户体验。 -
添加调试日志:在开发阶段,多加 console.log和console.error可以帮助你快速定位问题。但在发布前,记得清理或禁用这些日志,保持代码的整洁和性能。
推荐资源
-
Chrome 扩展官方文档:https://developer.chrome.com/docs/extensions/ -
Manifest V3 迁移指南:https://developer.chrome.com/docs/extensions/mv3/intro/
结语
浏览器插件开发是一个非常实用的技能,TextNova 这个项目让我深入理解了 Chrome 扩展的运作机制。整个过程最大的收获是:不要害怕报错,每个错误都是理解系统运作方式的机会。
如果你也想开发自己的浏览器插件,我的建议是:从解决自己的小需求开始,比如我最初只是想方便地翻译网页上的英文,一步步迭代就有了 TextNova。
夜雨聆风
