简介:针对希望利用前端技能自制Edge、Chrome标签页扩展插件的中高级开发者,这份压缩包系统梳理了浏览器扩展开发的核心知识。内容从配置文件、后台脚本、网页注入脚本、弹出窗口与图标资源等基础结构讲起,并引入jQuery简化DOM操作、CSS设计界面样式。同时详解了标签页管理、本地存储、网页导航等关键浏览器API的调用方法与适用场景,覆盖权限声明、安全最佳实践、开发者工具调试、跨浏览器兼容性测试,以及打包为可分发文件并发布到应用商店的完整流程。资源以zip压缩包形式提供,共约14.64MB,虽然文件清单未标注,但知识点足以支撑从入门到实战的完整学习与后期维护。已有1027人学习,适合想快速构建个性化标签页扩展并提升插件开发能力的前端开发者参考。
1. 自制Edge、Chrome标签页扩展插件:为什么值得自己写一个
浏览器标签页越来越多,找标签页比找文件还费劲,这是很多人想动手做一款自己的 Edge、Chrome 标签页扩展插件的第一动机。给你的浏览器加上标签页扩展插件,不只是为了“整理”,而是把浏览器变成你自己的工具台——一键分组、批量关闭、自动归档、跨窗口搬运,这些能力浏览器原生设置给不了,商店里的现成插件又常常带着你用不上的功能和隐私顾虑。自己写一个,代码量不大,核心逻辑几百行就能跑通,而且 Chrome 和 Edge 共用一套 Chromium 扩展机制,写一次两边都能用。这篇笔记适合有点前端基础、想亲手掌控浏览器行为的开发者,也适合团队内部做浏览器统一管理工具的技术负责人。我把从搭建工程到发布避坑的完整路径拆开讲,照着做就能用上。
2. 扩展到底改的是浏览器哪一层:Manifest V3、权限模型与最小工程目录
2.1 Edge 和 Chrome 为什么能共用同一套插件代码
微软 Edge 从某个大版本开始切换到 Chromium 内核,扩展机制和 Chrome 完全一致:都读 manifest.json,都用 chrome.* API,都走 WebExtension 规范。这意味着你在 Chrome 上写完的标签页扩展,在 Edge 里打开“开发人员模式”加载同一份文件夹就能跑。唯一差异在商店发布入口和审核策略,代码层面基本不用改。
做标签页插件时,“共用一套代码”这句话要理解到权限层面。Chromium 给扩展的权限分两类:一类是浏览器功能权限,比如 tabs、storage、bookmarks;另一类是站点权限,也就是 host_permissions,决定你的脚本能注入哪些网站。标签页管理插件通常两类都要,这也是新手最容易配错的地方——只写了 tabs 权限,结果 content script 在目标网站上完全不执行。
我一般建议新工程直接按“能跑最小集”起步:tabs 权限负责读标签页信息,storage 权限负责保存插件自己的数据,host_permissions 先给成<all_urls>方便调试,后面再按需收紧。权限配多了审核麻烦,配少了功能起不来,后面我会逐个说。
2.2 Manifest V3 与 V2 的差别,以及标签页插件必须拿到的权限
现在的扩展商店已经全面转向 Manifest V3,简称 MV3。V2 时代那种 background page 常驻后台、可以随便用远程代码的模式已经被淘汰。MV3 把后台逻辑改成 service worker,平时休眠、事件来了才唤醒,页面代码也不能再从远程服务器加载。这对标签页插件影响很大:你想在后台定时轮询标签页状态,不能依赖 setTimeout 长驻,得靠 chrome.alarms 或者事件触发。
标签页插件在 MV3 下至少需要三块权限。第一块是"permissions": ["tabs", "storage", "tabGroups"],其中 tabGroups 在旧版本 Chrome 里叫tabGroups,用来读分组信息;第二块是"host_permissions",决定 content script 能跑在哪些站点上;第三块是"action"配置,决定工具栏图标和弹窗面板。少了tabs权限,chrome.tabs.query 拿不到标签页的 url 和 title,只能拿到空壳 id。
注意一点:MV3 的background.service_worker不支持 DOM 操作,不能用 document 相关 API。你要是打算在后台脚本里动态创建 HTML 节点做渲染,直接报错。标签页插件的渲染工作放到 popup 里,后台只做数据中转和行为触发。
2.3 最小工程目录与第一份 manifest.json
一个标签页扩展的最小工程不需要构建工具,纯手写即可。目录里只需要四个文件:manifest.json、popup.html、popup.js,以及一个 background.js。新手阶段不要加 options 页面和 content script,先让扩展能加载、能弹窗、能读标签页列表,再逐步加东西。下面是这份 manifest.json 的完整写法。
{ "manifest_version": 3, "name": "Tab Manager Practice", "version": "0.1.0", "description": "A minimal tab manager extension for learning.", "permissions": ["tabs", "storage", "tabGroups"], "host_permissions": ["<all_urls>"], "background": { "service_worker": "background.js" }, "action": { "default_popup": "popup.html", "default_title": "Tab Manager" }, "icons": { "16": "icons/icon16.png", "48": "icons/icon48.png", "128": "icons/icon128.png" } }这段配置里最关键的是permissions里的tabGroups,没有它,chrome.tabGroups.query 和 chrome.tabGroups.update 会直接抛错。host_permissions我先给了<all_urls>,调试 content script 时不至于莫名其妙不生效;正式发布前建议改成你实际需要的站点匹配规则,比如https://*.example.com/*。
action.default_popup指向的 popup.html 是点击工具栏图标弹出的面板,这是大多数标签页插件的入口界面。background.js 目前可以先写一个空脚本或只做日志输出。图标文件可不是可选项,加载扩展时缺少 icons 目录会警告,某些系统下商店打包会被拒。能用最简单的方式生成三个尺寸的 PNG 就行,关键是不能缺。
3. 从零写一个能跑的标签页管理器:核心代码与参数说明
3.1 弹窗面板:列出当前窗口所有标签页
先把面板功能做出来。popup.html 只放一个按钮和一个列表容器,popup.js 在打开时读取当前窗口的标签页并渲染。注意 popup 的生命周期:用户点击图标弹出面板,面板一关闭,里面的定时器和数据就没了。所以 popup.js 的逻辑必须“打开即取数”,不要依赖任何持久状态。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <style> body { width: 360px; font-family: system-ui, sans-serif; } #tabList { max-height: 400px; overflow-y: auto; } .tab-item { padding: 6px 8px; border-bottom: 1px solid #eee; cursor: pointer; } .tab-item:hover { background: #f0f2f5; } </style> </head> <body> <h3>当前窗口标签页</h3> <div id="tabList"></div> <script src="popup.js"></script> </body> </html>async function loadTabs() { const tabs = await chrome.tabs.query({ currentWindow: true }); const container = document.getElementById("tabList"); container.innerHTML = ""; for (const tab of tabs) { const item = document.createElement("div"); item.className = "tab-item"; item.textContent = tab.title || tab.url || "(无标题页面)"; item.addEventListener("click", () => { chrome.tabs.update(tab.id, { active: true }); window.close(); }); container.appendChild(item); } } loadTabs();chrome.tabs.query({ currentWindow: true })返回的是一个 Promise,MV3 页面脚本里可以直接 await。currentWindow: true表示只看当前窗口的标签页,不加这个参数默认也是当前窗口,但写明确更不容易产生歧义。点击列表项后调用chrome.tabs.update把这条标签页切到前台,然后window.close()把 popup 面板关掉——这是弹窗插件的标准操作,面板不主动关闭会遮住用户视线。
这里有一个新手很容易踩的坑:tab.title在没有tabs权限时是 undefined。如果你的 manifest 没写tabs权限,列表显示出来全是“(无标题页面)”。另一个坑是chrome.tabs.update的第二个参数对象里,active: true只会切换激活状态,不会自动聚焦窗口;需要聚焦时还要补一个chrome.windows.update(tab.windowId, { focused: true })。
3.2 一键分组:chrome.tabs.group 的用法与参数
标签页分组是原生 API 里对整理类插件最友好的能力。调用chrome.tabs.group可以把一批标签页指定到某个分组,如果没给 groupId 就新建分组,返回新分组的 id。先看代码再解释参数。
async function groupTabsByDomain(tabIds) { const groups = {}; const tabs = await chrome.tabs.query({ currentWindow: true }); for (const tab of tabs) { if (!tabIds.includes(tab.id)) continue; let hostname; try { hostname = new URL(tab.url).hostname; } catch (e) { hostname = "(其他)"; } if (!groups[hostname]) { groups[hostname] = []; } groups[hostname].push(tab.id); } for (const hostname of Object.keys(groups)) { const groupId = await chrome.tabs.group({ tabIds: groups[hostname] }); await chrome.tabGroups.update(groupId, { title: hostname }); } }这个函数做的事是:按域名把传入的标签页 id 分组,每个域名生成一个组,并把组标题改成域名。chrome.tabs.group接收tabIds数组或单个 id,groupId可选,不传就新建分组。返回的 groupId 传给chrome.tabGroups.update设置标题和颜色。
参数层面要注意两点。第一,tabIds里如果混入了已经分组的标签页,旧分组关系会被解除,标签页会被挪到新组。第二,chrome.tabGroups.update的title参数在部分系统版本上长度限制大约 16 个字符以内,超长会被截断,域名太长时建议只取主域名。还有一个兼容点:chrome.tabs.group这个方法在 Chrome 88+ 才可用,企业环境里如果浏览器版本偏旧,记得加特性检测。
if (chrome.tabs.group) { // 支持分组 } else { // 降级:只移动位置不分组 }3.3 用 chrome.tabs.move 整理标签页位置
分组之外,移动标签页位置也是高频操作。chrome.tabs.move可以把标签页移动到窗口内指定索引,也可以跨窗口移动。它的参数比分组简单,但边界行为值得说清楚。
async function moveTabsToFront(tabIds) { const tabs = await chrome.tabs.query({ currentWindow: true }); const pinnedCount = tabs.filter(t => t.pinned).length; await chrome.tabs.move(tabIds, { index: pinnedCount }); }这段代码把选中的标签页统一移动到固定标签页后面。index参数支持 -1 表示移到末尾,正常情况下传非负数表示具体位置。有个隐藏规则:当前窗口里如果存在固定标签页,它们在索引顺序上永远排在最前面,普通标签页无法插入到固定标签页之前,即便你传 index: 0 也会被自动推到固定区之后。所以先数出pinnedCount再计算实际索引是可靠做法。
chrome.tabs.move的tabIds可以传数组,如果目标是另一个窗口,要传{ windowId, index }两个字段。跨窗口移动时,原窗口的标签页会被剪切走,窗口顺序也会变化。这个 API 没有确认弹窗,所以插件里调用前最好自己对用户做一次操作确认,不然误点一下标签页全跑对面窗口去了,挺折腾的。
3.4 把插件代码跑起来:从加载到调试的完整流程
写好的文件夹怎么进浏览器?路径是:Chrome 打开chrome://extensions,右上角开启“开发者模式”,左上角点“加载已解压的扩展程序”,选中工程目录。Edge 是打开edge://extensions,同样开启开发人员模式然后加载文件夹。加载之后,图标出现在工具栏,如果没出现,去扩展详情页里点“固定”按钮。
调试方面,popup 的调试方式和普通网页一样,在 popup 页面里右键“检查”打开 DevTools。background service worker 的调试入口藏在扩展详情页里,找到“服务工作人员”或“Service Worker”字段,点链接打开独立 DevTools,console 输出和断点调试都在那里。我经常见到有人改完代码忘了在详情页点刷新按钮,导致一直测的是旧版本,这一点要养成习惯。
改动 manifest.json 里的权限或 background 配置后,必须去扩展详情页点一下刷新。如果改动只涉及 popup.html 或 popup.js,直接重新打开弹窗面板就能生效,不需要手动刷新。区分这两种情况能省不少时间。
4. 让插件看见页面内容:Content Script 注入与事件监听
4.1 content script、popup、service worker 三者的职责分工
标签页信息能读到标题和 URL,但页面里到底发生了什么,扩展默认看不见。要读取 DOM、监听页面按钮点击、统计页面上的链接数量,就必须引入 content script。理解这三层的关系是写标签页插件的基本功。
popup 是界面层,用户操作入口,生命周期短,关闭即销毁。service worker 是后台调度层,负责监听浏览器级事件,比如标签页打开、关闭、切换,也可以在后台调用大部分 chrome.* API。content script 是注入到网页里的脚本,能操作页面 DOM,但它和页面本身共享 DOM 却不共享 JavaScript 变量,也不能调用 chrome.tabs 里的多数方法。三者通过消息传递通信:content script 用chrome.runtime.sendMessage把数据发给 service worker,service worker 处理完用chrome.tabs.sendMessage回传。
标签页插件常见的信息流是:content script 在页面上发现新内容 → 发消息给 service worker → service worker 更新 storage → popup 读取 storage 并渲染。每一层只做自己的事,不要混着写。有些人图省事在 content script 里直接调用 chrome.tabs.query,结果得到 undefined,因为 content script 的权限模型和扩展后台不一样。
4.2 注入脚本的三种方式:static、dynamic 与 programmatic
MV3 里注入 content script 有静态声明和动态注册两种方式。静态声明写在 manifest.json 的content_scripts字段里,浏览器在匹配页面加载时自动注入。动态注册通过chrome.scripting.registerContentScripts在运行时注册,适合按用户操作决定要不要注入的场景。还有第三种是chrome.scripting.executeScript,一次性注入,不入册。
{ "content_scripts": [ { "matches": ["https://*/*"], "js": ["content.js"], "run_at": "document_idle" } ] }matches决定注入哪些站点,run_at三个可选值:document_start 表示页面刚开始解析就执行,document_end 表示 DOM 解析完但子资源可能还在加载,document_idle 表示页面加载完成后执行。标签页管理类插件建议用 document_idle,因为 document_start 时 DOM 还不完整,很多代码跑不出效果。
动态注册的优点是可以按需精确控制,不污染所有网站。常见做法是在 popup 或右键菜单里让用户勾选“此站点启用增强功能”,然后调用注册接口。注意chrome.scripting需要在 permissions 里加"scripting"权限,没有它,相关方法直接不存在。
Programmatic 注入适合一次性操作:比如点击插件按钮后,弹出当前页面所有链接。这种场景不需要常驻注入,等用户点击再执行就行,减少性能浪费。
const results = await chrome.scripting.executeScript({ target: { tabId: activeTabId }, func: () => Array.from(document.querySelectorAll("a")).map(a => a.href) });executeScript的func函数会被序列化后注入到页面执行,所以它不能引用外部变量,外部能传进去的只有args数组。这是新人最常翻车的地方:func 里用了外层闭包变量,结果拿到 undefined。
4.3 监听标签页变化:onCreated、onUpdated、onRemoved 的写法
标签页插件的很多功能依赖实时状态:用户新建标签页时要自动分组,页面标题变化时要更新列表,标签页关闭时要清理数据。这些都要在 service worker 里挂事件监听。
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { if (changeInfo.status === "complete") { console.log(`Tab ${tabId} finished loading: ${tab.url}`); } }); chrome.tabs.onRemoved.addListener((tabId, removeInfo) => { console.log(`Tab ${tabId} removed from window ${removeInfo.windowId}`); }); chrome.tabs.onActivated.addListener((activeInfo) => { console.log(`Tab ${activeInfo.tabId} activated in window ${activeInfo.windowId}`); });onUpdated会触发多次:加载中、标题变化、URL 变化、加载完成都会各触发一次。想只在页面真正加载完时处理逻辑,必须判断changeInfo.status === "complete",并且还要判断changeInfo.url是否存在,避免重复处理。onRemoved的removeInfo里有两个字段,windowId 和 isWindowClosing,后者表示是不是整个窗口关闭导致标签页被动移除,处理数据清理时两个场景往往要区别对待。
监听器挂多了要小心:service worker 休眠后重新唤醒会重新注册监听器,如果 wakeup 逻辑里又注册了一遍,同一个监听器会执行两次。经验是只在顶层作用域注册监听器,不要在某个事件回调里再嵌套注册,这样无论唤醒多少次都只会有一份监听。
5. 标签页插件避坑指南:SW 休眠、事件丢失与打包翻车
5.1 service worker 被休眠,定时器和内存数据全没了
现象:background.js 里写了个 setInterval 打算每 30 秒刷新一次标签页列表,结果过一会儿发现完全没动静,重新打开扩展详情页才恢复。原因:MV3 的 service worker 空闲一段时间后会被浏览器回收,所有定时器、全局变量一并销毁,这不是 bug 而是设计。解决:定时任务改用chrome.alarmsAPI,它由浏览器在系统层面调度,休眠期间也能按时唤醒 worker。
chrome.alarms.create("refreshTabs", { periodInMinutes: 1 }); chrome.alarms.onAlarm.addListener((alarm) => { if (alarm.name === "refreshTabs") { refreshTabList(); } });chrome.alarms最短周期是 30 秒,periodInMinutes: 1实际触发间隔可能在一分钟左右浮动,不要拿它做精确计时。同时,需要持久保存的数据一律走chrome.storage,不要放在全局变量里赌 worker 不休眠。
5.2 chrome:// 和商店页面禁止注入,插件报错别慌
现象:content script 明明配置了https://*/*,但在chrome://extensions、edge://settings和 Chrome 自带商店页面里完全不执行,console 里也没有报错。原因:这些是浏览器特权页面,扩展的 host_permissions 对这些页面一律无效,属于平台硬性限制,不是你的代码问题。解决:这是预期行为。判断方式很简单,在普通网站上跑一下,能执行就说明脚本没问题。用户如果在特权页面上打开你的插件并抱怨没反应,合理的回复是说明浏览器限制。
5.3 分组 API 版本兼容,老版本直接把方法返回空
现象:调用chrome.tabGroups.update报错Cannot read properties of undefined,或者chrome.tabs.group根本不存在。原因:tabGroups 相关 API 是 Chrome 88 之后才加入,某些企业浏览器内核停留在旧版,没有暴露这些方法。解决:调用前做特性检测,不存在时降级到不打分组、只移动位置或加前缀 title。我一般会在代码里写一个 isTabGroupsSupported() 工具函数,并在 UI 上把“分组”按钮置灰,避免用户点了没反应。
5.4 打包密钥丢失,已发布插件变成孤儿
现象:扩展在商店发布了 0.1.0 版本,后来电脑重装,打包时的私钥文件没备份,下一个版本怎么都发布不上去。原因:商店用打包时生成的私钥验证开发者身份,换密钥等于换身份,旧用户不会收到新版本更新。解决:打包生成的.pem私钥文件立刻备份到安全位置。如果你只是自用不发布,密钥丢了影响不大,重打包换个新 id 就行。但一旦上架,私钥比账号密码还重要,这事建议提前写进团队交接文档里。
5.5 content script 与页面脚本共享 DOM 但不共享变量
现象:content script 里设置了一个全局变量window.myData = 1,然后在页面自身的 script 里读myData,结果是 undefined。原因:content script 运行在隔离环境里,和页面共享 DOM 树但 JavaScript 执行环境彼此独立,全局对象不是同一个。解决:要往页面上下文传数据,通过 DOM 事件或者把数据写到某个 DOM 属性上。反过来也一样,页面里定义的变量在 content script 里读不到。这不是 bug,是安全设计,理解了这条,很多莫名其妙的问题都能解释通。
6. 进阶:把标签页插件做成你的浏览器工作台
做一套标签页会话快照功能。核心思路:把当前窗口所有标签页的 url 和 title 存到chrome.storage.session,需要的时候一键还原。这里用 session 而非 local,是因为 session 在浏览器会话结束后自动清空,符合“会话快照”的语义,不会堆积垃圾数据。
async function snapshotWindow() { const tabs = await chrome.tabs.query({ currentWindow: true }); const data = tabs.map(t => ({ url: t.url, title: t.title, pinned: t.pinned })); await chrome.storage.session.set({ snapshot: data }); } async function restoreWindow() { const { snapshot } = await chrome.storage.session.get("snapshot"); if (!snapshot) return; for (const item of snapshot) { await chrome.tabs.create({ url: item.url, pinned: item.pinned }); } }chrome.storage.session的容量限制是 10MB,存几百个标签页的 URL 绰绰有余。还原时注意别把当前已有标签页清掉,否则用户会认为你误删了数据。
给这个功能配一个快捷键。chrome.commands 在 manifest 里声明命令,用户可以在扩展详情页里自定义组合键。声明方式如下。
{ "commands": { "snapshot": { "suggested_key": { "default": "Ctrl+Shift+S" }, "description": "Save current window snapshot" }, "restore": { "suggested_key": { "default": "Ctrl+Shift+R" }, "description": "Restore last snapshot" } } }然后 background.js 里监听。chrome.commands在部分网站和 address bar 上可能不生效,这是平台限制,自用场景影响不大。我实际使用时,喜欢把 snapshot 和 restore 做成按钮加在 popup 面板里,快捷键只是补充——因为改完 manifest 后还要去扩展详情页重新确认快捷键是否被系统其他软件占用,这一步很容易被忽略。
能做的事远不止这些:一键把当前窗口所有标签页按域名拆到不同窗口、定期清理休眠标签页、结合右键菜单对单个标签页做操作。原理都逃不出 chrome.tabs 和 chrome.windows 这两个 API 的组合。我自己做这套插件时最大的教训是先把权限边界摸清再动 UI,权限没配好代码再对也是白搭。希望这篇笔记能帮你在浏览器自动化这条路上少踩几个坑,动手做一个真正趁手的标签页扩展。
本文还有配套的精品资源,点击获取