之前开了一个系列记录 chrome 插件的开发过程,讲了怎么用 vite + vue3 把模板工程搭起来,顺带踩了 contentScript 样式隔离的坑,感兴趣的jym可以去翻一下 📌
这阵子模板仓库又迭代了一波,主要更新了三个点:
- 🗂️ 支持
sidepanel+options多页面 - 📨 统一的类型化消息通信
- 🚀 依赖全面升级到
vite 8
所以这篇就把这三个更新点盘一遍,老规矩单篇两千字左右 🚲,看不完就点个赞。
更新总览 ✨
| 更新点 | 解决了什么 | 核心文件 |
|---|---|---|
| 多页面支持 | popup / options / sidepanel 一个模板全搞定 | src/manifest.json、vite.config.ts |
| 类型化消息通信 | taskId 写错、参数不对,编译期就能拦住 | src/shared/message.ts |
| Vite 8 升级 | 依赖大版本刷新 + 工程化体验重构 | vite*.config.ts、eslint.config.mjs |
多页面:popup / options / sidepanel 🗂️
很多插件模板只带一个 popup,真要上 options 或侧边栏,得自己改 manifest、加打包入口、写复制脚本,坑不少。这个模板把四种页面全部内置了,src/manifest.json 里一眼可见:
json
{
"action": { "default_popup": "/popup/index.html" },
"options_ui": { "page": "/options/index.html", "open_in_tab": true },
"side_panel": { "default_path": "/sidepanel/index.html" },
"permissions": ["tabs", "contextMenus", "activeTab", "scripting", "storage", "sidePanel"]
}
四种页面各自的使用场景:
| 页面 | 入口配置 | 适合放什么 |
|---|---|---|
| popup | action.default_popup |
点图标弹窗,轻量的快捷开关 |
| options | options_ui(open_in_tab: true) |
独立标签页打开的完整设置表单 |
| sidepanel | side_panel.default_path |
常驻型工具,类似 Claude 的侧拉层 |
| contentScript | content_scripts |
注入到页面里的 UI(shadow DOM 隔离) |
三张页面共享同一份 Element Plus 自动导入和合并后的 style.css,样式能力完全一致。

sidepanel和options都可以通过在popup里进行打开

侧边栏的一个坑 🕳️
chrome.sidePanel.open() 需要真实的 windowId,但千万别直接传 chrome.windows.WINDOW_ID_CURRENT(固定值 -2)------close() 不会把它解析成真实窗口 id,会静默失败,没有任何报错,很难排查。
模板把解析逻辑封装到了 src/shared/sidePanel.ts:
javascript
export const getCurrentWindowId = async () => {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
return tab?.windowId
}
popup 里打开 / 关闭侧边栏只需两行:
csharp
await chrome.sidePanel.open({ windowId }) // 打开
await chrome.sidePanel.close({ windowId }) // 关闭
类型化消息通信 📨
插件开发里最让人头大的就是消息通信:chrome.runtime.sendMessage 传的是裸对象,taskId 写错、参数形状不对,运行时才知道,调试全靠 console.log。
模板的核心设计是把所有消息契约集中到一处 (src/shared/message.ts),用 TaskMap 声明式定义每个 taskId 的入参和出参:
css
export interface TaskMap {
/** contentScript 通过 background 读写 IndexedDB 缓存 */
'get-value-bg': { params: { keyName: string }; response: { result: any } }
'set-value-bg': { params: { keyName: string; value: any }; response: { result: any } }
'del-value-bg': { params: { keyName: string }; response: { result: any } }
/** popup / options 通过 background 读写 chrome.storage.sync 设置 */
'get-setting': { params: { key: string }; response: { result: any } }
'set-setting': { params: { key: string; value: any }; response: { result: any } }
/** background 通过 contextMenus 点击向当前标签页 contentScript 推送 */
'ping-content': { params: void; response: { url: string; title: string; injectedAt: number } }
}
新增一个消息 = 往 TaskMap 加一行,发起端和接收端的类型同时被推导出来,编译期就能拦住拼错 taskId 或传错参数。三个方向一致的工具函数:
csharp
// contentScript / popup / options -> background
sendMessage('get-setting', { key: 'nickname' })
// background -> 指定标签页的 contentScript
sendMessageToTab(tabId, 'ping-content', undefined)
// 注册处理器;callback 返回 Promise 时自动保持通道开启
onMessage('get-setting', async (params) => ({ result: data[params.key] }))

