资讯动态

Chrome浏览器插件开发实战:从最小Demo到页面高亮统计

发布时间:2026/10/6 10:04:09 来源:尧图企业网站定制
简介这是一份面向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 切换的关键节点。permissionsactiveTab让你在用户点击插件时临时获得当前标签页权限比申请all_urls主机权限更克制审核和用户都更友好scripting是 MV3 里动态注入脚本必须的权限。content_scripts.matchesall_urls表示所有页面注入。实际项目里强烈建议收窄比如只写https://*.example.com/*否则用户访问任何页面你都注入既慢又容易被怀疑。run_atdocument_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 charsetutf-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 idkw placeholder输入要高亮的关键词 / button idbtn高亮并统计/button div idresult/div script srcpopup.js/script /body /html3.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默认容量约 10MBunlimitedStorage权限可放开对绝大多数插件够用。注意它是异步 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 报错上三天热情直接耗光。正确顺序永远是——先让一个按钮能弹出来再让它能发消息再让它能改页面最后才是加功能。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