从零到落地:用 Vue3 + Vite 打造 Manifest V3 浏览器插件(电商采买助手实战)
从零到落地:用 Vue3 + Vite 打造 Manifest V3 浏览器插件(电商采买助手实战)
本文基于一套 Chrome 扩展工程实践整理而成,聚焦技术架构、工程化思路与关键代码模式,适合希望用现代化前端栈开发 MV3 插件、并在第三方页面上做「页面增强 + 业务协同」的同学。
一、写在前面:为什么还要做浏览器插件?
浏览器插件(Browser Extension)是「网页」与「本地业务系统」之间最自然的桥梁之一。尤其在电商采买、选品、比价、数据采集这类场景里,业务同学每天都在第三方平台页面上操作:看商品、翻页、勾选、对比、再手工录入到内部 ERP。流程重复、易错、效率低。
如果能把「识别商品 → 判断是否已存在 → 批量采集 → 同款比价 → 关键词选品」嵌进页面本身,就能把人工链路压缩成几次点击。这正是这类插件的价值:
- 就地增强:不打断用户当前浏览路径,在目标站点页面上直接提供操作面板。
- 跨上下文协同:把第三方页面 DOM、页面内接口数据、企业内部 API 串成一条流水线。
- 可分发、可更新:通过扩展机制统一版本,比脚本油猴更规范、权限更清晰。
本文将以「电商采买助手」为业务壳,系统讲解一套 Vue3 + TypeScript + Vite + @crxjs/vite-plugin + Manifest V3 的工程化方案,覆盖:
- MV3 架构与各入口职责
@crxjs/vite-plugin工程化配置- Content Script 注入 Vue 面板
web_accessible_resources+ 页面主世界脚本拦截 XHR/Fetch- Background Service Worker 消息中枢与登录态同步
- 请求封装、鉴权、环境切换
- 打包、本地调试与发布注意事项
- 安全、性能与可维护性实践
二、业务场景
2.1 目标用户与核心诉求
| 角色 | 诉求 |
|---|---|
| 采买/选品同学 | 在供应商商品列表页快速判断「是否已采」「可否入库」,批量提交 |
| 运营同学 | 同款比价、关键词选品,减少重复录入 |
| 研发同学 | 可维护的 MV3 工程、清晰的消息通信、稳定的构建发布 |
2.2 功能清单
- 登录态管理:Popup 输入授权码登录,Token 写入本地存储,并广播给已打开的目标站点标签页。
- 悬浮操作面板:在匹配域名的商品列表页右侧注入固定面板(查询、采购类型切换、全选、批量采集、翻页、同款比价、关键词选品)。
- 页面数据感知:通过注入主世界脚本拦截页面 XHR/Fetch,拿到商品列表数据后,在对应 DOM 上挂「勾选框 / 状态标签 / 采集按钮」。
- 业务 API 协同:调用企业内部商品中心接口做「是否已存在」「批量采集」「同款比对」「关键词绑定」等。
- 辅助能力:Options / Side Panel / New Tab / DevTools 等扩展入口可作为脚手架能力保留,按需启用。
三、技术选型与为什么这样选
3.1 技术栈一览
| 层级 | 选型 | 理由 |
|---|---|---|
| 规范 | Manifest V3 | Chrome 现行标准,Service Worker 替代持久 Background Page |
| UI | Vue 3 + <script setup> |
组件化面板、弹窗、Popup 体验好 |
| 语言 | TypeScript | 消息协议、API 契约更稳 |
| UI 组件 | Element Plus(按需) | 弹窗、表单类交互快 |
| 样式 | Tailwind CSS v4 | 面板样式迭代快 |
| 构建 | Vite 7 + @crxjs/vite-plugin |
HMR、多入口、自动生成 manifest |
| DOM 辅助 | jQuery(仅 Content 层) | 第三方页面选择器多变,链式操作省事 |
| 打包 | archiver 打 zip | 一键产出分发包 |
3.2 为什么用 @crxjs/vite-plugin
传统做法是手写 manifest.json + 多入口 Rollup 配置,还要自己处理 Content Script 的路径、HMR、资源拷贝。@crxjs/vite-plugin 的核心价值:
- 用 TypeScript/
defineManifest声明清单,可按env.mode动态改名称(如开发态加➡️ Dev)。 - 自动处理 Content Script、Background、Popup 等多入口构建产物映射。
- 开发期与
chrome-extension://源协同,配合 Vite 的 CORS 配置,调试体验接近普通 Web 应用。
四、工程目录与入口职责
一个可扩展的 MV3 项目,目录大致如下:
chrome-mall-assistant/
├── manifest.config.ts # MV3 清单(TS 动态生成)
├── vite.config.ts # Vite + crx + Vue + Tailwind
├── package.json
├── zip.js # 构建产物打包 zip
├── public/
│ ├── icons/ # 扩展图标
│ └── lib/
│ └── externalScript.js # 注入页面「主世界」的拦截脚本
└── src/
├── background/ # Service Worker
├── popup/ # 工具栏弹窗(登录等)
├── options/ # 设置页
├── sidepanel/ # 侧边栏
├── newtab/ # 新标签页覆盖(可选)
├── devtools/ # DevTools 面板(可选)
├── contentScript/
│ ├── index.ts # 注入主世界脚本
│ ├── panel/ # 悬浮操作面板(Vue)
│ └── modal/ # 同款比价 / 关键词等弹层(Vue)
├── utils/
│ ├── request.ts # fetch 封装 + 鉴权
│ ├── api.ts # 业务 API
│ ├── index.ts # 通用工具
│ └── load.ts # Loading UI
└── assets/
4.1 各入口一句话职责
| 入口 | Manifest 字段 | 职责 |
|---|---|---|
| Background | background.service_worker |
安装/更新生命周期、消息中枢、用户态广播 |
| Popup | action.default_popup |
登录 / 退出 / 展示当前操作人 |
| Content Script | content_scripts |
匹配目标站、挂载 Vue 面板、监听 window.postMessage |
| 主世界脚本 | web_accessible_resources |
劫持 XHR/Fetch,把列表数据回传给 Content Script |
| Options / Side Panel | options_page / side_panel |
配置与扩展能力脚手架 |
| New Tab / DevTools | chrome_url_overrides / devtools_page |
按产品需要启用 |
经验:History/Bookmarks Override、Omnibox、Commands 属于「特定场景能力」,脚手架可留目录,不必一上来全开,避免权限膨胀。
五、Manifest V3 配置详解
下面是一份清单配置思路(对应项目中的 manifest.config.ts):
import { defineManifest } from '@crxjs/vite-plugin'
import type { ManifestV3Export } from '@crxjs/vite-plugin'
import packageData from './package.json' with { type: 'json' }
export default defineManifest((env) => ({
name: `${packageData.displayName || packageData.name}${
env.mode === 'development' ? ' ➡️ Dev' : ''
}`,
description: packageData.description,
version: packageData.version,
manifest_version: 3,
icons: {
16: 'icons/icon16.png',
32: 'icons/icon32.png',
48: 'icons/icon48.png',
128: 'icons/icon128.png',
},
action: {
default_title: '采买助手',
default_popup: 'src/popup/popup.html',
default_icon: 'icons/icon48.png',
},
options_page: 'src/options/options.html',
devtools_page: 'src/devtools/devtools.html',
background: {
service_worker: 'src/background/index.ts',
type: 'module',
},
content_scripts: [
{
// 仅匹配业务相关站点,避免 <all_urls>
matches: ['https://*.example-mall.com/*'],
js: [
'src/contentScript/index.ts',
'src/contentScript/panel/index.ts',
'src/contentScript/modal/index.ts',
],
},
],
side_panel: {
default_path: 'src/sidepanel/sidepanel.html',
},
web_accessible_resources: [
{
resources: ['lib/externalScript.js'],
matches: ['https://*.example-mall.com/*'],
},
],
// 最小权限原则:按功能取用
permissions: ['sidePanel', 'storage', 'tabs', 'activeTab', 'notifications'],
chrome_url_overrides: {
newtab: 'src/newtab/newtab.html',
},
} as ManifestV3Export))
5.1 权限怎么取舍
| 权限 | 用途 | 是否必需 |
|---|---|---|
storage |
登录态、配置持久化 | 必需 |
tabs / activeTab |
向当前标签页推送用户信息 | 按通信方案决定 |
sidePanel |
侧边栏能力 | 若未启用可去掉 |
notifications |
采集结果通知 | 可选 |
scripting |
动态注入 | 本方案用声明式 content_scripts,可不声明 |
原则:能用 matches 收窄域名就不要用 <all_urls>;能用 activeTab 就不要长期持有宽泛 tabs 权限(若需要向多个标签广播,再保留 tabs)。
5.2 开发态命名技巧
在 defineManifest 里根据 env.mode 给名称加 ➡️ Dev,本地装两个版本时一眼可辨,避免误操作生产包。
六、Vite 工程化配置
核心配置如下:
import { defineConfig } from 'vite'
import { crx } from '@crxjs/vite-plugin'
import vue from '@vitejs/plugin-vue'
import manifest from './manifest.config'
import tailwindcss from '@tailwindcss/vite'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
export default defineConfig({
build: {
cssCodeSplit: true,
emptyOutDir: true,
outDir: 'build',
rollupOptions: {
input: {
// 确保主世界脚本输出到固定路径,供 web_accessible_resources 引用
externalScript: 'public/lib/externalScript.js',
},
output: {
entryFileNames: (chunkInfo) => {
if (chunkInfo.name === 'externalScript') {
return 'lib/[name].js'
}
return 'assets/[name]-[hash].js'
},
},
},
},
plugins: [
vue(),
crx({ manifest }),
tailwindcss(),
AutoImport({ resolvers: [ElementPlusResolver()] }),
Components({ resolvers: [ElementPlusResolver()] }),
],
server: {
cors: {
origin: [/chrome-extension:\/\//],
},
},
legacy: {
skipWebSocketTokenCheck: true,
},
})
6.1 几个容易踩坑的点
outDir固定:加载未打包扩展时指向build/,与 zip 脚本输入目录一致。- 主世界脚本路径固定:
lib/externalScript.js不能带 hash,否则chrome.runtime.getURL/ WAR 配置要对齐。 - CORS:开发服务器允许
chrome-extension://,否则 HMR / 资源拉取可能失败。 - Element Plus 按需:
unplugin-auto-import+unplugin-vue-components减小 Popup/Modal 体积。
6.2 npm scripts 建议
{
"scripts": {
"dev": "cross-env NODE_ENV=production vite",
"build": "run-p type-check \"build-only {@}\" --",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "eslint . --fix",
"zip": "npm run build && node zip.js"
}
}
zip 脚本用 archiver 把 build/ 打成 package/{name}-v{version}.zip,便于内部分发或上传商店。
七、Background:消息中枢与登录态同步
MV3 的 Background 是 Service Worker:无持久 DOM、可能被挂起,适合做事件驱动逻辑。
7.1 典型职责
onInstalled:首次安装 / 更新时初始化默认配置(可打开 Options)。onMessage:处理getUser/setUser/token_expired等协议。- (可选)Side Panel 行为:
chrome.sidePanel.setPanelBehavior。
7.2 消息协议示例
// src/background/index.ts
console.log('background is running')
const USE_SIDE_PANEL = false
if (USE_SIDE_PANEL) {
chrome.sidePanel
.setPanelBehavior({ openPanelOnActionClick: true })
.catch((error: unknown) => console.error(error))
}
chrome.runtime.onInstalled.addListener(async (opt) => {
if (opt.reason === 'install') {
// 可打开引导页:chrome.runtime.getURL('src/options/options.html')
return
}
if (opt.reason === 'update') {
return
}
})
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
switch (message.type) {
case 'getUser':
chrome.storage.local.get(['userInfo'], (result) => {
sendResponse(result.userInfo)
})
break
case 'setUser':
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
tabs.forEach((tab) => {
if (tab.id != null) {
chrome.tabs.sendMessage(tab.id, {
type: 'userInfo',
data: message.data,
})
}
})
})
sendResponse({ success: true })
break
case 'token_expired':
// 可再广播给 Popup / 其他视图
chrome.runtime.sendMessage({ type: 'token_expired' })
break
}
// 异步 sendResponse 必须返回 true
return true
})
7.3 通信关系图
┌────────────┐ chrome.runtime.sendMessage ┌──────────────────┐
│ Popup │ ─────────────────────────────► │ Background SW │
│ (登录) │ ◄───────────────────────────── │ (storage/tabs) │
└────────────┘ └────────┬─────────┘
│ chrome.tabs.sendMessage
▼
┌──────────────────┐
│ Content Script │
│ (面板 / 业务) │
└────────┬─────────┘
│ window.postMessage
▼
┌──────────────────┐
│ 主世界脚本 │
│ (XHR/Fetch 拦截) │
└──────────────────┘
关键点:Content Script 与页面 JS 不在同一 JS 世界(Isolated World)。要读页面里的 window.__GLOBAL_DATA 或拦截页面自己的 XHR,必须通过 web_accessible_resources 注入真正跑在页面主世界的脚本,再用 postMessage 回传。
八、Popup:登录与用户态落地
Popup 是用户感知最强的入口之一。采买助手常见流程:
- 未登录:输入「授权码 / PDA 码 / 工号令牌」(具体形态按公司 SSO 定)。
- 调用企业内部登录接口,拿回
token与用户信息。 - 写入
localStorage(Popup 上下文)+chrome.storage.local(跨上下文共享)+ 通知 Background 广播。
8.1 登录流程示例
import { ref, onMounted } from 'vue'
import { loginUser } from '../utils/api'
import { isEmpty } from '../utils/index'
const authCode = ref('')
const userInfo = ref<any>({})
const onLogin = () => {
loginUser({ platform: 'product', code: authCode.value })
.then((res) => {
if (res.success && res.data) {
localStorage.setItem('userInfo', res.data)
syncUserStatus(res.data)
} else {
alert('登录失败')
}
})
.catch(() => alert('登录失败'))
}
const onLogout = () => {
localStorage.clear()
chrome.storage.local.remove(['userInfo'])
syncUserStatus(null)
}
const syncUserStatus = (data: string | null) => {
if (data) {
userInfo.value = JSON.parse(data)
chrome.storage.local.set({ userInfo: data })
chrome.runtime.sendMessage({ type: 'setUser', data: userInfo.value })
} else {
userInfo.value = null
}
}
onMounted(() => {
const cached = localStorage.getItem('userInfo')
syncUserStatus(cached)
})
8.2 为什么同时用 localStorage 和 chrome.storage
| 存储 | 作用域 | 适用 |
|---|---|---|
Popup 的 localStorage |
仅扩展页面上下文 | 快速读当前操作人 |
chrome.storage.local |
扩展全局 | Background / Content 共享 |
Content 页的 localStorage |
是目标网站的 | 可暂存 token 供 Content 内 fetch 带鉴权,但要注意站点隔离 |
本实践里 Content Script 会在拿到用户信息后执行:
localStorage.setItem('plugin_userInfo_token', userInfo.value.token)
这样 request.ts 里 Authorization 可从当前页面的 localStorage 读取。注意:这是「挂在业务站点源下的 key」,命名要加插件前缀,避免与站点自身 key 冲突。
九、Content Script:把 Vue 挂到别人的网页上
9.1 三层 Content 入口
contentScript/index.ts:只做一件事——把主世界脚本插进页面。contentScript/panel/:悬浮操作条(查询 / 批量采集 / 翻页等)。contentScript/modal/:同款比价、关键词选品等较大交互弹层。
拆开的好处:首屏注入更轻,弹层可按需组织;构建后仍由 manifest 一并声明注入。
9.2 注入主世界脚本
// src/contentScript/index.ts
console.log('主脚本加载成功')
const script = document.createElement('script')
script.src = chrome.runtime.getURL('lib/externalScript.js')
document.head.appendChild(script)
对应 WAR:
web_accessible_resources: [
{
resources: ['lib/externalScript.js'],
matches: ['https://*.example-mall.com/*'],
},
]
9.3 面板挂载模式
Content Script 里用 Vue createApp 挂到自己创建的容器上,避免污染站点根节点:
// panel/index.ts
import { createApp } from 'vue'
import App from './main.vue'
import '../assets/main.css'
const ROOT_ID = 'mall-assistant-root'
if (!document.getElementById(ROOT_ID)) {
const el = document.createElement('div')
el.id = ROOT_ID
document.documentElement.appendChild(el)
createApp(App).mount(el)
}
样式建议:
- 根容器用较高
z-index、position: fixed。 - 面板内部用 Tailwind / scoped CSS,必要时对根节点做样式隔离(Shadow DOM 是更强隔离方案,成本更高)。
- 选择器尽量带插件前缀 class,减少与站点 CSS 冲突。
9.4 面板功能拆分
悬浮面板可拆成:
panel.vue:主操作条(查询、采购类型、全选、批量采集、上一页/下一页、打开弹层)。onsite.vue:同款比价弹层。keyword.vue:关键词选品弹层。
主组件通过 ref 调用子弹方法:
<template>
<Panel
@same-goods="() => onsiteRef?.openModal()"
@open-keyword="() => keywordRef?.openModal()"
/>
<Onsite ref="onsiteRef" :userInfo="userInfo" />
<Keyword ref="keywordRef" :userInfo="userInfo" />
</template>
9.5 与 Background 同步用户
chrome.runtime.sendMessage({ type: 'getUser' }, (raw) => {
if (!raw) return
userInfo.value = JSON.parse(raw)
localStorage.setItem('plugin_userInfo_token', userInfo.value.token)
})
十、主世界脚本:拦截 XHR / Fetch 拿列表数据
第三方站点商品列表往往是异步接口渲染的。只靠轮询 DOM 不稳定;更稳妥的做法是 拦截页面自己的网络请求,拿到结构化 offerList,再映射回 DOM。
10.1 拦截思路
/**
* public/lib/externalScript.js
* 运行在页面主世界,通过 postMessage 回传给 Content Script
*/
const WATCH_APIS = [
'mtop.example.shop.data.get',
'mtop.example.recommend.list',
]
const XHR = XMLHttpRequest.prototype
const rawOpen = XHR.open
const rawSend = XHR.send
XHR.open = function (method, url) {
this._method = method
this._url = url
return rawOpen.apply(this, arguments)
}
XHR.send = function (postData) {
this.addEventListener('load', function () {
try {
const urlParams = new URLSearchParams(this._url)
const api = urlParams.get('api')
if (api && WATCH_APIS.includes(api)) {
window.postMessage(
{
type: 'xhr',
responseText: this.responseText,
url: this._url,
},
'*',
)
}
} catch (e) {
console.warn('[assistant] xhr hook error', e)
}
})
return rawSend.apply(this, arguments)
}
const rawFetch = window.fetch
window.fetch = async function (...args) {
const response = await rawFetch.apply(this, args)
try {
if (String(response.url).includes('/service/marketOfferResultViewService')) {
window.postMessage(
{ type: 'fetch', url: response.url },
'*',
)
}
} catch (e) {
console.warn('[assistant] fetch hook error', e)
}
return response
}
// 可选:同步页面全局数据
if (window.__GLOBAL_DATA) {
window.postMessage(
{ type: 'globalData', data: window.__GLOBAL_DATA },
'*',
)
}
10.2 Content Script 侧消费
window.addEventListener('message', (e) => {
if (e.data?.type === 'xhr') handleXhrMessage(e.data)
if (e.data?.type === 'script') handleScriptMessage(e.data)
if (e.data?.type === 'fetch') handleFetchMessage()
})
收到 offerList 后典型处理:
- 抽取商品 ID 列表。
- 调企业内部「是否已存在」接口。
- 按返回结果在对应商品卡片 DOM 上插入:
- 可采集 → checkbox
- 已存在 → 状态文案
- 不支持 → 「不支持」标记
- 预触发「同款比对预处理」接口,减少用户打开比价弹层时的等待。
10.3 DOM 挂载要注意的坑
- 列表虚拟滚动 / 懒加载:接口条数与当前 DOM 节点数可能不一致,需要兜底选择器。
- 翻页后清理:翻页前移除旧按钮/勾选框,避免重复插入。
- 事件冒泡:采集按钮
click要stopPropagation,否则会触发站点卡片跳转。 - 延时渲染:部分站点脚本后置渲染图片区,可在回调里
setTimeout再扫 DOM(更优是 MutationObserver)。
10.4 辅助:隐藏站点干扰浮层
某些站点有 AI 客服浮层遮挡操作。可用 Content Script 注入一段 CSS:
export const hideSiteFloatBot = () => {
if (!location.href.includes('//s.example-mall.com')) return
const style = document.createElement('style')
style.textContent = `
[class*="im-ai-chat-floatbot-container"] {
display: none !important;
}
`
;(document.head || document.documentElement).appendChild(style)
}
十一、业务 API 层:鉴权、环境、版本
11.1 统一 request
// utils/request.ts
export const isDev = process.env.NODE_ENV === 'development'
export const productBaseUrl = isDev
? 'http://127.0.0.1:8802/api/product/center'
: 'https://api.example-erp.com/api/product/center'
export const authBaseUrl = isDev
? 'https://api.example-erp.com/erp-system'
: 'https://api.example-erp.com/erp-system'
export async function request(url: string, options: any = {}) {
const finalOptions: any = {
...options,
headers: { 'Content-Type': 'application/json' },
}
if (finalOptions.isAuth) {
finalOptions.headers.Authorization =
localStorage.getItem('plugin_userInfo_token') || ''
}
if (finalOptions.method === 'GET' && finalOptions.params) {
const qs = new URLSearchParams(finalOptions.params).toString()
url = `${url}?${qs}`
}
if (finalOptions.body && typeof finalOptions.body === 'object') {
finalOptions.body = JSON.stringify(finalOptions.body)
}
delete finalOptions.isAuth
const isLoginApi = url.startsWith('/login/')
const finalUrl = isLoginApi
? `${authBaseUrl}${url}`
: url.includes('https://')
? url
: `${productBaseUrl}${url}`
const response = await fetch(finalUrl, finalOptions)
if (!response.ok) {
throw new Error(`HTTP错误: ${response.status}`)
}
return response.json()
}
11.2 API 分组建议
// 批量采集
export function batchCollect(params: any, version = 'v3') {
return request(`/gather/receive/${version}`, {
method: 'POST',
body: params,
isAuth: true,
})
}
// 批量查询是否已存在
export function batchExistQuery(params: any, version = 'v3') {
return request(`/gather/product/exist/${version}`, {
method: 'POST',
body: params,
isAuth: true,
})
}
// 同款比对
export function comparisonList(params: any) {
return request(`/comparison/list`, {
method: 'POST',
body: params,
isAuth: true,
})
}
// 登录
export function loginUser(params: any) {
return request('/login/auth', {
method: 'POST',
body: params,
isAuth: false,
})
}
// 关键词相关
export function getUserKeywords(params: any) {
return request(`/keyword/user-keywords`, {
method: 'GET',
params,
isAuth: true,
})
}
11.3 版本号与业务线分支
真实项目里,同一「批量采集」可能按采购类型走不同后端版本,例如:
前端用面板上的「网采 / 跨境」切换决定 version 参数,而不是复制两套 UI。这是很实用的扩展点设计。
11.4 跨域说明
Content Script 发起的 fetch 会受扩展权限与站点 CSP 影响。常见做法:
- 在 manifest 里声明
host_permissions(访问企业内部 API 域名)。 - 或把请求统一走 Background,由 SW
fetch再回传(更利于统一鉴权与隐藏 Token)。
本实践采用 Content 直连 + Token Header;若安全要求更高,建议迁到 Background 代理。
十二、核心交互流程串讲
12.1 「查询 → 勾选 → 批量采集」
用户打开供应商商品列表
│
▼
主世界脚本拦截列表 XHR,postMessage 给 Content
│
▼
Content 调「是否已存在」API
│
▼
在对应卡片插入 checkbox / 状态标签
│
▼
用户选择采购类型(国内 / 跨境)并勾选
│
▼
点击「批量采集」→ confirm 数量 → 调采集 API
│
▼
短延时后自动再点「查询」,刷新状态
12.2 翻页协同
面板的「上一页 / 下一页」不是自己实现分页,而是 触发站点原生翻页按钮:
const clickByText = (text: string) => {
document.querySelectorAll('button').forEach((btn) => {
if (btn.textContent === text) btn.click()
})
}
const onNextPage = () => {
clickByText('下一页 >')
document.querySelector('.fui-paging-list .fui-next')?.dispatchEvent(
new MouseEvent('click', { bubbles: true }),
)
const all = document.getElementById('st_chk_all') as HTMLInputElement | null
if (all) all.checked = false
}
这样不用逆向站点分页协议,稳定性取决于站点文案/结构;站点改版时优先修选择器。
12.3 同款比价 / 关键词选品
这两类交互信息密度高,适合独立 Modal:
- 打开前可对已勾选 ID 做「比对预处理」接口,后端异步算结果。
- Modal 内展示图片、价格、日期等(图片 URL 做 CDN 拼装时注意访问权限)。
- 关键词模块:拉取用户关键词 → 内部词查询 → 绑定 → 再驱动选品。
十三、工具函数与体验细节
13.1 Loading
export const loading = {
show(msg = '加载中,请稍后...') {
let el = document.querySelector('.plugin-loading') as HTMLDivElement | null
if (!el) {
el = document.createElement('div')
el.className = 'plugin-loading'
el.innerHTML = `<div class="spinner"></div><div>${msg}</div>`
Object.assign(el.style, {
position: 'fixed',
inset: '0',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(0,0,0,.35)',
zIndex: '2147483646',
})
document.body.appendChild(el)
}
},
hide() {
document.querySelector('.plugin-loading')?.remove()
},
}
13.2 空值判断
插件里接口字段经常缺省,统一 isEmpty 比到处写 !x 更稳:
export const isEmpty = (data: any) => {
if (data === null || data === undefined) return true
if (Array.isArray(data)) return data.length === 0
if (Object.prototype.toString.call(data) === '[object Object]') {
return Object.keys(data).length === 0
}
if (typeof data === 'string') return data.trim() === ''
return false
}
13.3 清理旧 DOM
export const removeInjectedMarks = (parent: JQuery) => {
parent.find('.AddProductUrlButton, .getStatus, .st_chk, .nonsupport').remove()
}
每次重绘前先清,是防重复插入的最低成本方案。
十四、本地调试全流程
14.1 安装依赖与启动
环境要求建议 Node.js ≥ 20。
npm install
npm run dev
# 或
npm run build
14.2 加载扩展
- Chrome 打开
chrome://extensions/ - 开启「开发者模式」
- 「加载已解压的扩展程序」→ 选择项目的
build/目录 - 打开匹配的目标站点页面,验证面板是否出现
14.3 调试入口对照
| 要调试的内容 | 怎么打开 DevTools |
|---|---|
| Popup | 右键插件图标 → 检查弹出内容 |
| Background SW | 扩展管理页 → Service Worker「检查视图」 |
| Content Script | 目标网页 F12 → Console / Sources 里找扩展上下文 |
| 主世界脚本 | 目标网页 F12(与页面同上下文) |
14.4 常见问题
1)面板不出现
matches是否覆盖当前 URL(注意www/ 子域 /httpvshttps)- Content 入口是否构建进产物
- 控制台是否有 CSP / 扩展上下文失效错误
2)收不到 XHR 数据
- 主世界脚本是否成功插入(Network 里能否看到
chrome-extension://.../lib/externalScript.js) WATCH_APIS是否与站点真实 api 参数一致(站点改版最常见)postMessage的type是否与监听一致
3)登录成功但采集报未授权
- Token 是否写入 Content 可见的
localStorage Authorization头格式是否与后端约定一致(Bearer / 裸 token)- 开发环境
baseUrl是否指向可达地址
4)消息无响应
onMessage异步场景是否return true- Service Worker 被挂起后首次唤醒的时序问题:必要时在 SW 顶层注册 listener(不要只放在 async 回调里)
十五、打包与内部分发
15.1 zip 脚本示例
import archiver from 'archiver'
import fs from 'fs'
import { promises as fsPromises } from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import pkg from './package.json' with { type: 'json' }
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const INPUT_DIR = path.join(__dirname, './build')
const DEST_DIR = path.join(__dirname, './package')
const ensureDir = async (dir) => {
try {
await fsPromises.access(dir)
} catch {
await fsPromises.mkdir(dir, { recursive: true })
}
}
const buildZip = (src, dist, filename) =>
new Promise((resolve, reject) => {
const output = fs.createWriteStream(path.join(dist, filename))
const archive = archiver('zip', { zlib: { level: 9 } })
output.on('close', resolve)
archive.on('error', reject)
archive.pipe(output)
archive.directory(src, false)
archive.finalize()
})
const main = async () => {
const filename = `${pkg.name}-v${pkg.version}.zip`
await ensureDir(DEST_DIR)
await buildZip(INPUT_DIR, DEST_DIR, filename)
console.info(`Build complete: ${filename}`)
}
main()
执行:
npm run zip
得到 package/xxx-v1.2.3.zip,可内部分发或用于商店上传。
15.2 版本管理建议
package.json的version与 manifestversion同源(本方案从 package 读取)。- 破坏性改动(选择器大改、协议变更)升次版本;文案/样式升修订号。
- 内部分发附带「更新说明 + 兼容站点版本说明」。
十六、安全与合规(务必重视)
做「页面增强型」插件,安全边界比普通站点更模糊,建议至少做到:
16.1 权限与数据
- 最小权限:域名、API、storage 能少则少。
- Token 保护:优先 Background 代理请求;若必须放 Content,注意 XSS 风险(第三方页面脚本可读同页面
localStorage!)。 - 日志规范:Console 不要打印完整 Token、手机号、完整用户结构。
- HTTPS:生产 API 强制 HTTPS。
特别提醒:把 Token 写入目标站点源下的
localStorage,意味着 该站点任意脚本都能读到。内网工具可接受时需明确风险;对外分发应改为chrome.storage+ Background 代理。
16.2 注入与 XSS
- 尽量少用字符串拼接 HTML;用
createElement+textContent。 - 若必须
innerHTML,对用户/接口字段做转义。 postMessage校验event.origin/event.source,不要盲目信任*消息(示例为简洁使用了*,生产应收紧)。
16.3 合规与商店
- 明确隐私政策:采集哪些数据、发往何处、是否出境。
- 不要在未声明的情况下上传与业务无关的浏览数据。
- 尊重目标站点 ToS;企业内网工具与公开商店插件的合规标准不同,上架前法务评估。
十七、性能与可维护性实践
17.1 性能
- Content Script 拆分入口,避免大弹层逻辑阻塞首屏注入。
- DOM 操作合并、减少强制同步布局;列表重绘前先批量清理。
- 对高频 Mutation / scroll 使用防抖。
- Element Plus / Swiper 等仅在弹层用到时再引入(可后续做异步组件)。
17.2 可维护性
- 选择器集中管理:站点 DOM 选择器单独放
selectors.ts,改版只改一处。 - 消息协议枚举化:
type: 'getUser' | 'setUser' | ...用 TS 联合类型。 - 功能开关:如
USE_SIDE_PANEL,避免半成品能力干扰生产。 - E2E 心态:关键路径「登录 → 查询 → 勾选 → 采集」写成手工检查清单;有条件再补 Playwright + 扩展加载。
17.3 站点改版应对策略
电商站点前端迭代频繁,插件最脆弱的是选择器与拦截 API 名。建议:
- 拦截层:API 名单配置化,支持远程下发(需注意安全)。
- DOM 层:主选择器 + 兜底选择器。
- 监控:采集失败率、面板挂载失败上报(上报字段避免包含敏感信息)。
十八、架构演进路线图
如果你从本实践继续演进,可以按优先级考虑:
| 阶段 | 方向 | 收益 |
|---|---|---|
| P0 | Token 迁出页面 localStorage,Background 代理 API | 安全性 |
| P0 | 选择器 / 拦截名单配置化 | 抗改版 |
| P1 | Shadow DOM 挂载面板 | 样式隔离 |
| P1 | 统一错误码与 Toast,替代 alert |
体验 |
| P2 | 消息总线封装(带超时、重试、类型推断) | 可维护 |
| P2 | 多站点适配层(Strategy 按 host 分发) | 扩展性 |
| P3 | 自动化回归(扩展加载 + 页面 mock) | 质量 |
十九、完整「最小可运行」心智模型
抛开业务细节,这类插件可以压缩成五句话:
- Manifest 声明:我在哪些页面注入、有哪些权限、哪些资源可被页面加载。
- Background 做中枢:存用户、转发消息、管生命周期。
- Popup 做人机入口:登录、退出、看状态。
- Content + Vue 做人机增强:在别人的页面上画自己的 UI。
- 主世界脚本 做数据感知:把页面网络数据桥回扩展世界。
把这五层画清楚,再往上堆「采集 / 比价 / 关键词」都只是业务插件。
二十、结语
浏览器插件开发的难点,往往不在 Vue 组件怎么写,而在 多个 JavaScript 世界之间的边界:扩展页面、Service Worker、Content Isolated World、页面主世界、企业后端。Manifest V3 让边界更清晰,也让「持久后台」思维失效——你必须用事件驱动与存储来设计状态。
本文以「电商采买助手」为样本,给出了一条可落地的工程路径:
- Vue3 + Vite +
@crxjs的现代化脚手架 - Content 注入面板 + 主世界拦截的数据闭环
- Background 消息协议与登录态同步
- 请求层、打包、调试、安全与抗改版建议
如果你正在把油猴脚本升级成正规 MV3 扩展,或要把内部 ERP 能力「嵌」进第三方业务页面,希望这篇文章能帮你少走弯路。
附录 A:推荐阅读
更多推荐



所有评论(0)