浏览器插件-技术栈与脚手架
浏览器插件本质是「一组特殊上下文(context)的网页 + 一份清单文件 manifest.json」。manifest.json 把多个入口(popup 弹窗、options 选项页、side panel 侧边栏、content script 内容脚本、background/service worker 后台)声明给浏览器。普通前端构建工具默认面向「一个或多个 HTML 页面」,并不理解 manifest.json 里这些特殊入口与权限声明,也不会处理 content script 在目标页面的 CSS 注入、HMR(Hot Module Repla
[!info] 文档信息
- 主题:浏览器插件(Browser Extension)开发的构建工具/框架选型与脚手架对比
- 官方文档:
- CRXJS:https://crxjs.dev/ · 仓库 https://github.com/crxjs/chrome-extension-tools
- WXT:https://wxt.dev/ · 仓库 https://github.com/wxt-dev/wxt
- Plasmo:https://docs.plasmo.com/ · 仓库 https://github.com/PlasmoHQ/plasmo
- vite-plugin-web-extension(aklinker1):https://vite-plugin-web-extension.aklinker1.io/
- Extension.js:https://extension.js.org/
- 核实日期:2026-06-06(版本号来自 npm registry 实查)
- 适用版本与最新版本号:
@crxjs/vite-plugin2.4.0(latest),beta标签停留在2.0.0-beta.33(见下文“CRXJS 版本与维护状态”陷阱)wxt0.20.26(活跃维护,约每 1~2 周发版)plasmo0.90.5(约一年未更新,社区视为维护停滞/低活跃)extension(Extension.js)3.18.2@samrum/vite-plugin-web-extension5.1.1(约一年未更新)vite-plugin-web-extension(aklinker1)4.5.1(作者已宣布以 WXT 为后继,逐步弃用)webextension-polyfill0.12.0,@webext-core/messaging3.0.2- 配套:Vite 5/6/7、Vue 3.5+、TypeScript 5.x、Manifest V3(MV3)
概述
浏览器插件本质是「一组特殊上下文(context)的网页 + 一份清单文件 manifest.json」。manifest.json 把多个入口(popup 弹窗、options 选项页、side panel 侧边栏、content script 内容脚本、background/service worker 后台)声明给浏览器。普通前端构建工具默认面向「一个或多个 HTML 页面」,并不理解 manifest.json 里这些特殊入口与权限声明,也不会处理 content script 在目标页面的 CSS 注入、HMR(Hot Module Replacement,热模块替换)跨上下文同步等问题。插件脚手架/框架的核心价值,就是把 manifest.json 的生成、多入口编排、跨浏览器适配、开发期热重载这四件事自动化。
What(是什么):本文横向对比 6 种主流方案——CRXJS、WXT、Plasmo、vite-plugin-web-extension、Extension.js、以及「原生 Vite 多页 + 手写 manifest」,并给出 Vite + Vue 技术栈下 CRXJS 与 WXT 两套完整脚手架。
Why(为什么需要选型):方案之间在「HMR 质量、多浏览器支持、manifest 管理方式、框架支持、学习曲线、维护活跃度」六个维度差异巨大,且部分老牌方案(Plasmo、CRXJS、aklinker1 的 vite-plugin-web-extension)正处于维护放缓或转向后继项目的状态。选错会导致项目中途被迫迁移。
When(何时用哪个):
- 新项目、想要零配置全家桶、需要多浏览器(Chrome/Firefox/Edge/Safari)一套代码 → 首选 WXT。
- 已有重度 Vite + Vue 工程、想保留对
vite.config的完全掌控、只补一个插件 → CRXJS(但需接受其维护风险)。 - 团队主用 React、能接受 Parcel 生态 → Plasmo(仅维护已有项目,新项目慎选)。
- 只想要极简零配置、不挑框架的实验性项目 → Extension.js(较新,生态尚浅)。
- 学习底层原理、或插件极其简单(仅一个 background) → 原生 Vite 多页 + 手写 manifest。
一、构建工具/框架横向对比
1.1 总览对比表
| 方案 | 底层 | manifest 管理 | HMR 质量 | 多浏览器 | 框架支持 | 学习曲线 | 维护活跃度(2026-06) | 适用场景 |
|---|---|---|---|---|---|---|---|---|
| WXT | Vite | 文件约定自动生成 + wxt.config |
极佳(UI 即时 HMR,脚本快速重载) | Chrome/FF/Edge/Safari/全 Chromium | 任意(官方模块:Vue/React/Svelte/Solid) | 低(约定优于配置) | 高,发版频繁 | 新项目零配置首选、多浏览器 |
| CRXJS | Vite | manifest.config.ts(TS 编写) |
极佳(原生 HMR,content script 也热重载) | Chrome 为主,FF 实验性 | 任意(沿用 Vite 插件) | 中(需懂 Vite 多入口) | 中/不稳定,曾寻找新维护者 | 已有 Vite 工程、要掌控 config |
| Plasmo | Parcel | 文件约定 + package.json 字段 |
良好 | Chrome/FF/Edge/Safari | React 为主,Vue/Svelte 次之 | 中 | 低,约一年未更新 | 维护已有 React 插件 |
| vite-plugin-web-extension(aklinker1) | Vite | 指向 manifest.json(可 .js) |
良好 | Chrome/FF | 任意 | 中 | 转维护,作者推荐迁 WXT | 旧项目,新项目改用 WXT |
| @samrum/vite-plugin-web-extension | Vite | 传入 manifest 对象 | 无内置 HMR(需手动重载) | Chrome/FF | 任意 | 中 | 低,约一年未更新 | 不推荐新项目 |
| Extension.js | 自带(基于 Rspack/webpack 思路,零插件) | 文件约定 + 自动适配 | 良好(background/content/popup HMR) | Chrome/Edge/FF | TS/React/Vue/Svelte/Preact | 低(零配置) | 中(较新,迭代快) | 极简零配置、实验项目 |
| 原生 Vite 多页 + 手写 manifest | Vite | 完全手写 manifest.json |
差(content script/background 需手动 reload) | 自行处理 | 任意 | 高(全部自管) | 取决于自己 | 学习原理、极简插件 |
1.2 同名生态辨析:两个 vite-plugin-web-extension
这是新手最易混淆的点,两个包名相似但归属不同:
| 维度 | @samrum/vite-plugin-web-extension |
vite-plugin-web-extension(aklinker1) |
|---|---|---|
| 作者 | samrum | aklinker1(WXT 作者本人) |
| npm 包名 | @samrum/vite-plugin-web-extension(带 scope) |
vite-plugin-web-extension(无 scope) |
| 配置方式 | 在 vite.config 传入完整 manifest 对象 |
指向一份 manifest.json/.js,自动多浏览器构建 |
| 开发体验 | 无内置 HMR,改完手动 reload | 自动开独立浏览器 profile,集成 web-ext 运行 |
| 当前定位 | 维护放缓(最新 5.1.1,约一年前) | 作者已声明 WXT 是其后继,逐步弃用 |
| 结论 | 不建议新项目 | 旧项目可用,新项目直接上 WXT |
[!warning] aklinker1 同时是 WXT 与
vite-plugin-web-extension的作者
WXT 已支持vite-plugin-web-extension的全部能力且更流行、功能更多。aklinker1 官方建议新项目跳过该插件,直接用 WXT。
1.3 CRXJS 版本与维护状态(重点核实)
CRXJS 是 Vite + Vue 生态里历史最久、HMR 体验最好的插件,但其维护状态需要谨慎对待:
- npm
latest标签为 2.4.0,但beta标签停留在 2.0.0-beta.33。历史上 CRXJS 长期只发2.0.0-beta.x,很多旧教程让你npm i @crxjs/vite-plugin@beta——今天@beta反而比@latest旧,应直接装@latest(2.4.0)。 - 项目曾公开「寻找新维护者」:原作者一度宣布若无新维护团队接手将归档,后由社区志愿者组成维护团队接手协调,目前仍在持续发版,但维护带宽不及 WXT 充裕。
- 决策含义:CRXJS 适合「已经重度依赖自定义
vite.config、迁移成本高」的存量项目;纯新项目若不强需要对 config 的完全掌控,WXT 更稳妥。
1.4 选型决策树
开始:要写一个浏览器插件
│
├─ 主用框架是 React 且只维护已有 Plasmo 项目?
│ 是 → Plasmo(不为新项目引入)
│ 否 ↓
│
├─ 需要一套代码同时上 Chrome + Firefox + Edge + Safari?
│ 是 → WXT(多浏览器构建最省心)
│ 否 ↓
│
├─ 已有成熟的 Vite + Vue 工程,且离不开自定义 vite.config?
│ 是 → CRXJS(接受其维护风险,装 @latest 而非 @beta)
│ 否 ↓
│
├─ 想要约定优于配置、零样板、官方 Vue 模块开箱即用?
│ 是 → WXT(新项目通用首选)
│ 否 ↓
│
├─ 插件极简(只有一个 background,无 UI),或想学底层原理?
│ 是 → 原生 Vite 多页 + 手写 manifest
│ 否 ↓
│
└─ 想试零配置新工具、不挑框架 → Extension.js
[!tip] 一句话建议
绝大多数 Vite + Vue 新项目选 WXT。 强依赖既有vite.config的存量 Vue 工程选 CRXJS(装@latest)。其余皆为特定场景。
二、各脚手架快速创建命令与目录结构
2.1 WXT
# 交互式创建(推荐),会询问框架(选 vue)与包管理器
npx wxt@latest init my-extension
cd my-extension
npm install
npm run dev # 默认 Chrome;--browser firefox 切换
WXT 生成的目录(约定式 entrypoints/):
my-extension/
├── entrypoints/ # 入口约定目录,文件名/目录名即入口类型
│ ├── background.ts # 后台 service worker(defineBackground)
│ ├── content.ts # 内容脚本(defineContentScript)
│ ├── popup/ # 弹窗(含 index.html + main.ts + App.vue)
│ └── options/ # 选项页
├── components/ # Vue 组件
├── composables/ # Vue 组合式函数
├── assets/ # 经构建处理的资源
├── public/ # 原样拷贝的静态资源(图标等)
├── wxt.config.ts # 唯一配置文件
├── package.json
└── tsconfig.json
2.2 CRXJS(结合官方 Vite Vue 模板)
# 先用官方模板拉一个 Vite + Vue + TS 工程,再加 CRXJS 插件
npm create vite@latest my-extension -- --template vue-ts
cd my-extension
npm install
npm install -D @crxjs/vite-plugin@latest # 注意装 @latest,不是 @beta
CRXJS 推荐目录(手动组织,下文第三节给完整配置):
my-extension/
├── src/
│ ├── popup/ ├── options/ ├── sidepanel/
│ ├── content/ └── background/
├── manifest.config.ts # 用 TS 定义 manifest(类型安全)
├── vite.config.ts
└── package.json
2.3 Plasmo
npm create plasmo my-extension # React + TS 默认
# 选 Vue:使用社区 with-vue 示例模板
npm create plasmo --with-vue
cd my-extension
npm run dev
my-extension/
├── popup.tsx # 文件名即入口:popup/options/newtab...
├── options.tsx
├── background.ts
├── contents/ # 内容脚本目录
├── assets/
└── package.json # manifest 字段写在这里
2.4 Extension.js
npx extension@latest create my-extension --template=vue
cd my-extension
npm run dev # 启动并自动打开浏览器加载
my-extension/
├── manifest.json # 手写但有自动浏览器适配
├── background.js
├── content/ ├── popup/ └── pages/
└── extension.config.js # 可选,配置浏览器/启动命令
2.5 vite-plugin-web-extension(aklinker1)
npm create vite-plugin-web-extension@latest my-extension
cd my-extension && npm install && npm run dev
三、Vite + Vue + CRXJS 完整脚手架
3.1 manifest.config.ts(类型安全的 manifest)
// manifest.config.ts
import { defineManifest } from '@crxjs/vite-plugin'
import pkg from './package.json'
// defineManifest 提供完整 MV3 类型提示,避免手写 JSON 拼错字段
export default defineManifest({
manifest_version: 3,
name: pkg.name,
version: pkg.version, // 与 package.json 同步,发版只改一处
description: pkg.description,
icons: {
16: 'public/icons/16.png',
48: 'public/icons/48.png',
128: 'public/icons/128.png',
},
action: {
default_popup: 'src/popup/index.html', // 弹窗入口
default_icon: 'public/icons/48.png',
},
options_page: 'src/options/index.html', // 选项页入口
side_panel: {
default_path: 'src/sidepanel/index.html', // 侧边栏入口(Chrome 114+)
},
background: {
service_worker: 'src/background/index.ts', // 后台 service worker
type: 'module', // MV3 推荐 ES module 形式
},
content_scripts: [
{
matches: ['<all_urls>'], // 注入的页面匹配规则
js: ['src/content/index.ts'], // 内容脚本入口
},
],
permissions: ['storage', 'sidePanel', 'activeTab'],
// host_permissions 用于跨域请求目标站点
host_permissions: ['https://*/*'],
})
3.2 vite.config.ts
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { crx } from '@crxjs/vite-plugin'
import manifest from './manifest.config'
export default defineConfig({
plugins: [
vue(),
crx({ manifest }), // 传入上面定义的 manifest
],
server: {
// CRXJS 的 HMR 依赖固定端口;端口冲突会导致 content script 无法热重载
port: 5173,
strictPort: true, // 端口被占用时直接报错,避免随机换端口破坏 HMR
hmr: {
port: 5173, // 显式锁定 HMR 端口(见“常见陷阱”)
},
},
})
3.3 多入口目录组织与示例
src/
├── popup/
│ ├── index.html # <script type="module" src="./main.ts">
│ ├── main.ts # createApp(App).mount('#app')
│ └── App.vue
├── options/
│ ├── index.html ├── main.ts └── App.vue
├── sidepanel/
│ ├── index.html ├── main.ts └── App.vue
├── content/
│ └── index.ts # 注入目标页面的逻辑
└── background/
└── index.ts # service worker 逻辑
<!-- src/popup/index.html -->
<!doctype html>
<html lang="zh-CN">
<head><meta charset="UTF-8" /><title>Popup</title></head>
<body>
<div id="app"></div>
<script type="module" src="./main.ts"></script>
</body>
</html>
// src/popup/main.ts
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
// src/background/index.ts
// service worker 中监听插件安装事件
chrome.runtime.onInstalled.addListener(() => {
console.log('extension installed')
})
// 点击 action 图标时打开 side panel(需 sidePanel 权限)
chrome.sidePanel
.setPanelBehavior({ openPanelOnActionClick: true })
.catch((err) => console.error(err))
四、Vite + Vue + WXT 搭建对比
WXT 把上一节里 CRXJS 需要手写的 manifest.config.ts、多入口 HTML、HMR 端口配置全部用「文件约定 + 包装函数」替代。
4.1 wxt.config.ts
// wxt.config.ts
import { defineConfig } from 'wxt'
export default defineConfig({
modules: ['@wxt-dev/module-vue'], // 官方 Vue 模块,自动接好 @vitejs/plugin-vue
manifest: {
// 这里只写「文件约定无法表达」的字段;入口由 entrypoints/ 自动推导
name: 'My Extension',
permissions: ['storage', 'sidePanel', 'activeTab'],
host_permissions: ['https://*/*'],
},
// 多浏览器:构建时用 --browser firefox 切换,manifest 自动适配 MV2/MV3 差异
})
4.2 entrypoints 约定 + defineBackground/defineContentScript
// entrypoints/background.ts
// defineBackground 包裹后台逻辑,WXT 据此生成 manifest.background 字段
export default defineBackground(() => {
chrome.runtime.onInstalled.addListener(() => {
console.log('installed')
})
})
// entrypoints/content.ts
// defineContentScript 同时声明注入规则与逻辑,无需再到 manifest 手写 content_scripts
export default defineContentScript({
matches: ['<all_urls>'], // 注入页面匹配
main() {
console.log('content script injected')
},
})
entrypoints/
├── popup/
│ ├── index.html ├── main.ts └── App.vue # 名为 popup → 自动设为 action.default_popup
├── options/
│ ├── index.html ├── main.ts └── App.vue # 名为 options → 自动设为 options_page
├── background.ts # 自动 → background.service_worker
└── content.ts # 自动 → content_scripts[]
4.3 CRXJS vs WXT 心智差异
| 维度 | CRXJS | WXT |
|---|---|---|
| manifest | 手写 manifest.config.ts 全量字段 |
入口字段自动推导,只补权限等 |
| 入口注册 | manifest 里逐个指向 HTML/TS | entrypoints/ 目录约定 |
| 后台/内容脚本 | 普通 TS 文件 + manifest 声明 | defineBackground / defineContentScript 自描述 |
| 多浏览器 | 主要 Chrome,FF 需自处理 | --browser 一键切换,自动适配 MV2/MV3 |
| 配置自由度 | 高(直接改 vite.config) |
中(通过 vite() 钩子扩展) |
五、配套工具链
5.1 TypeScript 与 chrome 类型
npm i -D typescript @types/chrome
| 包 | 作用 |
|---|---|
@types/chrome |
chrome.* API 的 TS 类型(WXT/CRXJS 模板已内置) |
@types/webextension-polyfill |
browser.* 跨浏览器 API 类型 |
5.2 跨浏览器:webextension-polyfill
Chrome 用回调式 chrome.*,Firefox 用 Promise 式 browser.*。webextension-polyfill 把 chrome.* 统一成返回 Promise 的 browser.*,便于 async/await:
npm i webextension-polyfill
npm i -D @types/webextension-polyfill
import browser from 'webextension-polyfill'
// 统一用 Promise 风格,Chrome/Firefox 一致
const { token } = await browser.storage.local.get('token')
[!note] WXT 内置
@wxt-dev/browser
WXT 用户无需手动装 polyfill,直接import { browser } from 'wxt/browser'即可获得统一的browser对象与类型。
5.3 ESLint
npm i -D eslint
为插件项目额外加 webextensions 全局环境,避免 chrome/browser 被报未定义:
// eslint.config.js(flat config)
import globals from 'globals'
export default [
{
languageOptions: {
globals: {
...globals.browser,
...globals.webextensions, // 提供 chrome / browser 全局
},
},
},
]
5.4 UnoCSS / Tailwind 在插件里
UI 上下文(popup/options/sidepanel)与普通 Vue 页面用法完全一致。唯一要注意 content script:content script 注入到目标页面,若直接用全局 Tailwind/UnoCSS,preflight(基础样式重置)会污染宿主页面,且宿主页面样式也可能反污染你的 UI。
// 推荐:content script 的 UI 用 Shadow DOM 隔离(WXT 提供 createShadowRootUi)
export default defineContentScript({
matches: ['<all_urls>'],
cssInjectionMode: 'ui', // 让 CSS 注入到 Shadow DOM,而非宿主页面
async main(ctx) {
const ui = await createShadowRootUi(ctx, {
name: 'my-ui',
position: 'inline',
onMount(container) {
// 在隔离的 shadow root 内挂载 Vue 应用
},
})
ui.mount()
},
})
CRXJS 下没有内置 Shadow DOM 辅助,需自行创建 shadow root 并把构建产物 CSS 字符串注入其中。
5.5 组件库(Element Plus / PrimeVue)在 popup 的注意点
- 尺寸:popup 宽度受限(通常 ≤ 800px,高度 ≤ 600px),重型组件库默认样式可能溢出,建议固定
body { width: 360px }。 - Teleport/全局挂载:Element Plus 的
ElMessage、ElDialog默认挂到document.body。在 content script 的 Shadow DOM 场景下,需把appendTo指向 shadow root,否则弹层渲染到宿主页面外、样式丢失。 - 按需引入:用
unplugin-vue-components+ 对应 Resolver 自动按需,减小 popup 体积(影响首屏打开速度)。 - CSP:MV3 默认 CSP 禁止 inline script 与
eval。组件库若依赖运行时编译模板会被拦截,应使用预编译(SFC<template>编译,而非运行时template选项)。
六、状态管理:Pinia + storage 持久化
popup 每次关闭即销毁、重开重建,内存状态不持久;多个上下文(popup/options/background)各自独立运行,内存不共享。结论:Pinia 负责单个上下文内的响应式状态,chrome.storage/browser.storage 负责跨上下文与持久化。
// stores/settings.ts
import { defineStore } from 'pinia'
import browser from 'webextension-polyfill'
export const useSettings = defineStore('settings', {
state: () => ({ theme: 'light' as 'light' | 'dark' }),
actions: {
// 初始化时从 storage 读回(popup 重开后恢复)
async load() {
const { theme } = await browser.storage.local.get('theme')
if (theme) this.theme = theme as 'light' | 'dark'
},
// 变更时写入 storage,并通知其他上下文
async setTheme(theme: 'light' | 'dark') {
this.theme = theme
await browser.storage.local.set({ theme })
},
},
})
// 监听 storage 变化,让 options 修改后 popup 自动同步
browser.storage.onChanged.addListener((changes, area) => {
if (area === 'local' && changes.theme) {
useSettings().theme = changes.theme.newValue
}
})
[!tip]
@webext-core/storage简化持久化
@webext-core/storage提供带类型、带默认值、可监听的 storage 封装(defineItem),比裸storage.get/set更安全,可替代手写同步逻辑。
七、测试工具
| 工具 | 层级 | 用途 |
|---|---|---|
vitest |
单元/组件 | 测纯逻辑、Pinia store、Vue 组件 |
@webext-core/fake-browser |
单元 | 在 vitest 里 mock browser.* API |
playwright |
E2E | 启动真实 Chromium 并加载插件,测端到端流程 |
puppeteer |
E2E | 同上,老牌方案,加载未打包插件 |
7.1 vitest mock 浏览器 API
// settings.test.ts
import { fakeBrowser } from '@webext-core/fake-browser'
import { beforeEach, expect, it } from 'vitest'
beforeEach(() => fakeBrowser.reset()) // 每个用例重置内存版 storage
it('persists theme', async () => {
await fakeBrowser.storage.local.set({ theme: 'dark' })
const { theme } = await fakeBrowser.storage.local.get('theme')
expect(theme).toBe('dark')
})
7.2 Playwright 加载已构建插件做 E2E
// e2e/popup.spec.ts
import { test, chromium, expect } from '@playwright/test'
import path from 'node:path'
test('popup renders', async () => {
const pathToExtension = path.resolve('dist') // 先 npm run build 产出 dist
// MV3 service worker 需用 persistent context + 加载插件参数
const context = await chromium.launchPersistentContext('', {
headless: false,
args: [
`--disable-extensions-except=${pathToExtension}`,
`--load-extension=${pathToExtension}`,
],
})
// 从 service worker 拿到扩展 ID,再打开 popup 页面断言
const [sw] = context.serviceWorkers()
const extId = sw.url().split('/')[2]
const page = await context.newPage()
await page.goto(`chrome-extension://${extId}/popup/index.html`)
await expect(page.locator('#app')).toBeVisible()
await context.close()
})
八、打包发布工具
| 工具 | 作用 |
|---|---|
web-ext(Mozilla 官方) |
Firefox 下 run(带热重载运行)、lint、sign(签名)、build(打 zip) |
wxt zip |
WXT 内置,一键产出各浏览器商店所需 zip(含 Firefox 所需源码包) |
zip 产物 |
Chrome Web Store / Edge Addons 上传需要 zip 包 |
| CI(GitHub Actions) | 自动构建 + zip + 调用商店 API 上架 |
8.1 web-ext 常用命令
npm i -D web-ext
web-ext run --source-dir ./dist # 启动 Firefox 加载插件并热重载
web-ext lint --source-dir ./dist # 上架前静态校验 manifest/权限
web-ext build --source-dir ./dist # 产出 web-ext-artifacts/*.zip
web-ext sign --source-dir ./dist \
--api-key=$AMO_KEY --api-secret=$AMO_SECRET # AMO 签名(自托管 .xpi)
8.2 GitHub Actions 自动上架(WXT 示例)
# .github/workflows/release.yml
name: Release Extension
on:
push:
tags: ['v*']
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npm run zip # WXT:产出 chrome zip
- run: npm run zip:firefox # WXT:产出 firefox zip + 源码包
# 用 publish-browser-extension / chrome-webstore-upload 等 action 上传
- run: npx wxt submit \
--chrome-zip .output/*-chrome.zip \
--firefox-zip .output/*-firefox.zip \
--firefox-sources-zip .output/*-sources.zip
env:
CHROME_EXTENSION_ID: ${{ secrets.CHROME_EXTENSION_ID }}
CHROME_CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }}
CHROME_CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }}
CHROME_REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }}
九、最佳实践
-
新项目默认 WXT,存量 Vue+Vite 工程才用 CRXJS。 WXT 把 manifest、入口、HMR、多浏览器全部约定化,样板最少;CRXJS 的价值仅在「你已重度依赖自定义
vite.config」。npx wxt@latest init my-ext # 新项目一行起步 -
版本号集中到
package.json,manifest 引用它。 发版只改一处,避免 manifest 与 package.json 版本不一致被商店拒绝。import pkg from './package.json' export default defineManifest({ version: pkg.version /* ... */ }) -
content script 的 UI 一律用 Shadow DOM 隔离。 防止宿主页面 CSS 与你的样式互相污染。WXT 用
cssInjectionMode: 'ui'+createShadowRootUi;CRXJS 手动建 shadow root 注入 CSS 字符串。 -
跨浏览器统一用
browser.*(polyfill 或wxt/browser),全程async/await。 不要混用chrome.*回调与browser.*Promise,否则 Firefox 上行为不一致。 -
CI 用
web-ext lint在上架前卡关。 把 manifest 字段错误、过度权限在合并前就拦下,避免商店审核打回。web-ext lint --source-dir ./.output/chrome-mv3 -
状态分层:Pinia 管内存响应式,storage 管持久化与跨上下文,并监听
storage.onChanged做同步。 popup 关闭即销毁,纯内存状态会丢。 -
遵循最小权限原则。
permissions与host_permissions只声明确实用到的;<all_urls>会显著拉长商店审核时间,能用activeTab就别要广域 host 权限。
十、常见陷阱
[!warning] 陷阱 1:CRXJS 装错 dist-tag(
@beta反而更旧)
现象:照旧教程npm i @crxjs/vite-plugin@beta装到2.0.0-beta.33,新版 Vite 7 下报错或 HMR 失效。
原因:CRXJS 历史上长期只发2.0.0-beta.x,beta标签从未更新;而latest已是 2.4.0。@beta现在指向的是一个比@latest旧的版本。
解决:明确装npm i -D @crxjs/vite-plugin@latest(或锁定^2.4.0),不要再用@beta。
[!warning] 陷阱 2:CRXJS HMR 端口冲突导致 content script 不热重载
现象:popup 能 HMR,但改 content script 后注入页面不更新,控制台报 WebSocket 连接失败。
原因:CRXJS 的 HMR 依赖一个固定端口与目标页面建立连接。若该端口被占用或随每次启动变化(strictPort: false),content script 侧连不上 HMR server。
解决:在vite.config锁定server.port+server.strictPort: true+server.hmr.port,确保端口稳定;端口被占用时换一个固定值而非让 Vite 随机选。
[!warning] 陷阱 3:content script 的 CSS 污染宿主页面 / 被宿主页面污染
现象:插件注入的按钮样式在某些网站完全错乱,或注入后目标网站自身排版被你的 reset 样式破坏。
原因:content script 默认把 CSS 注入宿主页面全局作用域,与宿主页面样式互相覆盖;Tailwind/UnoCSS 的 preflight reset 影响尤其大。
解决:用 Shadow DOM 承载注入 UI(WXTcssInjectionMode: 'ui'),让样式封闭在 shadow root;组件库弹层把appendTo指向 shadow root,避免渲染到外部丢样式。
[!warning] 陷阱 4:manifest 里的资源路径写成构建后路径
现象:开发正常,构建后图标/content script 404,或web_accessible_resources拿不到文件。
原因:CRXJS/WXT 期望你在 manifest 里写源码路径(如src/popup/index.html、public/icons/48.png),插件负责转换为产物路径;若手写成dist/...或assets/xxx.js这类构建产物路径,映射就会断。
解决:manifest 中一律写源码相对路径,让脚手架做路径改写;静态图标放public/并以public/...引用。
[!warning] 陷阱 5:MV3 CSP 拦截运行时模板编译 / inline 脚本
现象:popup 白屏,控制台报Refused to evaluate ... unsafe-eval或 CSP 违规。
原因:MV3 默认 CSP 禁止eval与 inline script。Vue 运行时编译(runtime-compiler)、某些组件库的动态模板会触发。
解决:使用 SFC 预编译(默认的vue构建即是 runtime-only),不要在代码里传字符串template选项;第三方库若内含eval需替换或寻找 MV3 兼容版本。