简介:这是一份面向Chrome扩展开发初学者与前端工程师的实战示例包,围绕浏览器插件自动填写Worktile任务描述表单这一场景展开,帮助读者理解插件架构、内容脚本注入、DOM操作与用户授权等核心环节。压缩包共22个文件,约185KB,包含9个JavaScript脚本、7个HTML页面、3个JSON配置、2个PNG图标及1个CSS样式文件,分别承担逻辑处理、界面展示、清单配置与视觉资源等职责,结构紧凑便于对照阅读。目前已有1886人学习下载。资源覆盖manifest配置、背景脚本与内容脚本协作、localStorage数据存取、dispatchEvent模拟输入、MutationObserver监听表单变化以及与Worktile的API集成思路,并涉及开发者工具调试、权限最小化与隐私保护等实践要点,适合作为插件入门与自动化表单填充的参考样例。
1. 从零写一个 Chrome 浏览器插件:为什么“能跑起来”比“看懂文档”更重要
很多人第一次接触 Chrome 浏览器插件,都是被一个很具体的需求逼出来的:想批量抓页面数据、想给某个站点加个悬浮按钮、想改掉某个页面的样式,或者单纯想搞清楚chrome://extensions/里那些开关到底怎么来的。可真打开官方文档,Manifest V3、Service Worker、content script、消息通信这一套名词砸下来,新手很容易卡在“每个字都认识,连起来不知道从哪下手”。我的经验是:别先啃文档,先让一个最小插件在浏览器里跑起来,看到图标、点到按钮、拿到结果,再回头补概念,效率高得多。
这篇笔记就按这个思路走。我会用一个能实际用起来的例子——页面元素高亮 + 一键统计——把 Chrome 浏览器插件从目录结构、manifest 配置、脚本注入、消息通信到调试打包整条链路讲透。适合两类人:完全没写过插件、想照着抄一个能跑的新手;以及写过一两个 demo、但一遇到 Service Worker 生命周期和权限报错就翻车的熟手。全程只讲能复现的步骤和参数,不堆概念。
2. 插件的最小骨架:manifest、目录和三个角色怎么分工
2.1 先搞清楚一个插件里到底有几个“运行环境”
Chrome 浏览器插件最反直觉的一点,是它不是一个程序,而是几个运行在不同环境里、靠消息互相喊话的脚本集合。新手最容易翻车的地方,就是把该写在 content script 里的代码写进了 popup,然后发现拿不到页面 DOM。所以动手前先把三个核心角色分清楚:
- popup(弹窗页):点插件图标弹出来的那个小页面,本质是一个普通 HTML 页面,有自己的 DOM,但拿不到当前标签页的 DOM。它适合放按钮、开关、展示结果。
- content script(内容脚本):被注入到目标网页里运行的脚本,能读写页面 DOM,但默认不能调用大部分 chrome API,也不能跨域请求。
- service worker(后台脚本):Manifest V3 里替代旧 background page 的角色,负责处理事件、调 chrome API、做跨域请求。它没有 DOM,而且会被浏览器随时休眠。
这三者之间靠chrome.runtime.sendMessage和chrome.tabs.sendMessage通信。理解这一点,后面所有报错你都能定位到“是哪个环境里写错了”。
2.2 一个能跑的最小目录结构
先建目录,结构如下。名字随意,但manifest.json必须在根目录:
chrome-plugin-demo/ ├── manifest.json # 插件配置,入口 ├── popup.html # 弹窗页面结构 ├── popup.js # 弹窗逻辑 ├── content.js # 注入页面的脚本 ├── background.js # service worker └── icons/ ├── icon16.png ├── icon48.png └── icon128.png图标没有也能跑,但action.default_icon不填会显示默认灰色拼图,建议至少放一个 128 的。图标尺寸对应关系:16 用于扩展页列表,48 用于扩展管理页,128 用于应用商店和安装弹窗。
2.3 manifest.json 的每个字段都要能说出为什么
Manifest V3 的配置是整个插件的合同,写错一个字段就是“无法加载扩展程序”。下面这份是能直接用的最小可用版本:
{ "manifest_version": 3, "name": "页面高亮统计 Demo", "version": "1.0.0", "description": "高亮页面关键词并统计数量", "permissions": ["activeTab", "scripting"], "action": { "default_popup": "popup.html", "default_icon": { "16": "icons/icon16.png", "48": "icons/icon48.png", "128": "icons/icon128.png" } }, "background": { "service_worker": "background.js" }, "content_scripts": [ { "matches": ["<all_urls>"], "js": ["content.js"], "run_at": "document_idle" } ] }逐字段说明,这几个是新手最常问的:
manifest_version:必须写 3。写 2 的话 Chrome 109 之后的新版本会直接拒绝加载,这也是为什么热搜里“chrome 109”总跟插件话题绑在一起——那是 MV2 向 MV3 切换的关键节点。permissions:activeTab让你在用户点击插件时临时获得当前标签页权限,比申请<all_urls>主机权限更克制,审核和用户都更友好;scripting是 MV3 里动态注入脚本必须的权限。content_scripts.matches:<all_urls>表示所有页面注入。实际项目里强烈建议收窄,比如只写https://*.example.com/*,否则用户访问任何页面你都注入,既慢又容易被怀疑。run_at:document_idle表示 DOM 基本就绪后注入,最稳。想更早拿到 DOM 可以改document_start,但那时document.body可能还不存在,容易报 null。
提示:改完
manifest.json必须回chrome://extensions/点一次刷新按钮,插件不会热更新配置。这是新手第一个高频翻车点。
3. 让插件真正干活:注入、通信、操作 DOM 的完整链路
3.1 content script 怎么写才能稳定拿到页面元素
content script 的核心任务是操作页面 DOM。下面这段实现“高亮关键词并返回数量”,注意它不直接返回结果,而是把结果通过消息发回去:
// content.js // 监听来自 popup 或 background 的消息 chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { if (request.action === 'highlight') { const keyword = request.keyword; if (!keyword) { sendResponse({ count: 0, msg: '关键词为空' }); return true; // 保持消息通道开放 } // 先清除上一次的高亮,避免重复叠加 document.querySelectorAll('mark.__demo_hl').forEach((el) => { const parent = el.parentNode; parent.replaceChild(document.createTextNode(el.textContent), el); parent.normalize(); }); let count = 0; const walker = document.createTreeWalker( document.body, NodeFilter.SHOW_TEXT, { acceptNode(node) { // 跳过 script/style 和已经高亮的节点 const tag = node.parentNode.nodeName; if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'MARK') { return NodeFilter.FILTER_REJECT; } return node.nodeValue.includes(keyword) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; } } ); const targets = []; while (walker.nextNode()) targets.push(walker.currentNode); targets.forEach((node) => { const parts = node.nodeValue.split(keyword); const frag = document.createDocumentFragment(); parts.forEach((part, i) => { if (i > 0) { const mark = document.createElement('mark'); mark.className = '__demo_hl'; mark.style.backgroundColor = '#ffe066'; mark.textContent = keyword; frag.appendChild(mark); count++; } if (part) frag.appendChild(document.createTextNode(part)); }); node.parentNode.replaceChild(frag, node); }); sendResponse({ count, msg: `高亮 ${count} 处` }); } return true; // 异步 sendResponse 必须返回 true });逻辑说明:用TreeWalker遍历文本节点而不是innerHTML替换,是为了不破坏页面原有的事件绑定和结构——直接改innerHTML会让页面上已绑定的监听器全部失效,这是血泪经验。return true是关键,它告诉 Chrome 这个监听器会异步调用sendResponse,不写的话 popup 那边永远收不到回复,表现为“点了没反应”。
参数说明:keyword由 popup 传入;__demo_hl这个类名加前缀是为了避免和页面自身样式冲突;高亮色#ffe066可以按需改。
3.2 popup 和 content script 之间怎么把消息传对
popup 负责收集用户输入、发消息、展示结果。它不能直接操作页面,必须通过chrome.tabs.sendMessage找到当前标签页的 content script:
// popup.js document.getElementById('btn').addEventListener('click', async () => { const keyword = document.getElementById('kw').value.trim(); if (!keyword) { document.getElementById('result').textContent = '请输入关键词'; return; } // 拿到当前激活的标签页 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); if (!tab || !tab.id) { document.getElementById('result').textContent = '未找到标签页'; return; } try { const res = await chrome.tabs.sendMessage(tab.id, { action: 'highlight', keyword }); document.getElementById('result').textContent = res?.msg || '无响应'; } catch (err) { // content script 未注入时会走到这里 document.getElementById('result').textContent = '当前页面无法注入脚本,请刷新页面后重试'; console.error('sendMessage 失败:', err); } });逻辑说明:chrome.tabs.query({active:true, currentWindow:true})拿到用户当前看的标签页,sendMessage把消息发给这个标签页里的 content script。用try/catch包住是必须的——如果当前页面是chrome://开头的内置页、扩展商店页,或者 content script 还没注入,sendMessage会直接抛错,不捕获的话 popup 控制台一片红。
参数说明:active:true表示当前激活标签,currentWindow:true表示当前窗口。如果你想让插件作用于所有窗口的激活页,去掉currentWindow即可,但一般不需要。
对应的popup.html保持极简:
<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <style> body { width: 260px; padding: 12px; font-family: system-ui; } input { width: 100%; padding: 6px; box-sizing: border-box; } button { margin-top: 8px; width: 100%; padding: 6px; cursor: pointer; } #result { margin-top: 8px; font-size: 13px; color: #333; } </style> </head> <body> <input id="kw" placeholder="输入要高亮的关键词" /> <button id="btn">高亮并统计</button> <div id="result"></div> <script src="popup.js"></script> </body> </html>3.3 background.js 在 MV3 里到底还要不要写
很多教程还在教 MV2 的background.scripts,放到 MV3 直接报错。MV3 里后台是 service worker,没有持久生命周期,浏览器觉得没事干就把它休眠,所以不要在里面存全局状态。一个常见用途是处理安装事件和右键菜单:
// background.js chrome.runtime.onInstalled.addListener(() => { console.log('插件已安装/更新'); }); // 注册右键菜单,选中文字后可直接高亮 chrome.runtime.onInstalled.addListener(() => { chrome.contextMenus.create({ id: 'highlight-selection', title: '高亮选中文字', contexts: ['selection'] }); }); chrome.contextMenus.onClicked.addListener((info, tab) => { if (info.menuItemId === 'highlight-selection' && tab?.id) { chrome.tabs.sendMessage(tab.id, { action: 'highlight', keyword: info.selectionText }); } });逻辑说明:onInstalled在安装和更新时各触发一次,适合做初始化。右键菜单必须在permissions里加contextMenus,否则create会静默失败——注意是静默失败,不报错,只是菜单不出现,这个坑很隐蔽。
参数说明:contexts: ['selection']表示只在选中文字时显示菜单项;info.selectionText就是用户选中的文本。
4. 调试与排错:插件不生效时按这个顺序查
4.1 三个控制台分别看什么
Chrome 浏览器插件调试最反直觉的是:不同环境的日志在完全不同的控制台里,看错地方就会以为代码没执行。
| 出问题的部分 | 日志在哪看 | 打开方式 |
|---|---|---|
| popup | 弹窗右键 → 检查 | 点插件图标后右键弹窗 |
| content script | 目标页面的控制台 | F12 → Console,日志和页面脚本混在一起 |
| service worker | 扩展管理页 | chrome://extensions/→ 该插件 → 点击“Service Worker” |
| manifest 加载错误 | 扩展管理页顶部 | 加载时直接弹红条 |
新手最常见的误判是:在 popup 里console.log然后去页面控制台找,当然找不到。记住 popup 是独立页面,日志只在它自己的控制台。
4.2 避坑清单:五个我真实踩过的坑
现象一:插件图标是灰的,点了没反应。原因:manifest.json里action字段拼错,或者图标路径写错导致加载失败。 解决:去chrome://extensions/看有没有红色“错误”按钮,点开看具体报错行。路径大小写敏感,Icons/和icons/在部分系统上不通用。
现象二:popup 里sendMessage报 “Could not establish connection”。原因:当前标签页是chrome://内置页、扩展商店页,或者 content script 没注入(比如页面在插件安装前就打开了)。 解决:先刷新目标页面;代码里用try/catch兜底提示用户。这是最高频的报错,没有之一。
现象三:content script 里document.body是 null。原因:run_at设成了document_start,此时 DOM 还没构建。 解决:改回document_idle,或者在脚本里用DOMContentLoaded事件包一层。
现象四:改了代码但页面行为没变。原因:content script 是注入到页面里的,页面不刷新,旧脚本还在跑。 解决:改完 content script 后,回扩展页点刷新,再刷新目标页面。两个刷新缺一不可。
现象五:service worker 里的变量过一会儿就没了。原因:MV3 的 service worker 会被浏览器休眠,全局变量不持久。 解决:需要持久化的数据用chrome.storage.local,不要用全局变量。这是 MV3 和 MV2 最大的思维差异。
注意:如果你在
chrome://extensions/打开了“开发者模式”还是加载失败,优先检查 JSON 语法——多一个逗号、少一个引号都会导致整个 manifest 解析失败,而且报错信息往往不指向真正的位置。
5. 从 demo 到能用的插件:几个让代码更稳的进阶技巧
5.1 用 chrome.storage 替代全局变量做状态管理
前面说过 service worker 会休眠,所以任何需要跨会话保留的状态——比如用户上次输入的关键词、开关状态——都得落到chrome.storage。它和 localStorage 的区别是:storage 是异步的、能跨 popup/content/background 共享,localStorage 只在单个页面环境里有效。
// 保存 await chrome.storage.local.set({ lastKeyword: keyword }); // 读取 const { lastKeyword } = await chrome.storage.local.get('lastKeyword');chrome.storage.local默认容量约 10MB(unlimitedStorage权限可放开),对绝大多数插件够用。注意它是异步 API,别写成同步取值,否则拿到的是 undefined。
5.2 动态注入:不写死 content_scripts 也能操作页面
有时候你不想在 manifest 里声明content_scripts,而是用户点击时才注入。这在 MV3 里靠chrome.scripting实现,前提是 manifest 里声明了scripting和activeTab:
// 在 background 或 popup 中调用 await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ['content.js'] });这种方式的好处是按需注入,不污染用户所有页面;代价是每次都要手动触发。常见做法是:默认不注入,用户点插件图标时再executeScript,然后发消息。注意executeScript对chrome://页面同样会失败,逻辑上要兜底。
5.3 打包发布前必须做的三件事
第一,把matches从<all_urls>收窄到实际需要的域名,这既是性能问题也是审核问题。第二,删掉所有console.log,尤其是 content script 里的——它们会打到用户页面的控制台,很不专业。第三,版本号在manifest.json里递增,Chrome 靠version判断是否更新,不改版本号重新上传会被拒。
打包命令很简单,把整个目录压成 zip 即可,注意manifest.json必须在压缩包根目录,不能多套一层文件夹:
cd chrome-plugin-demo zip -r ../chrome-plugin-demo.zip . -x "*.DS_Store"上传到开发者后台后,审核通常关注权限是否最小化、描述是否和功能一致。我自己的习惯是:每加一个权限,都问自己“不加这个功能还能不能实现”,能就不用加。这个习惯帮我避开了好几次审核打回。
写插件这几年,我最大的教训是:别在没跑通最小 demo 之前就去设计复杂架构。我见过太多人一上来就想做完整功能,结果卡在 manifest 报错上三天,热情直接耗光。正确顺序永远是——先让一个按钮能弹出来,再让它能发消息,再让它能改页面,最后才是加功能。希望帮到你。
本文还有配套的精品资源,点击获取