又一个坑:异步 sendResponse 🕳️
MV3 里最容易踩的坑------异步 sendResponse 必须 return true 保持通道开启 ,否则回调里的 Promise 还没 resolve,消息通道就关了,接收端拿到 undefined 一脸懵。这个细节 onMessage 已经封装好了,你不用再记。
围绕这套通信,background 里挂了三组现成的示例处理器:
| 模块 | taskId | 背后存储 |
|---|---|---|
src/background/db.ts |
get-value-bg / set-value-bg / del-value-bg |
IndexedDB(idb 封装,适合大体积缓存) |
src/background/settings.ts |
get-setting / set-setting |
chrome.storage.sync(跨设备同步的偏好设置) |
src/background/contextMenu.ts |
ping-content |
右键菜单 + 图标角标示例 |
其中 contextMenu.ts 是个完整的「background → contentScript」闭环:右键点菜单 → 计数写入 storage.sync → 更新图标角标 → 用 sendMessageToTab 把 ping-content 推给当前页面的 contentScript 并打印响应。新手照抄就能理解整个通信链路。
Vite 8 升级 🚀
这次更新把基础依赖从老版本一口气拉到了当前主版本:
| 依赖 | 升级前 | 升级后 |
|---|---|---|
| vite | 3.x | 8.x |
| vue | 3.2 | 3.5 |
| element-plus | 2.2 | 2.14 |
| typescript | 4.8 | 6.x |
| eslint | 旧版 | 10(flat config) |
同时做了一轮工程化重构:
1. 去掉停更插件,改内联插件
移除了停止维护的 vite-plugin-eslint / vite-plugin-replace,ESLint 改走官方 flat config (eslint.config.mjs),新增 pnpm lint / pnpm typecheck 脚本,配合 husky + lint-staged 在提交前卡代码质量。
2. 全链路 ESM
package.json 设了 "type": "module",scripts/monitor.js 和热更新脚本全部迁移为 ESM,Vite 配置用 import.meta.dirname 定位路径、用 .ts 后缀直接导入配置模块(Vite 8 原生配置加载器的要求,别手痒删掉)。
3. 修了 contentScript 样式 404 的老大难 🕳️
contentScript 跑在页面里,样式文件不能内联,只能通过 chrome.runtime.getURL() 外链加载,还必须在 web_accessible_resources 里声明。之前构建产物一旦没有 CSS 就生成不了 style.css,页面就 404。
修复方式是在 vite.content.config.ts 里加了一个内联插件 vite-content-script-css:打包收尾时保证 contentScript/style.css 永远被输出 (没有 CSS 就生成空文件),顺手把 :root 重写成 :host,让样式正确作用到 shadow DOM 上------也就是第一篇里那个 :root 变量坑的自动化版:
bash
if (styleCss) {
styleCss.source = styleCss.source.replace(/:root(?=\s*{)/g, ':host')
} else {
this.emitFile({ type: 'asset', fileName: 'style.css', source: '' })
}
4. Chrome API 统一 Promise 风格
所有 Chrome API 调用改为 Promise 风格,不再用回调地狱;manifest 的 background 增加 "type": "module",contentScript 用 IIFE 格式打包避免全局污染。
5. 双构建配置 + 可靠的热更新
两个 Vite 配置并行跑:vite.config.ts 产出 background / popup / options / sidepanel(ES 格式),vite.content.config.ts 单独产出 contentScript(IIFE 格式,子目录输出)。开发时 WebSocket 监听在 8801 端口:background 变了自动 chrome.runtime.reload(),contentScript 变了只重注入当前活动标签页,不用手动刷新页面。
体验对比 📊
| 场景 | 传统模板 | 本模板 |
|---|---|---|
| 新增一个 taskId | 手写两端消息代码 + 靠记忆对齐类型 | TaskMap 加一行,两端自动推导 |
| 想加 options / sidepanel | 手改 manifest + 打包配置 + 拷贝脚本 | 入口声明式注册,模板自动处理 |
| 异步 sendResponse | 容易忘 return true 导致通道提前关闭 |
onMessage 封装好了 |
| contentScript 样式 | 构建缺文件就 404 | 内联插件保证 style.css 永远存在 |
| 依赖升级 | 大版本不兼容警告满天飞 | 模板已踩完坑,直接开箱即用 |
快速上手 🚀
bash
pnpm i
pnpm dev # 产物输出到 local/,chrome://extensions 加载 unpacked 即可热更新
pnpm build # 产物输出到 extension/,可直接发布
pnpm test # vitest,含消息通信、IndexedDB 封装的单元测试
代码层面需要关注的入口就几个:src/shared/message.ts(消息契约)、src/manifest.json(权限与入口)、vite.config.ts + vite.content.config.ts(打包入口)。
结语
这次更新的核心思想是**「把插件工程化的脏活累活提前封装好」**:多页面开箱即用、消息通信类型安全、构建与热更新不再有玄学 bug。你只需要专注于插件本身的业务逻辑。
模板仓库也会跟着这个系列持续迭代,如果你正要开始一个新的 Chrome 插件项目,或者想把手头那个还在手写消息通信的老项目迁移过来,这个模板值得一试: