Chrome扩展程序是轻量级的工具,它们能够提升并个性化你的浏览体验——无论是帮助你管理密码、翻译网页,还是为日常使用的网站添加新的功能。
已有数百万开发者将他们的扩展程序发布到了Chrome应用商店中,而开发这样的扩展程序其实比你想象的要简单得多。
通过这本手册,你将学习如何使用TypeScript、React以及Plasmo这个现代框架从零开始制作一个可发布的Chrome扩展程序。Plasmo会处理那些重复性的设置和配置工作,这样你就可以专注于编写核心功能代码,而无需花费时间处理繁琐的底层细节。
在学习过程中,你还将接触到那些真正用于构建扩展程序的API:如何查询标签页、创建标签页组,以及如何在扩展程序的不同组件之间传递信息。
学习结束后,你将拥有可运行的代码,也会对扩展程序的结构有清晰的了解,并且掌握将自己的创意发布到Chrome应用商店所需的一切技能。
目录
什么是Plasmo?
Plasmo是一个用于开发浏览器扩展程序的开源框架。你可以把它看作是专门为Chrome扩展程序设计的“Create React App”或“Next.js”。
如果没有Plasmo,开发Chrome扩展程序就需要手动编写`manifest.json`文件、配置构建工具,并自行设置TypeScript和React的相关参数。而Plasmo可以完成所有这些工作。
只需一条命令,就能创建一个已经配置好了TypeScript和React的可用项目。Plasmo会自动读取你的`package.json`文件,并生成Chrome所需的`manifest.json`文件,因此你根本不需要直接编辑它。
此外,在开发过程中,对源代码所做的任何修改都会自动触发扩展程序的重新构建,并在Chrome浏览器中立即生效。同时,Plasmo还提供了类型安全功能,包括针对Chrome自身API的类型检测。
Plasmo并不会隐藏Chrome扩展程序开发的相关概念——你仍然需要直接使用`chrometabs`、`chrome.runtime`等Chrome API。但它省去了那些繁琐的开发准备工作,让你能够立即开始编写代码。
你将构建什么
在这个教程中,你将从零开始制作一个标签页分组器Chrome扩展程序。
这个扩展程序会根据网页的域名自动对浏览器中的标签页进行分类整理。

使用案例示例
假设你打开了20个标签页:其中5个来自GitHub,4个来自YouTube,3个来自Stack Overflow,另外8个来自其他网站。
只需点击一下,标签页分组器就会自动为每个网站创建对应的颜色组,这样你就能轻松地找到并管理这些标签页了。
你将学到什么
完成这个教程后,你将在三个方面获得实践经验。
首先,是Chrome扩展程序基础:了解扩展程序在底层是如何工作的,扩展程序的构成要素(manifest文件、后台脚本、弹出窗口等),以及如何在开发过程中在Chrome中加载和测试扩展程序。
其次,是Chrome API:具体来说包括用于管理浏览器标签页的`chrome.tabs`,用于创建和自定义标签页组的`chrome.tabGroups`,以及用于在扩展程序的不同部分之间传递信息的`chrome.runtime`。
第三,是现代Web开发工具:使用TypeScript编写类型安全的JavaScript代码,利用React构建弹出窗口界面,同时借助Plasmo框架将所有这些技术整合在一起。
先决条件
你不需要在这些领域成为专家,但如果你对基本的JavaScript或TypeScript有一定了解,并且对HTML和CSS也有一定的认识,那么你的学习过程会更加顺利。
虽然熟悉React会有帮助,但并不是必需的。我们将会制作的弹出窗口组件非常简单,即使你是新手也能轻松掌握。
在软件方面,你需要安装Node.js 18版本或更高版本(点击此处下载)、Google Chrome浏览器、一个代码编辑器(推荐使用VS Code),以及pnpm作为包管理工具。
验证你的环境配置
打开终端,运行以下命令来确认所有软件都已经安装完毕:
node --version
# 应该输出v18.0.0或更高版本
npm --version
# 应该输出9.0.0或更高版本
寻求帮助
如果遇到困难,可以查看代码库中的完整代码,查阅Chrome扩展程序的官方文档,或者在社区论坛中寻求帮助。
准备开始了吗?
在下一节中,你将设置开发环境,并创建你的第一个Chrome扩展程序项目。
让我们开始吧!
项目设置
在本节中,您将使用 Plasmo 来搭建您的 Chrome 扩展程序项目框架,然后根据 Tab Grouper 的需求对其进行定制。
无需手动创建文件,Plasmo 会自动生成包含所有必要配置的起始项目模板,您可以先查看这些内容,再根据实际需要对其进行修改。
步骤 1:安装 pnpm(推荐操作)
Plasmo 官方推荐使用 pnpm,因为它能够加快安装速度并更高效地利用磁盘空间。请检查您是否已经安装了 pnpm:
pnpm --version
如果看到了版本号,可以直接跳到步骤 2。

