效率革命:为什么我选择用 Plasmo 开发浏览器插件?
摘要:浏览器插件开发曾经是一场与
manifest.json和繁琐构建流程的搏斗。直到我遇到了 Plasmo。本文将分享我使用 Plasmo 框架从零开发、调试到发布浏览器插件的完整流程与心得,带你体验“下一代”插件开发的丝滑感。
前言:插件开发的“痛点”
作为一名前端开发者,我一直想写一个浏览器插件来优化我的工作流(比如自动填充表单、提取页面数据等)。但每次打开官方文档,看到以下内容时,热情总会冷却一半:
- Manifest V3 的复杂性:权限配置、Service Worker 的生命周期、CSP 限制,配置稍错就报错。
- 构建工具缺失:原生开发没有热更新(HMR),改一行代码要手动重载插件,调试效率极低。
- 多浏览器适配:Chrome、Firefox、Edge 的 API 细微差别,需要写多套代码或复杂的兼容逻辑。
- UI 开发割裂:Popup 和 Content Script 的样式隔离,无法复用 React/Vue 组件生态。
直到我发现了 Plasmo。官方称它为 "The Browser Extension Framework",社区里则更直白地叫它 "浏览器插件界的 Next.js"。
经过一个周末的实际项目演练,我决定写下这篇博客,记录这段“真香”的开发体验。
为什么是 Plasmo?
在开始之前,先说说 Plasmo 打动我的几个核心特性:
- 🚀 极速开发体验:支持热模块替换(HMR),修改代码秒级生效,无需手动重载插件。
- 📦 开箱即用:内置 TypeScript、ESLint、Prettier,无需配置 Webpack 或 Vite。
- 🌍 跨浏览器构建:一套代码,同时构建出 Chrome、Firefox、Safari、Edge 可用的安装包。
- ⚛️ 框架无关但 React 优先:虽然支持 Svelte/Vue,但对 React 的支持最完善,可以直接使用 Shadcn、AntD 等 UI 库。
- 💾 简化的存储 API:
@plasmo/storage让跨上下文(Popup、Content、Background)的数据共享变得异常简单。
系统要求:
- Node.js 16.14.x 或更高版本
- (强烈推荐)pnpm
快速开始:5 分钟 scaffolding
1. 初始化项目
pnpm create plasmo
# OR
yarn create plasmo
# OR
npm create plasmo
按照提示输入项目名称,选择模板(默认 React + TypeScript)。
2. 目录结构解析
初始化后,目录结构非常清晰,符合前端直觉:
my-extension/
├── assets/ # 静态资源
├── popup.tsx # 插件点击图标后的弹窗页面
├── background.ts # 后台服务脚本 (Service Worker)
└── contents.ts # 内容脚本(注入到网页中)
├── package.json
└── tsconfig.json
亮点:Plasmo 采用基于文件的路由系统。
- 想写 Popup?创建
popup.tsx。 - 想写注入脚本?直接创建
contents.ts。 - 想写后台?直接创建
background.ts。 无需手动配置manifest.json中的action或content_scripts,框架会自动处理。
3. 启动开发服务器
pnpm dev
运行后,Plasmo 会自动打开一个 Chrome 窗口。此时,你在代码里保存修改,页面会自动刷新。这对于调试 Content Script 来说,简直是救星。
4. 在浏览器中调试插件
这里以谷歌浏览器为例。
1.点击拓展程序打开开发助手
2.找到你项目的build文件夹,将里面的chrome-mv3-dev文件直接拖到此页面
3.然后你的浏览器就多了你的插件,然后固定,点击打开就能看到页面了
核心功能开发实战
假设我们要开发一个 “网页刷新助手”:用户可以一键深度刷新网页,而且可以配置快捷键,可以更改插件主题色
1. 开发 Popup 界面 (src/popup.tsx)
这就是一个标准的 React 组件。
import { useState, useEffect } from "react"
import "../style/index.css"
function IndexPopup() {
const [mounted, setMounted] = useState(false)
const [iconUrl, setIconUrl] = useState("")
const [themeColor, setThemeColor] = useState("#4a90e2")
useEffect(() => {
setMounted(true)
// 默认恢复上次的主题色(如果有持久存储)
}, [])
const handleRefresh = async () => {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
if (!tab?.id) return
chrome.tabs.reload(tab.id, { bypassCache: true })
if (tab.url && tab.url.startsWith("http")) {
try {
const url = new URL(tab.url)
chrome.browsingData.remove(
{ origins: [url.origin] },
{ cache: true, cacheStorage: true, cookies: true, localStorage: true }
)
} catch (e) {}
}
window.close()
}
// 替换悬浮按钮图标
const handleReplaceIcon = async () => {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
if (tab?.id) {
chrome.tabs.sendMessage(tab.id, {
action: "replace-floating-icon",
iconUrl: iconUrl
})
}
}
// 切换主题色
const handleThemeChange = async (color: string) => {
setThemeColor(color)
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
if (tab?.id) {
chrome.tabs.sendMessage(tab.id, {
action: "change-theme",
color: color
})
}
// 动态更新 Popup 本身的样式
document.documentElement.style.setProperty('--popup-theme-color', color)
}
const openShortcuts = () => {
chrome.tabs.create({ url: "chrome://extensions/shortcuts" })
}
if (!mounted) {
return <div style={{ minWidth: "280px", minHeight: "450px" }} />
}
return (
<div className="outbody">
<div className="title" style={{ color: themeColor }}>页面辅助助手</div>
<button
className="refresh-button"
onClick={handleRefresh}
style={{ backgroundColor: themeColor }}
>
🚀 深度刷新页面
</button>
{/* 主题颜色选择器 */}
<div className="theme-section">
<span className="section-label">主题切换</span>
<div className="color-options">
{['#4a90e2', '#50c878', '#f28b82', '#fbbc04', '#9334e6'].map(color => (
<div
key={color}
className={`color-box ${themeColor === color ? 'active' : ''}`}
style={{ backgroundColor: color }}
onClick={() => handleThemeChange(color)}
/>
))}
<input
type="color"
value={themeColor}
onChange={(e) => handleThemeChange(e.target.value)}
title="自定义颜色"
/>
</div>
</div>
{/* 悬浮图标替换 */}
<div className="image-replace-section">
<span className="section-label">悬浮图标替换</span>
<input
type="text"
placeholder="输入图片/Emoji替换右下角图标"
value={iconUrl}
onChange={(e) => setIconUrl(e.target.value)}
className="url-input"
/>
<button
className="replace-btn"
onClick={handleReplaceIcon}
style={{ backgroundColor: themeColor }}
>
🖼️ 替换图标
</button>
</div>
<div className="tip">清除缓存并强制刷新当前页面</div>
<div className="shortcut-section">
<div className="shortcut-header">
<span>键盘快捷键</span>
<button
className="config-btn"
onClick={openShortcuts}
style={{ color: themeColor, borderColor: themeColor }}
>
去设置
</button>
</div>
<div className="shortcut-keys">
<kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>R</kbd>
</div>
</div>
</div>
)
}
export default IndexPopup
2. 开发内容脚本 (src/contents.ts)
Content Script 是注入到网页中的,此脚本运行在目标网页的内容上下文中,即使它没有直接操作 DOM,也是扩展与网页交互的桥梁。
/**
* Content Script (内容脚本)
*
* 此脚本运行在目标网页的内容上下文中。
* 即使它没有直接操作 DOM,也是扩展与网页交互的桥梁。
*/
export {}
// 1. 在页面上注入一个支持拖拽的悬浮按钮
const createFloatingButton = () => {
const btn = document.createElement("div")
btn.id = "page-helper-floating-btn"
// 初始图标内容
const iconSpan = document.createElement("span")
iconSpan.id = "floating-btn-icon"
iconSpan.innerText = "🚀"
btn.appendChild(iconSpan)
const style = btn.style
style.position = "fixed"
style.bottom = "20px"
style.right = "20px"
style.width = "40px"
style.height = "40px"
style.backgroundColor = "var(--theme-color, #4a90e2)"
style.color = "white"
style.borderRadius = "50%"
style.display = "flex"
style.alignItems = "center"
style.justifyContent = "center"
style.cursor = "move" // 设置为移动指针
style.zIndex = "999999"
style.boxShadow = "0 4px 12px rgba(0,0,0,0.15)"
style.transition = "transform 0.2s ease, background-color 0.3s ease"
style.fontSize = "20px"
style.userSelect = "none"
// 简单的拖拽实现
let isDragging = false
let currentX: number, currentY: number
let initialX: number, initialY: number
btn.onmousedown = (e) => {
isDragging = false // 初始设为 false
initialX = e.clientX - btn.offsetLeft
initialY = e.clientY - btn.offsetTop
const onMouseMove = (ev: MouseEvent) => {
isDragging = true // 只要移动了就判定为拖拽
currentX = ev.clientX - initialX
currentY = ev.clientY - initialY
// 限制不超出屏幕
const minX = 0, minY = 0
const maxX = window.innerWidth - btn.offsetWidth
const maxY = window.innerHeight - btn.offsetHeight
btn.style.left = Math.max(minX, Math.min(maxX, currentX)) + "px"
btn.style.top = Math.max(minY, Math.min(maxY, currentY)) + "px"
btn.style.bottom = "auto"
btn.style.right = "auto"
}
const onMouseUp = () => {
document.removeEventListener("mousemove", onMouseMove)
document.removeEventListener("mouseup", onMouseUp)
// 如果没有拖拽,则视为点击
if (!isDragging) {
chrome.runtime.sendMessage({ action: "deep-refresh-signal" })
}
}
document.addEventListener("mousemove", onMouseMove)
document.addEventListener("mouseup", onMouseUp)
}
document.body.appendChild(btn)
}
// 2. 替换悬浮图标的功能
const replaceFloatingIcon = (newImageUrl: string) => {
const iconSpan = document.getElementById("floating-btn-icon")
if (iconSpan) {
if (newImageUrl.startsWith("http") || newImageUrl.startsWith("data:")) {
iconSpan.innerHTML = `<img src="${newImageUrl}" style="width: 24px; height: 24px; border-radius: 50%; object-fit: cover;" />`
} else {
iconSpan.innerText = newImageUrl // 支持输入 Emoji
}
}
}
// 3. 切换主题色
const changeThemeColor = (color: string) => {
document.documentElement.style.setProperty('--theme-color', color)
const btn = document.getElementById("page-helper-floating-btn")
if (btn) btn.style.backgroundColor = color
}
// 监听来自 Popup 的消息
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === "replace-floating-icon") {
replaceFloatingIcon(request.iconUrl)
sendResponse({ status: "done" })
} else if (request.action === "change-theme") {
changeThemeColor(request.color)
sendResponse({ status: "done" })
}
return true
})
// 初始化
const init = () => {
if (!document.getElementById("page-helper-floating-btn")) {
createFloatingButton()
}
}
if (document.readyState === "complete" || document.readyState === "interactive") {
init()
} else {
window.addEventListener("DOMContentLoaded", init)
}
console.log("Page Helper: Content Script 启动成功,已注入悬浮按钮并准备好图片替换功能。")
3. 后台脚本
扩展的后台逻辑中心,负责监听浏览器事件、快捷键命令。
/**
* Background Script (Service Worker)
*
* 扩展的后台逻辑中心,负责监听浏览器事件、快捷键命令
* 以及执行跨页面的特权 API 操作(如清除浏览数据)。
*/
// 监听在 manifest.json 中定义的键盘命令 (commands)
chrome.commands.onCommand.addListener(async (command) => {
// 检查是否为定义的 "deep-refresh" 深度刷新指令
if (command === "deep-refresh") {
executeDeepRefresh()
}
})
// 监听来自 Content Script 的信号
chrome.runtime.onMessage.addListener((message, sender) => {
if (message.action === "deep-refresh-signal") {
executeDeepRefresh()
}
})
const executeDeepRefresh = async () => {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
if (!tab?.id) return
// 1. 优先触发刷新指令,减少用户等待感
chrome.tabs.reload(tab.id, { bypassCache: true })
// 2. 异步执行重型数据清理
if (tab.url && tab.url.startsWith("http")) {
try {
const url = new URL(tab.url)
chrome.browsingData.remove(
{ origins: [url.origin] },
{ cache: true, cacheStorage: true, cookies: true, localStorage: true }
)
} catch (e) {}
}
}
export {}
4. 权限管理
传统开发需要手动编辑 manifest.json。在 Plasmo 中,你可以直接在 package.json 中配置,或者在代码中使用特定注释。
推荐在 package.json 中配置:
{
"manifest": {
"host_permissions": [
"https://*/*",
"http://*/*"
],
"permissions": [
"browsingData",
"tabs",
"activeTab"
],
"commands": {
"deep-refresh": {
"suggested_key": {
"default": "Ctrl+Shift+R",
"mac": "Command+Shift+R"
},
"description": "深度刷新当前页面并清除缓存"
}
}
}
}
Plasmo 构建时会自动合并这些配置到最终的 manifest.json 中。
构建与发布
开发完成后,发布流程同样简单。
1. 构建
pnpm build
构建产物在 build/chrome-mv3-prod 目录下。这是一个标准的插件文件夹结构。
2. 打包
你可以手动压缩为 zip,或者使用 Plasmo 的提交命令(需配置 Store 凭证)。
# 提交到 Chrome Web Store (需要配置 .plasmo/store-credentials)
pnpm package
3. 多浏览器
Plasmo 允许你指定目标浏览器:
pnpm build --target=firefox-mv2
pnpm build --target=safari-mv3
这解决了我最头疼的兼容性问题,无需维护多套代码库。
踩坑记录与思考
虽然 Plasmo 很强大,但在开发过程中也遇到了一些需要注意的地方:
- 版本更新快:Plasmo 目前处于快速迭代期,偶尔会有 Breaking Changes。建议锁定
package.json中的版本号,升级前阅读 Changelog。 - Safari 适配:虽然支持构建,但 Safari 的扩展机制与 Chromium 系差异较大,真机调试相对麻烦,需要 Mac 设备和 Xcode。
总体评价: 对于 95% 的前端开发者来说,Plasmo 带来的开发效率提升远远大于那一点点体积开销。它让插件开发回归到了“写组件”的本质,而不是“配置清单”。
结语
浏览器插件是前端技术落地的绝佳场景,它能让你直接触达用户,解决实际问题。而 Plasmo 的出现,抹平了基础设施的门槛。
如果你一直想写一个插件却迟迟未动,不妨现在就试试:
pnpm create plasmo
相信我,当你第一次体验到修改代码、浏览器自动刷新的那一刻,你会回来感谢我的。
你的插件灵感是什么?欢迎在评论区分享!
参考链接: