尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

浏览器插件开发实战:弄清manifest与content script,快速做出网页提取工具

浏览器插件开发实战:弄清manifest与content script,快速做出网页提取工具 上周有个做前端的朋友问我写浏览器插件是不是得学一整门新语言我看别人做的下载助手、划词翻译、网页内容提取工具每一个都挺唬人。我反问他你平时写的 defer 脚本和插件里的 content script 在页面上做的事差别真的很大吗他想了想确实不大——本质上都是在某个页面环境里跑 JavaScript。浏览器插件开发之所以被传得复杂核心是被“扩展包到底怎么组织”吓住了。一旦把 manifest 清单文件、后台脚本、内容脚本这三块拼图的位置摆对它就是你已经熟悉的前端技能套上了一层新壳。这篇文章我不打算讲一堆理论而是从上手角度完整跑一遍流程插件由哪些文件组成、manifest 里每个字段到底有什么含义、内容脚本为什么调不到页面自己的 JS 函数、如何绕过隔离拿到页面内存里的 token最后再亲手写一个能直接用的网页内容提取插件。无论你是外包接单、给公司做内部效率工具还是想在简历里放一个完整的插件项目这条链路走完就够用了。1. 插件到底由什么组成三个脚本一个清单先搞清楚谁在干活我接触过不少前端同学第一步就死在“不知道代码该往哪个文件里塞”。Chrome 扩展发展到现在虽然文件可以按项目随意组织但真正承担逻辑的角色固定就四个manifest.json清单文件、后台 Service Worker、内容脚本 content script、弹出页 popup。它们之间的关系很像一个微型前端项目manifest 是 package.json 路由配置后台脚本是常驻的服务端逻辑content script 是嵌在别人页面里的一块 iframe 式逻辑popup 则是点开图标后看到的操作面板。这几块通过消息机制互相喊话但对页面数据的操控能力完全不同理解这个边界是后续所有开发的地基。1.1 后台脚本Service Worker插件的“后端”MV3 里后台脚本被称作 Service Worker它最像你项目的 Node 层没有界面、不能直接操作页面 DOM但可以处理大部分 chrome API比如管理右键菜单、监听浏览器事件、发通知、处理跨 tab 的消息。它最大的特点是不常驻长时间没有事件触发时浏览器会把它休眠下次事件来了再唤醒。这个设计省内存但新手经常踩坑——在全局声明一个变量睡几分钟后回来发现变成了 undefined这就是因为它被回收了。所以后台脚本里尽量别存需要长期依赖的运行时状态真要存就落到chrome.storage或 IndexedDB。它更适合做“事件转发”和“逻辑调度”比如收到 content script 传来的消息后去做数据处理或者给 popup 转达状态。1.2 内容脚本Content Script插件的“手和眼睛”content script 是注入到目标网页里执行的 JS它可以直接读写 DOM、监听页面事件也是绝大多数“提取网页内容”“自动填充表单”功能的主力。很多前端第一次写插件时觉得爽就是因为 content script 的写法跟你平时写的页面交互代码几乎一模一样——document.querySelector、addEventListener、window这些全都认识。但它有一个非常关键的隔离机制content script 运行在所谓的 isolated world隔离世界。简单说它和页面的真实 JS 共享 DOM却共享不了页面里的全局变量和函数。页面里window.token abc你在 content script 里访问window.token会得到 undefined。这一点我会在第三节专门展开讲因为“插件能读 DOM 为什么读不到页面的 JS 变量”是新手最容易卡住的问题。1.3 弹出页Popup插件的“操作面板”popup 就是点击工具栏图标后弹出的那个小窗口本质是一个普通的 HTML 页面可以用任意前端技术栈来写。它的生命周期非常短——点击外面任意位置就关闭所以不要指望把数据长期放里面。它最合理的用途是给用户一个界面点击按钮后向 content script 发指令再把结果渲染展示。如果你需要更复杂的长页面设置应该用 options 选项页它就像插件的“后台管理系统”可以提供完整的配置能力。MV3 里 popup 和 options 页能调的 API 基本一致唯一差别就是生命周期。三种角色能力边界整理一下角色操作当前页 DOM读取页面 JS 变量调用 chrome API生命周期后台 Service Worker否否大部分可用事件驱动会休眠内容脚本 content script是受限隔离世界少量可用随标签页存在弹出页 popup只能操作自身 DOM否大部分可用打开即存在点击关闭即销毁在实际项目中90% 的插件逻辑跑在 content script 里popup 负责交互后台脚本负责监听浏览器级事件和转发消息。搞明白这个分工接下来写代码就是水到渠成的事。2. manifest.json 是地基字段配置与权限边界一步到位manifest.json 是扩展的“身份证加通行证”所有文件组织方式、权限声明、脚本注入规则都在这里规定。很多报错比如“无法读取 manifest”“content script 未注入”都是这个文件写错了。我用一个实际项目的配置逐段拆解。{ manifest_version: 3, name: 网页内容提取助手, version: 1.0.0, description: 一键提取当前页面的标题、链接、图片与表单信息并导出, action: { default_popup: popup.html, default_icon: icon128.png }, background: { service_worker: background.js }, content_scripts: [ { matches: [all_urls], js: [content.js], run_at: document_idle } ], permissions: [storage, activeTab, scripting], host_permissions: [all_urls], web_accessible_resources: [ { resources: [injected.js], matches: [all_urls] } ] }2.1 字段逐项拆解manifest_version必须写 3这是当前 Chrome 主推的版本。MV2 虽然还能用但 Google 已经在逐步淘汰旧版本的扩展了新项目直接用 MV3 不用犹豫。name、version、description商店展示用的基础信息。version 必须是语义化版本号每次上架都要比上一版高。action定义工具栏按钮。default_popup指向 popup 页面点击图标就弹出这个页面。如果只想点图标执行某个后台逻辑而不弹窗不配default_popup就行但需要配default_icon。background声明后台 Service Worker。注意 MV3 里只能写一个 service worker 文件它是整个扩展的事件中枢。content_scripts声明要把哪些 JS 注入到哪些页面。matches用匹配模式all_urls表示所有 http/https 页面run_at用document_idle表示 DOM 基本就绪后再执行大多数场景这个时机最稳。content script 的注入是基于 URL 匹配的插件加载后用户必须先刷新目标网页新的 content script 才会生效。这是新手最容易产生的困惑明明改了代码重载了插件打开旧页面却还是旧逻辑。web_accessible_resources声明哪些静态资源允许网页自身访问。后面我讲注入页面脚本时会用到如果这里不声明强行通过chrome.runtime.getURL引出的脚本会被浏览器的安全策略拦掉。2.2 permissions 与 host_permissions 的边界这两个字段是插件设计和审核的重点。permissions里声明的是一些能力类权限比如storage使用 chrome.storage、activeTab访问当前激活标签页的临时权限host_permissions里声明的是对哪些网站的访问权比如all_urls表示能向所有网站发起请求、读取页面信息。一个常见的误区以为 content script 能读页面 DOM是因为声明了host_permissions。实际上 content script 只要能注入页面就能操作这个页面的 DOMcontent_scripts.matches已经决定了注入范围。但如果你想让插件直接向某些接口发 fetch 请求就必须要host_permissions覆盖到对应域名否则请求会被 CORS 拦截。再说activeTab这个权限我在很多项目里喜欢用它替代全站的all_urls。它的逻辑是用户点工具栏图标后插件才获得操作当前标签页的临时权限不需要安装时弹“读取所有网站数据”的吓人警告。对内容提取、页面操作类工具来说activeTab加scripting是非常合理的权限组合商店审核也更友好。2.3 图标和常见的配置文件坑manifest 里不强制写图标但action.default_icon不配的话工具栏上会显示默认的灰色图案商店上架时也会被要求补齐各尺寸图标。建议准备 16、32、48、128 四张 PNG直接用一张 128 的也能通过大多数场景只是工具栏缩放时略模糊。另一个高频报错是匹配模式写错。Chrome 的匹配模式不能裸写https://*或*://*必须带路径部分比如https://*/*、http://example.com/*。写all_urls是官方提供的简写等价于所有支持的协议加任意路径。配置文件改完后一定要在chrome://extensions页面点“重新加载”否则你改的字段不会生效。如果改动涉及权限字段扩展会直接重新加载这是正常表现不用慌。3. 翻过最难的一道坎内容脚本如何绕过隔离调用页面 JS 函数现在进入最硬核的部分content script 调不到页面函数。我在群里看到这个问题被问过无数遍典型场景是页面是一个后台管理系统全局挂了window.exportExcel () {...}插件想点一个按钮就触发这个函数结果在 content script 里执行typeof window.exportExcel得到的是 undefined。原因就是前面提到的隔离世界。Chrome 为了安全给 content script 创建了独立的 JavaScript 执行环境页面自己的脚本和 content script 各自有一套window。它们共享的是 DOM 树但不共享 JS 层面的对象和变量。那么问题来了我们要触发页面全局函数或者读取页面内存里的 token直接的变量访问走不通唯一通用的做法是在页面自己的上下文里塞一段脚本让它替我们把数据拿出来再通过消息通道传回给 content script。3.1 注入 script 标签拿到页面执行权最经典的方式是创建一个script标签把要执行的代码塞进去再挂到页面的head或documentElement上。因为 script 标签本质是页面的一部分它执行时使用的就是页面的 window 对象能直接访问页面全局变量。// content.js function injectScript(code) { const script document.createElement(script); script.textContent code; script.onload () script.remove(); (document.head || document.documentElement).appendChild(script); }如果你把代码单独放成一个injected.js文件那需要在manifest.json的web_accessible_resources里声明它然后用script.src chrome.runtime.getURL(injected.js)来引用。两种方案都可以内联方式更简单一些不涉及源文件加载权限。3.2 用 postMessage 搭一条安全的通信通道数据从页面上下文拿回来后不能直接作为返回值给 content script——它们之间没有同步调用关系。通用的做法是让注入的脚本用window.postMessage把数据发到 window 上content script 再监听message事件取回来。// content.js injectScript( (function () { const token window.localStorage.getItem(token) || window.sessionStorage.getItem(token) || (window.__TOKEN__ ? JSON.stringify(window.__TOKEN__) : ); window.postMessage({ source: my-extension, type: PAGE_TOKEN, payload: token }, *); })(); ); window.addEventListener(message, (event) { if (event.source ! window || event.data?.source ! my-extension) return; if (event.data.type PAGE_TOKEN) { console.log([插件] 拿到页面 token:, event.data.payload); chrome.runtime.sendMessage({ type: TOKEN_FOUND, payload: event.data.payload }); } });这里有两个细节值得注意。第一postMessage的第二个参数如果写*表示任何窗口都能收到生产环境最好换成你的插件 ID 对应的目标 origin 校验不过 content script 所在的页面上下文里更稳妥的做法是在事件回调里校验event.source window和自定义的source字段防止别的脚本伪造消息。第二payload里如果包含大型对象要注意序列化开销普通登录 token 这种字符串完全没问题。3.3 调用页面全局函数也是同一套玩法读取变量和调用函数在原理上没有区别本质都是把代码放进页面上下文执行。假如页面暴露了一个window.generateReport(type)函数我们想从插件里触发它并且拿到返回结果// content.js const fnCallCode (function () { try { const result typeof window.generateReport function ? window.generateReport(monthly) : null; window.postMessage({ source: my-extension, type: PAGE_FN_RESULT, payload: { ok: true, data: result } }, *); } catch (err) { window.postMessage({ source: my-extension, type: PAGE_FN_RESULT, payload: { ok: false, error: err.message } }, *); } })(); ; injectScript(fnCallCode);监听部分和 token 例子完全一致只是type换成PAGE_FN_RESULT然后根据payload.ok判断结果。需要注意如果generateReport返回的是函数或 DOM 节点postMessage会序列化失败因为消息通道只支持结构化克隆。解决办法是让注入脚本在页面上下文里把数据先转成 JSON 兼容的字符串或普通对象。3.4 CSP 严格的页面怎么办讲一个容易劝退的细节并不是所有页面都让 script 标签随便注入。有些站点设置了非常严格的 Content Security Policy限制内联脚本执行你硬塞script进去会被浏览器阻止控制台会报Refused to execute inline script。遇到这种情况优先试外部脚本方案script.src指向插件包内的 JS因为有些页面 CSP 只拦内联脚本、放行外部脚本。如果外部脚本也被拦说明页面 CSP 直接禁止了非白名单来源此时除非你能通过chrome.debugger这类更高权限接口操作页面否则基本无能为力。这不是你的代码问题我在做企业内部门户的插件时就碰到过几次最后和站点开发团队沟通在 CSP 里临时加了插件资源的白名单才解决。如果目标站点属于自家公司最规范的方案是在响应头里允许插件静态资源加载这是长久之计。4. 完整实战做一个能一键提取网页内容的浏览器插件理论说够了下面做一个能直接用的工具网页内容提取助手。它能一键提取当前页面的标题、链接、图片、摘要信息和选中文本并支持导出 JSON 文件、复制到剪贴板。看完你会清楚 popup、content script、后台脚本三者是如何协作的。4.1 数据采集端content script 承担主力先在content.js里实现采集逻辑注意限制数量防止大页面卡死。// content.js function collectPageData() { const links Array.from(document.querySelectorAll(a)) .slice(0, 200) .map((a) ({ text: a.innerText.trim().slice(0, 50), href: a.href })); const images Array.from(document.images) .slice(0, 200) .map((img) img.currentSrc || img.src) .filter((src) src src.startsWith(http)); const meta {}; document.querySelectorAll(meta).forEach((m) { const key m.name || m.property; if (key) meta[key] m.content; }); return { title: document.title, url: location.href, description: meta.description || , selectedText: window.getSelection()?.toString().slice(0, 1000) || , links, images, meta }; } chrome.runtime.onMessage.addListener((message, sender, sendResponse) { if (message.type EXTRACT) { sendResponse(collectPageData()); } return true; });chrome.runtime.onMessage是 content script 接收消息的标准入口。popup 或后台脚本发来{ type: EXTRACT }content script 就返回采集结果。这里return true表示你要用sendResponse异步返回哪怕当前代码是同步的保留它也能避免后续改成异步时踩坑。采集策略上有意做了两个限制链接最多取 200 个图片最多取 200 个。普通页面完全够用而对那种几千个链接的长列表页一次性全量采集会造成消息传输超时和 DOM 查询卡顿。如果你真要处理超大页面建议在 content script 里分片处理先取前 500 条用户点击“加载更多”再取下一批。4.2 popup 端触发采集并渲染结果popup 是用户看到的面板。我直接用一个原生 HTML 页面来实现避免引入框架。核心逻辑是获取当前标签页向 content script 发送提取请求拿到数据后渲染成列表并提供导出按钮。// popup.js const [tab] await chrome.tabs.query({ active: true, currentWindow: true }); let result null; try { result await chrome.tabs.sendMessage(tab.id, { type: EXTRACT }); } catch (error) { document.getElementById(status).textContent 当前页面不支持提取请打开普通网页后重试; } if (result) { // 渲染基本信息 document.getElementById(pageTitle).textContent result.title; document.getElementById(pageUrl).textContent result.url; document.getElementById(pageDesc).textContent result.description || 无描述; document.getElementById(selectedText).textContent result.selectedText || 未选中任何文本; // 渲染链接列表 const linkList document.getElementById(linkList); result.links.forEach((link) { const item document.createElement(div); item.className link-item; item.textContent link.text || link.href; item.title link.href; linkList.appendChild(item); }); }这里有个关键点chrome.tabs.sendMessage是有可能失败的。比如用户打开的是chrome://内部页面或应用商店页面content script 没有被注入消息发出去没有接收方就会 reject。一定要用 try/catch 包住给用户一个友好提示而不是让 popup 白屏。在真实项目里我发现 popup 里直接做 DOM 渲染虽简单但数据量大时会出现明显的卡顿。一个两百条链接的列表用innerHTML一次性拼接反而比多次createElement更快。所以如果列表更长建议把渲染函数改成字符串模板拼接后一次性插入。4.3 导出 JSON 和复制结果采集完数据后用户最想要的是把结果带走。导出 JSON 的逻辑非常固定创建一个 Blob生成临时 URL模拟点击下载。// popup.js function exportJSON() { if (!result) return; const blob new Blob([JSON.stringify(result, null, 2)], { type: application/json }); const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download ${result.title || page-data}.json.replace(/[\\/:*?|]/g, _); a.click(); URL.revokeObjectURL(url); } document.getElementById(exportBtn).addEventListener(click, exportJSON);URL.revokeObjectURL在下载触发后立即调用是安全的因为浏览器已经拿到文件内容了。文件名里我做了一层非法字符过滤否则title里带/或:时 Windows 系统会报错。复制到剪贴板我推荐用navigator.clipboard.writeText但要注意它只在安全上下文里可用。普通插件页面加载的协议是chrome-extension://属于安全上下文可以直接用。不过老版本 Chrome 会有兼容问题稳妥做法是把writeText包在 try/catch 里失败时退回document.execCommand(copy)的旧方案。4.4 顺手加一个性能探针模式既然插件能直接操作页面我建议你顺手扩展一个非常实用的功能性能探针。这个功能在做大屏项目、数字孪生网站这类重前端应用时特别有用能快速看出页面到底卡在哪个环节。原理是在 content script 里注册PerformanceObserver监听longtask和resource条目然后把耗时超过阈值的请求汇总到 popup 展示。核心代码只需要十几行// content.js 里追加性能探针逻辑 const longTasks []; const slowRequests []; const observer new PerformanceObserver((list) { for (const entry of list.getEntries()) { if (entry.entryType longtask) { longTasks.push({ duration: entry.duration, startTime: entry.startTime }); } } }); observer.observe({ entryTypes: [longtask] });后台接口的耗时统计不需要 PerformanceObserver直接在 performance entries 里过滤duration 300的资源条目即可。把这些数据汇总后插件就从一个“内容抓取器”变成了“前端页面体检工具”。我在给一个可视化大屏项目做内部插件时就是靠这个探针模式定位到了图表渲染任务阻塞主线程的问题。5. 从本地调试到商店上架这条路比你想的短代码写完了接下来就是加载、调试、发布。很多前端只写到“本地能跑”就结束了其实完整链路并不复杂只是有一些经验性的坑。5.1 本地加载与热更新打开chrome://extensions右上角开启“开发者模式”左上角点“加载已解压的扩展程序”选择你的项目目录就能直接运行。开发过程中有两个“刷新”的概念容易混淆修改 manifest.json、popup.html、service worker 后需要回到扩展管理页点“重新加载”按钮。修改 content script 后光重新加载插件还不够必须重新加载你正在调试的目标页面因为 content script 在打开页面时已经注入了旧页面里跑的还是旧版本代码。如果嫌手动操作麻烦可以去商店搜一个叫Extensions Reloader的小工具它能一键重载所有插件并刷新当前标签页。不过我自己还是习惯手动刷新因为能顺便观察浏览器原生的报错顺序。5.2 控制台日志和报错排查content script 的日志不会出现在页面的 Console 里它属于插件的隔离环境。打开 DevTools 后如果当前页面里注入了 content script你会发现 Console 右上角多了一个下拉菜单可以切换执行上下文页面本身的上下文、content script 的上下文、扩展页面的上下文。选到content.js那个上下文才能看到 content script 打出的console.log。popup 的调试方式不同右键点插件图标选择“审查弹出内容”会单独弹出一个 DevTools 窗口里面能看到 popup 的 DOM 和 JS 日志。Service Worker 的调试入口在扩展管理页点后台脚本旁边的“查看”或者直接点扩展页面上“service worker”链接。常见报错我按频率排个序Cannot access a chrome:// URL目标页面不受支持、Could not establish connection. Receiving end does not existcontent script 没注入或目标页没刷新、The message port closed before a response was received接收方没有调用 sendResponse 且没有 return true。这三个只要理解了前面的运行机制基本都能一眼定位。5.3 Service Worker 生命周期带来的坑MV3 的 Service Worker 可能在几秒内休眠这让很多依赖状态保存的插件翻车。比如我在后台脚本里写了let lastUrl ; chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) { if (changeInfo.url) { lastUrl changeInfo.url; // 睡一会儿后就没了 } });这个lastUrl在 Worker 休眠后就会被清空。正确做法是写到chrome.storage.session里。MV3 专门提供了storage.session这个存储分区数据存活期和浏览器会话一致而且不会随 Worker 休眠丢失读写都是异步的非常适合做这类跨休眠的临时状态。开发调试时如果发现 Worker 醒来后行为不对劲就优先怀疑变量被回收了。展台工具、状态机逻辑这种东西越早迁移到 storage.session 越省事。5.4 发布到商店和企业内部分发正式分发一般走 Chrome Web Store。流程是在开发者后台注册账号需要一次性支付开发者注册费我现在印象中好像是 25 美元左右。然后把扩展打包成 zip上传源码包填写商店描述、截图、隐私政策提交审核。新扩展审核可能需要几天到两周不等第一次审核慢后续更新会快很多。审核最烦的是权限解释不充分。如果你的插件声明了all_urls加storage审查人员会要求你详细说明“为什么需要读取所有网站数据”。所以上架前尽量把权限最小化只读单个页面就优先activeTab要跨接口请求才加对应的host_permissions最好精确到域名而不是all_urls。如果插件只是公司内部用完全不必上架公网商店。最省事的是内网打包crx文件配合浏览器企业管理策略直接推给员工安装。微软 Edge 的 Add-ons 商店对开发者注册目前是免费的审核通常也比 Chrome 快一些可以作为国内团队的备选分发渠道。6. 入坑一个月后我最想提前告诉你的几条经验最后一个部分不写代码了聊点我在给客户做定制插件时积累下来的小经验。这些在文档里不容易看到但实际开发中非常影响体验和交付质量。第一能不用 npm 构建就不要用。我见过很多人给一个简单插件配 Vite、Webpack结果光配置就折腾半天。插件本体就是一个 HTML 加几个 JS 文件原生写法完全够用。只有当你确实需要 UI 框架或 TypeScript 编译时再引入构建工具否则只会增加调试负担。我现在的习惯是popup 用原生 JScontent script 用原生 JS全项目零依赖加载即调试。第二消息通道要统一管理 type 字段。插件开发必然涉及多角色互相发消息消息 type 满天飞会非常痛苦。我见过同事在 content script 里写了几十个 if-else 去判断消息类型最后连自己都搞不清哪个消息是从哪发的。解决方案是抽一个messages.js文件把 type 常量集中导出来比如// messages.js export const MSG { EXTRACT: extract, TOKEN_FOUND: token_found, FN_CALL: fn_call, PROBE_REPORT: probe_report };然后在所有脚本里统一引用。字段名、数据类型都定死消息结构用注释写清楚后期维护成本能降一半。第三在 content script 里做防御式判断是必要的。不是所有页面都有document.querySelector正常返回的 DOM也不是所有页面都允许注入脚本。写任何查询逻辑前先判空写注入逻辑前先 try/catch你的插件才不会在奇奇怪怪的站点上直接白屏。尤其企业门户、老旧 OA 系统这种非标准 HTML什么防御都不过分。第四权限影响你的用户转化率。我给一个内容提取工具做过一次小调研安装弹窗显示“读取所有网站数据”时明显比显示“读取当前网站的受限信息”时流失更大。能用activeTab解决的功能别拖到all_urls。最后说一个我自己印象最深的坑插件里 fetch 一个带 cookie 的接口时直接拿fetch(url)去请求可能没有携带页面 cookie因为扩展发起的请求默认不是 credentials 交叉上下文。解决方案是显式设置credentials: include并且确保host_permissions覆盖到了目标域名。这个坑排查了一个下午最后发现就是一行配置的事。大体上浏览器插件开发就是这么回事一个清单管权限三个脚本管分工一条消息通道管协作。这套链路跑熟了之后你会发现自己手里多了一把非常顺手的工具不只是能“抓数据”还能做自动化测试、性能分析、企业内部系统增强这些更酷的事。写第一个插件的最大收益不是学会了某个 API而是真正理解了浏览器的扩展运行模型这种理解会在你之后做前端工程时反复带给你回报。
返回列表