如果看到“命令未找到”的提示,请使用以下命令进行安装:
npm install -g pnpm
步骤 2:创建您的扩展程序项目
运行以下命令来创建一个新的 Plasmo 项目:
pnpm create plasmo tab-grouper
您会看到如下提示:
🟣 正在创建新的 Plasmo 扩展程序
📁 项目名称:tab-grouper
? 扩展程序描述:(请为您的扩展程序填写一个合适的描述)
? 作者姓名:(请输入您的名字)
Plasmo 会自动完成项目的框架搭建并安装依赖项。系统可能会要求您输入描述和作者姓名,您可以根据自己的喜好进行填写。

步骤 3:进入您的项目目录
cd tab-grouper
步骤 4:查看已生成的内容
列出 Plasmo 生成的文件列表:
ls -la
您应该会看到类似以下的输出:
tab-grouper/
├── .git/ # Git 仓库(已初始化)
├ ├── .github/ # GitHub Actions 工作流程相关文件
├ ├── assets/
│ └── icon.png # Plasmo 的默认图标文件
├ ├── node_modules/ # 依赖项(已安装完成)
├ ├── package.json # 项目配置文件
├ ├── popup.tsx # 默认的弹出窗口组件文件
├ ├── .prettierrc.cjs # 代码格式化规则文件
├ ├── .gitignore # Git 忽略规则文件
├ ├── README.md | 默认的阅读说明文件
└── tsconfig.json | TypeScript 配置文件
需要了解的关键文件包括:
-
assets/icon.png:Chrome浏览器所需的扩展程序图标文件。
-
package.json:列出了扩展程序所依赖的库和脚本,同时也是配置扩展程序元数据的地方。
-
popup.tsx:点击扩展程序图标时出现的用户界面代码。
-
tsconfig.json:其中包含了已经正确配置好的TypeScript相关设置。
步骤5:测试默认扩展程序
在对其进行自定义修改之前,请确保所有功能都能正常运行。
你可以通过启动开发服务器来验证这一点:
pnpm dev
你应该会看到如下输出:
🟣 Plasmo v0.90.5
🔴 浏览器扩展程序框架
🔵 INFO | 正在启动扩展程序开发服务器...
🔵 INFO | 正在为目标环境“chrome-mv3”构建代码...
🔵 INFO | 从以下路径加载了环境变量:[]
🟢 DONE | 扩展程序重新打包完成,用时1842毫秒!🚀
查看扩展程序文件:
📦 build/chrome-mv3-dev
你的扩展程序现在已经准备就绪。请保持这个终端窗口处于打开状态。
Plasmo会自动检测文件的变化并重新构建代码。
步骤6:在Chrome中加载扩展程序
现在,请将扩展程序加载到Chrome浏览器中进行测试:
-
打开Google Chrome浏览器
-
进入
chrome://extensions/页面 -
启用开发者模式(位于右上角)
-
点击“加载解压后的文件”
-
导航到你的项目文件夹
-
选择
build/chrome-mv3-dev文件夹 -
点击“选择文件夹”按钮

