ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Chrome插件开发实战:最小可运行例子与页面JS调用全解析

2026/9/26 6:27:14 拓冰建站 浏览量
Chrome插件开发实战:最小可运行例子与页面JS调用全解析 简介资源定位明确适合刚开始接触Chrome扩展开发的前端工程师以及希望用自动化脚本减轻重复填表负担的Worktile用户示例以任务表单为对象演示完整插件流程。压缩包共22个文件代码量不大但分层清晰涵盖9个JavaScript逻辑文件、7个HTML交互页面、3个JSON配置并附必要CSS样式与PNG图标其中content-script与background脚本分别负责页面注入与后台任务popup和options页面管理用户交互整体包大小仅185KB。目前已有1885人学习下载。通过研究这些模块的组合方式可以掌握扩展核心manifest.json的权限声明、DOM操作与事件派发模拟输入、localStorage暂存数据以及MutationObserver监听页面变化等自动填表技术还能结合Worktile API调用与授权逻辑了解业务系统对接的安全注意事项。对于想快速搭建生产级表单填写插件的读者这是一份结构清晰、小巧且可运行的参考也适合教学演示与二次扩展。1. chrome浏览器插件例子为什么说这是浏览器自动化性价比最高的一条路很多人一提到 chrome浏览器插件第一反应是“那是大神做的工具”自己顶多装装广告拦截、下载视频、抓网页长图。实际上一个能改页面行为、能主动帮你干活的插件最小可以只由两个文件组成加起来不到 80 行代码。它比写脚本去模拟点击更稳定因为插件走的是浏览器官方给出的扩展 API不用你去跟页面里的反爬逻辑死磕它也比重写一套客户端更轻因为页面渲染、登录态、cookie 这些事浏览器已经替你管好了。对做网页内容提取、表单填写、内部运营工具、数据清洗前置处理的人来说插件就是“能长在浏览器里的机器人”。这篇笔记就围绕一个最小可运行的插件例子把从搭建骨架、加载调试到调用页面 JS 函数、打通双向通信的完整路径讲清楚最后把我在实际开发里踩过的坑和排查顺序一次性交代出来。2. 插件到底长什么样manifest.json、内容脚本与后台脚本的分工2.1 三个角色各管什么background、content script 与 popup先不急着写代码把插件的组成捋清楚。一个 chrome 插件在浏览器眼里其实是一个“带特殊权限的静态目录”目录里至少有一个清单文件 manifest.json用来告诉浏览器这个插件是谁、要什么权限、哪些文件在什么时机执行其余的文件则按职责分成三类后台脚本、内容脚本和界面文件。后台脚本MV3 里叫 service worker是插件的“大脑”常驻或按需唤醒负责监听浏览器级事件比如点击图标、收到消息、网络请求发生变化再决定让谁来干活。它不接触页面 DOM能用的 API 是完整的 chrome.* 扩展接口比如 chrome.storage、chrome.tabs、chrome.scripting。内容脚本则是插件的“手”它被注入到匹配的网页里能读取和修改 DOM也能监听页面事件但它活在页面隔离世界里默认碰不到页面自己的 JavaScript 变量和函数。popup 是点图标后弹出的那个小窗口负责给人看和点常用它来配置参数、展示采集进度。这三者为什么会这样拆根源是 Chrome 的安全模型页面与扩展的权限边界必须清晰页面里再乱的代码也不能直接调 chrome.storage 这种特权接口反之插件也不能默默读取页面的 JS 内部状态。理解了这一点后面遇到“脚本怎么不生效”“window 上挂的东西读不到”这类问题就不会完全靠蒙了。2.2 MV2 与 MV3 怎么选别再在新项目里写 background.js 全局常驻打开任何搜出来的插件教程你会发现有些例子给的是 manifest_version: 2有些给的是 3这俩的选择直接决定后面代码能不能跑。MV2 时代常见做法是在 manifest.json 里写 background: { scripts: [background.js] }浏览器会为插件专门开一个常驻后台页所有逻辑全局共享好写但内存占用和不可控性让 Google 决定放弃它。MV3 把后台脚本改成 service worker不再是常驻页面而是事件驱动、休眠后再唤醒。它有几个直接的后果不能用 window、document 这类页面对象不能随手在全局挂一堆变量休眠后状态全丢不能远程加载脚本所有代码都得打进本地包里。这些听起来是限制实际是帮你把插件逼到“用消息和存储来管理状态”这条更稳的路上。新写插件例子时直接用 MV3理由很简单Chrome 商店现在对新上架的 MV2 插件已经不再接受且未来版本会逐步禁用旧格式你在本地学的 MV2 写法很可能很快就变成没有意义的功夫。2.3 最小目录与加载入口chrome://extensions/ 上的开发者模式插件不需要构建工具不需要安装 node 依赖一个文件夹加两个文件就能被浏览器认出来。最短的目录结构是这样根目录下放 manifest.json同目录放 background.js如果你想动手改页面内容再加一个 content.js。整个目录会被浏览器视为一个未打包的扩展加载入口在地址栏输入 chrome://extensions/打开右上角的“开发者模式”开关点“加载已解压的扩展程序”选中刚才那个目录就装好了。加载成功后扩展卡片上会出现插件的名称、ID 和“已加载”状态如果 manifest.json 写错了浏览器会直接红字提示比如“Manifest file is missing or unreadable”“Permission tabs is unknown or URL pattern is malformed”。养成一个习惯每次修改 manifest.json 后去扩展卡片点那个刷新图标重载只改 JS 文件内容则大多时候重载页面或点图标就能立即生效不需要反复卸载重装。3. 从零跑通第一个插件例子清单字段与最小可用代码3.1 先写 manifest.jsonMV3 下必填与常用字段一览要跑通最小例子第一步是写清单文件它负责把你的目录声明成一个插件。MV3 里最基础的字段是 manifest_version、name、version这三个缺一不可。接着你会用到 background用来注后台 service worker用到 action用来声明点图标后的行为用到 content_scripts用来向匹配页面注入脚本用到 permissions用来申请 chrome.storage、chrome.scripting 这类接口权限。以下是一个可用作起步的完整 manifest.json 例子我把每个字段的实际作用写在注释里{ manifest_version: 3, name: page-helper-demo, version: 0.0.1, description: 一个用于演示的最小插件例子记录页面标题并响应页面端主动上报的消息, permissions: [ storage, scripting ], host_permissions: [ http://*/*, https://*/* ], background: { service_worker: background.js }, action: { default_title: page-helper-demo, default_popup: popup.html }, content_scripts: [ { matches: [http://*/*, https://*/*], js: [content.js], run_at: document_idle } ] }这段配置里值得拎出来解释一下permissions 里的 scripting 是为了让你能在需要时用 chrome.scripting.executeScript 向特定标签页注入脚本如果只靠 content_scripts 静态注入可以不加它host_permissions 声明插件在哪些域上有“读取和注入”资格这里用通配符覆盖全部 http/https 页面也会导致安装时出现“读取和更改所有网站上的数据”的权限警告如果你只针对内网工具用推荐收紧成 “https://your-internal-site.com/*”action.default_popup 一旦存在点击插件图标就会弹这个 popup不写的话则只触发 onclicked 事件。run_at 用 document_idle意思是页面 DOM 解析完、资源加载差不多之后才注入大多数例子场景下这样最不容易遇到“脚本运行太早节点还没出来”的尴尬。3.2 后台脚本service worker 如何接收消息并写入存储接下来写后台脚本它是后台事件的中转站。在这个最小例子中我让它做两件事监听来自 content script 的消息把消息内容写入 chrome.storage.local以及当扩展图标被点击时读取当前页面标题并记一条日志。下面这个 background.js 可以直接放进目录里用// 监听来自 content script 或其他扩展页面的消息 chrome.runtime.onMessage.addListener((message, sender, sendResponse) { if (message.type page_title) { const record { title: message.title, url: sender.url, time: Date.now() }; // 用 chrome.storage.local 保存service worker 休眠后数据仍然在 chrome.storage.local.set({ lastRecord: record }, () { console.log(已保存页面标题记录, record); sendResponse({ ok: true }); }); } // 必须返回 true表示会异步调用 sendResponse return true; }); // 点击插件图标时如果没有 popup就走这里 chrome.action.onClicked.addListener(async (tab) { console.log(图标被点击当前页面是, tab.title, tab.url); });这段脚本的关键点就一句话chrome.runtime.onMessage.addListener 是扩展侧的消息入口sender 参数里自带 url、tab 等来源信息不用再额外传。我在监听器里返回 true是因为 chrome.storage.local.set 是异步操作不返回 truesendResponse 就会被当成不需要内容脚本那边会收到 undefined。另一个易错点是 MV3 service worker 的休眠机制如果你只靠全局变量临时保存数据worker 一休眠就全没了所以凡是需要跨会话保存的状态一律用 chrome.storage。3.3 内容脚本注入页面后怎么留下可观察的痕迹最后一个文件是 content.js它在匹配到的网页里执行。这个最小例子只做一件事等页面加载后把当前页面的标题和 URL 通过 chrome.runtime.sendMessage 发给后台。从效果上看你会打开任意一个网页然后能在扩展后台的 Service Worker 控制台里看到一条“已保存页面标题记录”的日志同时从 chrome://extensions/ 里打开“查看视图”下的 service worker 控制台能看到输出。// 通过 runtime.sendMessage 把页面信息发给后台脚本 function reportPageInfo() { const info { type: page_title, title: document.title, url: location.href }; chrome.runtime.sendMessage(info, (response) { if (chrome.runtime.lastError) { // 后台脚本报错或未就绪时会走到这里 console.warn(消息发送失败, chrome.runtime.lastError.message); return; } console.log(后台已确认收到, response); }); } // 等待页面进入空闲状态后再执行一次避免在 DOM 未就绪时误报 if (document.readyState complete) { reportPageInfo(); } else { window.addEventListener(load, reportPageInfo, { once: true }); }这里要注意一个细节chrome.runtime.sendMessage 的回调里如果消息发送失败比如接收端报错、service worker 启动失败错误会挂在 chrome.runtime.lastError 上不读取它浏览器会在控制台打出 Unchecked runtime.lastError 的警告而且不会告诉你具体内容。所以在任何消息发送路径里都养成读取 lastError 的习惯能省掉大量玄学排查时间。3.4 验证加载三步走装、开控制台、看日志写完这三个文件就可以做一次完整验证。第一步在 chrome://extensions/ 里开启开发者模式加载整个目录第二步在扩展卡片上点击带下划线的“service worker”字样打开后台控制台如果看不到这个入口就先把卡片折叠起来找细节图标里的“查看视图”第三步新开一个普通网页比如 example.com等页面加载完切回 service worker 控制台应该能看到来自 content script 上报的日志。整个过程没有构建与报错的话第一个能跑的插件例子就算正式跑通了。这套最小例子虽然看起来没有生产力但它是后面所有能力的底盘你能收到页面标题就意味着你能收到页面里任何数据你能给后台发消息就意味着你能把页面数据迁移到本地存储、上报到内部接口或者触发一次下载。4. 浏览器插件如何调用页面JS函数隔离世界下的通信桥接方案4.1 隔离世界到底隔离了什么为什么 window.greet 读不到很多人照着教程写完 content script 后会顺手在控制台里执行一个页面自身的全局函数比如 window.getToken()、window.APP_CONFIG然后发现明明页面控制台能调content script 里就是显示 undefined。这不代表函数不存在而是因为 content script 跑在页面的隔离世界里它看到的 window 是扩展侧的副本页面主世界里的 JavaScript 变量、函数、全局对象通通不共享。常见的翻车现场是这样的用户想在插件里调用页面遗留在 window 上的内部接口于是直接写 window.someFn()结果得到 “someFn is not a function”换个思路又尝试给 window.someFn 赋值结果页面业务代码根本感知不到这次赋值。原因就在于主世界和隔离世界各有一套 window只是 DOM 共享JS 执行环境不共享。4.2 破局思路往页面主世界注入 script 标签建立双向桥解决这个问题的标准做法是“往页面的主世界再注入一段真正的页面级脚本”让它运行在页面上下文里可以去读取或调用页面自己的全局函数再把结果通过 window.postMessage 回传给 content script。整个过程形成一个桥content script 通过消息向页面脚本下指令页面脚本执行完拿到的结果后用 postMessage 通知 content script。具体实现是动态创建 script 标签设置 textContent 为要执行的代码再把标签挂到 document.documentElement 上执行完顺便移除。这段代码本身是运行在主世界的所以它对 window 上的函数有完全访问权。在实际的例子中这叫“注入脚本”等于你在页面自己的领地里安插了一个内应。4.3 完整代码content script 向页面主世界下指令并回收结果下面这个例子模拟的是“调用页面自身的全局函数 window.getPageData() 并把返回值拿回 content script”。它由三块组成content script 负责调度注入的页面脚本负责真正调用再用 postMessage 传回数据。// content.js 部分注入主世界脚本并监听回传消息 function injectMainWorldScript(fn) { const script document.createElement(script); script.textContent (${fn})();; // 挂在 html 根节点下保证执行时机足够晚 (document.documentElement || document.head).appendChild(script); script.remove(); // 执行完成后立即移除不在页面上留痕迹 } // 这是真正运行在页面主世界里的函数 function callPageFunctionAndReport() { try { const data window.getPageData ? window.getPageData() : null; window.postMessage({ source: my-extension-content, type: page-data-response, payload: data }, *); } catch (err) { window.postMessage({ source: my-extension-content, type: page-data-response, error: String(err) }, *); } } // 发起一次调用 injectMainWorldScript(callPageFunctionAndReport); // 监听页面主世界发回的 postMessage window.addEventListener(message, (event) { const msg event.data; // 必须校验来源避免被页面自身或其他脚本误触 if (!msg || msg.source ! my-extension-content) return; if (msg.type page-data-response) { console.log(拿到页面函数返回值, msg.payload, msg.error); } });这段代码里值得重点说明的两个地方一是 postMessage 的 targetOrigin 我用了宽松的 ‘*’因为消息来源是页面自身页面里的其他脚本也可能收到并读取它。如果你传的是敏感数据且只在确定域名的内部工具里跑建议把它改为当前页面地址的 origin方法是传入 location.origin这样能避免内网环境里跨站数据泄漏二是 window.postMessage 的事件监听里必须校验 event.data 里的来源标识因为页面里可能有其他脚本也往 window 上发消息不校验就会收到一堆无关消息甚至可能被页面恶意伪造数据。4.4 参数怎么传从固定函数到可传值的调用模板上面的例子只演示了调用无参函数实际项目里你经常需要把参数传进页面函数比如调用 window.searchByKeyword(keyword) 或者 window.renderChart(optionObj)。传参思路很简单注入的代码里把参数值按 JSON 序列化后拼进函数体。function callPageFunction(name, ...args) { const argsStr args.map(arg JSON.stringify(arg)).join(,); const code (function() { const result window.${name}(${argsStr}); window.postMessage({ source: my-extension-content, type: page-function-result, name: ${name}, payload: result }, *); })(); ; const script document.createElement(script); script.textContent code; (document.documentElement || document.head).appendChild(script); script.remove(); }这里有一个必须要讲的坑直接把参数拼接进代码会有注入风险。JSON.stringify 能正确处理字符串、数字、数组和普通对象但如果参数里有函数、undefined、循环引用stringify 会返回 undefined拼进去的字符串就变成错误代码。所以设计接口时尽量只传可序列化的数据别图方便把整个对象原样塞进去。另一个细节是模板字符串里我用${name}直接拼接函数名如果你的页面函数名是动态的务必要先做一层白名单校验比如只允许 /^[a-zA-Z_$][\w$]*$/否则等于把任意代码执行权限交给了外部输入。4.5 为什么这套方案比 chrome.scripting.executeScript 更稳可能有人会问chrome.scripting.executeScript 也能往页面注入代码为什么不直接用它能注入但默认注入的代码同样运行在隔离世界访问不到页面 window 上的业务函数虽然可以通过指定 world: MAIN 参数来直接进入主世界但前提是你的浏览器版本支持chrome.scriptingAPI 且 manifest 里声明好了相应权限。如果你要兼容内部大量旧版本浏览器或者不想为一个几十行的功能去申请额外权限经典的 script 标签注入方案在任何版本上都能用且行为稳定可控更适合插件例子的教学和复用。5. 插件开发避坑清单这 5 条都是血泪经验5.1 白屏问题加载插件后什么都没发生console 也没报错现象插件按步骤加载成功刷新页面后 content script 看起来没执行service worker 控制台没有任何输出。原因最常见的有两种。一是 content_scripts 里的 matches 写成了http://*/*而你在本地调试用的是file:///或 chrome:// 开头的页面这两类页面默认不允许注入matches 也不会匹配二是 service worker 里有未被捕获的顶层错误导致它在加载阶段就退出后续消息全部失效。解决先在地址栏打开一个普通 http 站点再测试排除了页面协议问题后把 service worker 入口临时改成一个只输出 console.log 的空脚本确认它能否正常打印。如果空脚本也不输出去 chrome://extensions/ 里点刷新然后看扩展卡片上是否出现“Service worker registration failed”或类似提示如果是代码报错控制台会直接打出红色堆栈。5.2 消息发过去后台没反应service worker 休眠带来的误判现象content script 里调用 chrome.runtime.sendMessage控制台不报错但后台监听器没执行。你疯狂刷新页面结果时好时坏。原因MV3 的 service worker 是事件驱动唤醒的按道理发消息会唤醒它失败往往发生在“监听器注册时机”这里。比如你尝试把 chrome.runtime.onMessage 的监听逻辑写在一个被异步调用的函数里或者实现了动态 import这些情况下监听器还没来得及注册消息就先到了自然没有接收者。解决把 chrome.runtime.onMessage.addListener 写在 service worker 文件的顶层作用域保证它在脚本首次执行时就完成注册不要包在 promise、setTimeout、或任何条件判断里。调试时如果想确认 worker 确实被唤醒过在每个监听器顶部加一行 console.log([worker wake], Date.now())。5.3 postMessage 回传消息被重复触发每次都收到两条相同数据现象content script 监听 window message 事件页面脚本只 post 了一次content script 却收到两次偶尔更多。原因事件监听器被重复注册。典型场景是内容脚本被注入了多次或者你的 content_scripts 配置里同时在 matches 和 js 数组里放了两次同一个文件或者扩展在重载时旧 worker 的消息监听残留与新的叠加。此外页面上可能还有你自己开发的其他脚本在监听同类型消息来源标识又不严格也会把消息误认成给自己的。解决在 content script 的 message listener 里增加一个过滤条件检查 event.data.source my-extension-content这一步做了还是重复就去 chrome://extensions/ 里确认 content.js 是否只被声明一次再不行在 content script 顶部清空一次监听再注册不过要谨慎使用因为这会导致多个页面实例同时存在时互相把对方的监听器清掉这属于万不得已的补救。5.4 调用页面函数报错页面函数依赖局部变量直接以全局调用触发 ReferenceError现象你用注入脚本调 window.getData()控制台报 “ReferenceError: xxx is not defined”但你在页面自己的控制台里调用同一个函数是成功的。原因页面上的函数虽然挂在 window 上但它内部引用了模块作用域里的变量比如const config {...}这本身没问题问题出在注入的脚本被包裹成 IIFE 执行函数体里的 this 指向和变量作用域可能和页面原生调用不一样。更常见的是你要调用的函数根本不是 window 的全局函数而是页面模块内部的函数通过某个按钮的 onclick 事件绑定的你自然访问不到。解决先确认要调用的对象确实暴露在 window 上没有暴露就先考虑用 DOM 事件触发——比如找到那把按钮执行document.querySelector(#searchBtn).click()让页面自己走它自己的流程再监听页面 event 或 DOM 变化来回收结果。这种“不直接调函数、而是触发页面原生交互”的思路是很多采集类插件最稳的路线。5.5 “该扩展程序未列在 Chrome 应用商店中”的横幅与本地解压插件的区别现象自己在本地加载的开发插件每次启动浏览器时地址栏周围会弹出黄色/红色提示写着“该扩展程序未列在 Chrome 应用商店中并可能是在您不知情的情况下添加的”。原因Chrome 对未上架插件默认不信任这条提示就是告诉你这是 side-load 加载的非商店扩展。对它不必恐慌不代表插件坏了或中招了只是信任策略的信号。直接取消这个横幅目前没什么标准办法即便你从应用商店装来的扩展如果开发者后来改了分发方式也可能出现类似提示。解决本地开发阶段无视它如果你是给整个团队分发内网插件最理想的解法是把插件打包成 .crx 上传到企业内部的扩展更新源让 Chrome 策略强制安装。再补充一句这个横幅最容易吓到第一次写插件的人误以为系统被入侵其实只要你能确认这个目录是自己的代码就安全顺手做一次备份把 source 目录整个复制一份留着万一被浏览器误判清掉也能秒恢复。6. 进阶调试技巧把插件例子从“跑通”升级成“可维护的工具”当你已经能用最小例子完成注入、消息传递和后台存储之后接下来的核心问题就从“能不能跑”变成“出了问题我怎么查”。一个很容易被忽视的点是chrome 插件的 console 分布在多个地方普通页面控制台看不到 content script 的输出service worker 控制台又看不到 popup 里的调试日志必须养成分区看日志的习惯。我在自己维护的一个内部采集插件里会把所有消息都印成统一前缀[CS]开头的是内容脚本日志[SW]开头的是后台脚本日志[POP]开头的是弹窗日志。这样一打开对应控制台一眼就能看出消息链路卡在哪个环节比瞎翻调用栈高效得多。既然通信链路是最容易断的验证时就用“链路回环法”来测content script 发一条消息后台收到后立刻原样回传content script 在回调里对比响应内容是否一致一致就打印一条 OK 日志。这个测试脚本可以固化成一个debug.js文件开发时引入上线前移除成本极低收益很高。再进阶一点可以用 chrome.storage.onChanged 来实时观察状态变化后台任何一次存储写入都会触发监听器在 content script 里把这个变化打出来就能在不打断操作的前提下看到后台到底存了什么东西。我个人的习惯是始终保留一份“最小仓库”一个目录里只有 manifest.json、background.js、content.js全部是极致精简的可运行代码专门用来验证新想法和复现 bug。每当主项目出现奇怪现象我第一时间不是去大项目里翻而是把这个最小例子跑一遍看问题还在不在。如果不在那就是主项目里的边界行为如果还在多半是 chrome 版本或权限配置等基础设施问题。这个习惯帮我挡掉了大量被环境干扰的排查时间。插件开发归根结底是“边界越来越多、功能越来越小”的活控制好最小可运行体才能控制复杂度。希望这些例子和避坑记录对你有用。本文还有配套的精品资源点击获取