现在你的扩展程序应该已经出现在列表中了。
步骤7:测试默认弹出窗口
-
点击Chrome浏览器工具栏中的拼图图标
-
找到“tab-grouper”选项并将其固定到工具栏上
-
再次点击扩展程序图标
你会看到一个显示“欢迎使用Plasmo!”信息的默认弹出窗口。

扩展程序已经可以正常使用了,现在你可以对其进行自定义修改了。
步骤8:更新扩展程序信息
在编辑器中打开package.json文件。这个文件保存了关于你的扩展程序的元数据,包括名称、版本号、描述、依赖库以及用于构建和运行扩展程序的脚本等信息。
在文件顶部附近找到以下这几行代码:
{
"name": "tab-grouper",
"displayName": "tab-grouper",
"version": "0.0.0",
"description": "一个基本的Plasmo扩展程序。",
将它们修改为:
{
"name": "tab-grouper",
"displayName": "Tab Grouper",
"version": "1.0.0",
"description": "一个简单的Chrome扩展程序——可按域名对标签页进行分组。",
保存文件。
步骤9:添加所需的权限(非常重要!)
这是一个至关重要的步骤。如果没有这些权限,你的扩展程序将会出现错误,例如:
TypeError: 无法读取未定义对象的属性(在尝试访问‘query’时出现此错误)
Chrome扩展程序必须明确说明自己打算使用哪些浏览器API。在package.json文件中,找到"manifest"部分。
它的格式如下:
"manifest": {
"host_permissions": [
"https://*/*"
]
}
将其修改为:
"manifest": {
"permissions": [
"tabs",
"tabGroups"
]
}
保存文件。tabs权限允许你读取标签页的相关信息(这是使用chrome.tabs.query()函数所必需的),而tabGroups权限则使你能够创建和管理标签组(使用chrome.tabGroups.update()函数时需要这些权限)。
如何为自己的扩展程序选择合适的权限:
Chrome扩展程序权限参考列出了所有可用的权限以及它们各自能实现的功能。
每个API的文档页面也会说明它需要哪些权限。例如,chrometabs API的文档明确指出了需要"tabs"权限。
如果你正在使用Plasmo框架,Manifest配置文档会说明如何通过package.json文件来添加权限。
一般来说:如果在使用Chrome API时遇到了undefined类型的错误,首先应该检查是否缺少某些权限。
步骤10:验证热重载功能是否正常
当你保存对扩展程序的修改后,Plasmo会自动重新加载该扩展程序。
查看运行着pnpm dev命令的终端窗口。在保存了package.json文件之后,你应该会看到类似以下的提示信息:
🔄 正在重新加载扩展程序...
✅ 0.8秒后即可完成重载
现在你的项目已经准备就绪:Chrome浏览器中已加载了可正常使用的扩展程序,开发服务器也启用了热重载功能,而且所有必要的权限也都已经配置好了。
在继续后续步骤时,保持开发服务器处于运行状态,并让扩展程序保持加载状态。这样你的修改就会自动得到应用。
章节总结
在本节中,您安装了pnpm,使用pnpm create plasmo创建了一个新的扩展程序,了解了生成的项目结构,启动了开发服务器,在Chrome中加载了这个扩展程序,并更新了其元数据和权限设置。
下一步:您需要编写用于处理标签页分组逻辑的背景脚本。
了解背景脚本
背景脚本是您的扩展程序的核心部分。它在后台持续运行,其中包含了实现核心功能的代码。
在这个例子中,这段代码负责根据域名对标签页进行分组。
什么是背景脚本?
即使弹出窗口被关闭,背景脚本也会继续运行。
它可以监听诸如标签页打开、关闭或更新等浏览器事件,执行那些不需要用户直接干预的任务,并通过传递消息与其他扩展程序组件进行通信。
可以把背景脚本看作是您的扩展程序的“后端部分”,而弹出窗口则只是与之交互的用户界面。
步骤1:创建background.ts文件
Plasmo的模板生成机制默认不会创建背景脚本,因此您需要手动创建这个文件。在项目根目录下创建一个名为background.ts的新文件(该文件的层级应与popup.tsx相同):
export {}
// 背景脚本——在后台运行,负责处理标签页分组逻辑
console.log("标签页分组器背景脚本已加载!");
// 监听来自弹出窗口的消息
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === "GROUP_TABS") {
groupTabsByDomain()
sendResponse({ success: true })
}
return true
})
文件开头的export {}是Plasmo要求必须添加的,这样才能将这个文件视为一个模块。如果没有这行代码,可能会出现全局变量声明冲突的错误。
console.log这条语句可以帮助您确认脚本是否已正确加载(您可以在扩展程序的DevTools控制台中看到这条日志)。chrome.runtime.onMessage则用于设置监听器,使背景脚本能够接收来自弹出窗口的指令。
当背景脚本收到"GROUP_TABS"类型的消息时,就会调用groupTabsByDomain函数来执行分组操作。
关于这种消息传递机制的更多信息,您可以参考Chrome扩展程序文档。
步骤2:实现标签页分组逻辑
现在,在消息监听器下方添加主要的分组逻辑代码:
async function groupTabsByDomain() {
try {
// 第一步:获取当前窗口中的所有标签页
const tabs = await chrome.tabs.query({ currentWindow: true })
// 第二步:使用Map按域名对标签页进行分类
const domainGroups = new Map()
// 第三步:遍历每个标签页并按域名将其分组
tabs.forEach(tab => {
// 跳过没有URL的标签页
if (!tab.url) return
// 从URL中提取域名
const domain = getDomainFromUrl(tab.url)
// 跳过无效的域名(例如chrome:// pages)
if (!domain) return
// 将标签页添加到对应的域名组中
if (!domainGroups.has(domain)) {
domainGroups.set(domain, [])
}
domainGroups.get(domain)!.push(tab)
})
// 第四步:为每个域名创建标签页组(只有当该域名下有2个以上的标签页时才执行此步骤)
for (const [domain, domainTabs] of domainGroups) {
// 跳过只有一个标签页的域名
if (domain Tabs.length < 2) continue
// 获取所有标签页的ID
const tabIds = domainTabs
.map(t => t.id!)
.filter(id => id !== undefined)
if (tabIds.length === 0) continue
// 创建标签页组
const groupId = await chrome.tabs.group({ tabIds })
// 为标签页组设置标题和颜色
await chrome.tabGroups.updategroupId, {
title: domain,
color: getColorForDomain(domain) // 为标签页组随机分配颜色
})
}
console.log(`成功地为${domainGroups.size}个域名分组完成了标签页`)
} catch (error) {
console.error("在分组标签页时出现错误:", error)
}
}
该函数首先会查询当前窗口中的所有标签页,然后遍历这些标签页,构建一个以域名作为键的Map。
当所有标签页都被分到相应的域名组中后,程序会遍历这个映射表,对于那些拥有两个或更多标签页的域名,就会调用chrome.tabs.group()函数来将这些标签页组合成一个组,随后立即为这个组设置标题和颜色。
只有单个标签页的域名会被忽略——因为对这样的域名进行分组并没有意义。
步骤3:提取域名辅助功能
添加一个辅助函数,用于从URL中提取主机名:
function getDomainFromUrl(url: string): string | null {
try {
const urlObj = new URL(url)
// 跳过Chrome的内部页面(如chrome://、chrome-extension://)
if (urlObj.protocol === "chrome:" || urlObj.protocol === "chrome-extension:") {
return null
}
// 去掉“www.”前缀,返回主机名
return urlObj.hostname.replace(/^www\./, "")
} catch {
// 如果URL无效,返回null
return null
}
}
new URL(url)能为我们提供一个结构化的对象,从而无需手动解析URL字符串。
通过检查协议地址,可以过滤掉Chrome的内部页面,比如chrome://extensions和chrome://settings——这些页面是扩展程序无法访问的。
.replace/^www\./, "")这一操作确保了www.github.com和github.com会被视为同一个域名,而不会被分到不同的组中。
整个过程都被包裹在try-catch语句中,这样遇到格式错误的URL时,程序就会直接返回null并忽略它。
在实际应用中:https://www.github.com/user/repo会被处理成github.com,https://youtube.com/watch?v=123会被处理成youtube.com,而chrome://extensions则会返回null。
步骤4:颜色分配辅助功能
添加一个函数,用于为每个域名确定性地分配一种颜色:
function getColorForDomain(domain: string): chrome.tabGroups.ColorEnum {
// Chrome支持的颜色种类
const colors: chrome.tabGroups.ColorEnum[] = [
"blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"
]
// 根据域名生成一个哈希值
let hash = 0
for (let i = 0; i < domain.length; i++) {
hash = domain.charCodeAt(i) + ((hash << 5) - hash)
}
// 根据哈希值选择对应的颜色
return colors[Math.abs(hash) % colors.length]
}
Chrome支持为标签组分配八种不同的颜色。这个函数并不会随机选择颜色,而是将域名转换成一个数字,然后利用取模运算来从颜色数组中选取一个固定的索引,从而确保相同的域名总是会被分配到相同的颜色组中。
这样一来,github.com在不同会话中始终会使用相同的颜色,而不同的域名则很可能会被分配到不同的颜色组中。
完整的background.ts文件
你的完整background.ts文件应该如下所示:
export {}
console.log("标签页分组器后台脚本已加载!")
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === "GROUP_TABS") {
groupTabsByDomain()
sendResponse({ success: true })
}
return true
})
async function group TabsByDomain() {
try {
const tabs = await chrome.tabs.query({ currentWindow: true })
const domainGroups = new Map()
tabs.forEach(tab => {
if (!tab.url) return
const domain = getDomainFromUrl(tab.url)
if (!domain) return
if (!domainGroups.has(domain)) {
domainGroups.set(domain, [])
}
domainGroups.get(domain)!.push(tab)
})
for (const [domain, domainTabs] of domainGroups) {
if (domainTabs.length < 2) continue
const tabIds = domainTabs
.map(t => t.id!)
.filter(id => id !== undefined)
if (tabIds.length === 0) continue
const groupId = await chrome.tabs.group({ tabIds })
await chrome.tabGroups.updategroupId, {
title: domain,
color: getColorForDomain(domain)
})
}
console.log(`成功将${domainGroups.size}个域名进行了分组`)
} catch (error) {
console.error("在分组标签页时出现错误:", error)
}
}
function getDomainFromUrl(url: string): string | null {
try {
const urlObj = new URL(url)
if (urlObj.protocol === "chrome:" || urlObj.protocol === "chrome-extension:") {
return null
}
return urlObj.hostname.replace(/^www\./, "")
} catch {
return null
}
}
function getColorForDomain(domain: string): chrome.tabGroups.ColorEnum {
const colors: chrome.tabGroups.ColorEnum[] = [
"blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"
]
let hash = 0
for (let i = 0; i < domain.length; i++) {
hash = domain.charCodeAt(i) + ((hash << 3) - hash)
}
return colors[Math.abs(hash) % colors.length]
}
测试后台脚本
如果你的开发服务器还没有在前一节中启动,请现在运行它:
pnpm dev
为了验证后台脚本是否已正确加载,请打开chrome://extensions,找到“Tab Grouper Tutorial”,然后点击"service worker"链接。
此时会打开DevTools控制台,你会看到“标签页分组器后台脚本已加载!”的提示信息,这说明一切设置都已经完成。
构建弹出窗口界面
弹出窗口是指当用户在Chrome工具栏中点击你的扩展程序图标时出现的那个小窗口。
这个窗口可以用来显示信息、提供操作按钮,以及展示设置选项。
在这一节中,你将构建一个基于React的弹出窗口,该窗口能够实时显示标签页的相关统计信息,并触发后台脚本中的分组逻辑。
步骤1:替换popup.tsx文件
当你运行pnpm create plasmo时,系统会生成一个默认的popup.tsx文件,该文件仅用于显示欢迎信息。
打开这个文件,将其所有内容替换为以下代码框架:
import { useState, useEffect } from "react"
function IndexPopup() {
const [tabCount, setTabCount] = useState(0)
const [groupCount, setGroupCount] = useState(0)
const [isGrouping, setIsGrouping] = useState(false)
return (
标签页分组器
)
}
export default IndexPopup
保存文件后,页面会自动重新加载。
这三个状态变量分别用于记录当前打开的标签页数量、现有的分组数量,以及是否正在执行分组操作。
通过设置这个状态变量,我们可以禁用“分组标签页”按钮,并在分组操作进行时显示加载提示,从而防止用户同时触发多次分组操作。
步骤2:加载统计信息
现在需要在弹出窗口打开时加载标签页数量和分组数量的统计信息。将以下代码添加到IndexPopup函数中,放在状态变量声明之后:
// 在弹出窗口打开时加载统计信息
useEffect(() => {
loadStats()
}, []
async function loadStats() {
const tabs = await chrome.tabs.query({ currentWindow: true })
const groups = await chrome.tabGroups.query({
windowId: chrome.windows.WINDOW_ID_CURRENT
})
setTabCount(tabs.length)
setGroupCount(groups.length)
}
带有空依赖数组[]的useEffect会在组件首次挂载时执行一次,也就是说,每次弹出窗口打开时都会调用这个函数。
该函数会通过Chrome API查询当前窗口的标签页和分组信息,然后更新相应的状态变量。
步骤3:触发标签页分组操作
需要添加一个处理程序,在用户点击“分组标签页”按钮时向后台脚本发送消息:
async function handleGroupTabs() {
setIsGrouping(true)
// 向后台脚本发送消息
await chrome.runtime.sendMessage({ type: "GROUP_TABS" })
// 更新统计信息
await loadStats()
setIsGrouping(false)
}
chrome.runtime sendMessage会将类型为"GROUP TABS"的消息发送到我们在background.ts中设置的监听器。
后台脚本处理完消息后,我们会立即重新加载统计信息,以便分组数量能够及时更新,然后再重新启用“分组标签页”按钮。
步骤4:构建用户界面
将原来的占位符return语句替换为这个完整且带有样式的代码版本:
return (
{/* 页眉 */}
🗂️ 标签分组器
按域名来整理您的标签页
{/* 统计信息 */}
{/* 分组按钮 */}
{/* 页脚 */}
💡 提示: 此功能会根据网页的域名来对所有标签页进行分组。
)
用户界面由四个部分组成:顶部显示扩展程序的名称及简短描述,中间是一个统计信息框,其中并排显示当前选中的标签页数量和分组数量,主要的操作按钮在处理数据时会变为灰色,其文字也会变为“分组中…”,底部还有一个提示框。
本教程为简化说明使用了内联样式。在实际的开发环境中,你可能会选择使用CSS模块、Tailwind或styled-components等工具来设计界面。
完整的popup.tsx文件
你的完整popup.tsx文件应该如下所示:
import { useState, useEffect } from "react"
function IndexPopup() {
const [tabCount, setTabCount] = useState(0)
const [groupCount, setGroupCount] = useState(0)
const [isGrouping, setIsGrouping] = useState(false)
useEffect(() => {
loadStats()
}, [])
async function loadStats() {
const tabs = await chrome.tabs.query({ currentWindow: true })
const groups = await chrome.tabGroups.query({
windowId: chrome.windows.WINDOW_ID_CURRENT
})
setTabCount(tabs.length)
setGroupCount(groups.length)
}
async function handleGroupTabs() {
setIsGrouping(true)
await chrome.runtime.sendMessage({ type: "GROUP_TABS" })
await loadStats()
setIsGrouping(false)
}
return (
标签页分组
![如何使用 SCons 来构建软件项目——[完整手册] 如何使用 SCons 来构建软件项目——[完整手册]](http://www.cheeli.com.cn/wp-content/plugins/contextual-related-posts/default.png)

![如何在 Flutter 中使用混合组件——[完整手册] 如何在 Flutter 中使用混合组件——[完整手册]](https://cdn.hashnode.com/uploads/covers/63a47b24490dd1c9cd9c32ff/26c1c13b-8a54-4b4c-8b46-c292be780b65.png)