从零到落地:用 Vue3 + Vite 打造 Manifest V3 浏览器插件(电商采买助手实战)

本文基于一套 Chrome 扩展工程实践整理而成,聚焦技术架构、工程化思路与关键代码模式,适合希望用现代化前端栈开发 MV3 插件、并在第三方页面上做「页面增强 + 业务协同」的同学。


一、写在前面:为什么还要做浏览器插件?

浏览器插件(Browser Extension)是「网页」与「本地业务系统」之间最自然的桥梁之一。尤其在电商采买、选品、比价、数据采集这类场景里,业务同学每天都在第三方平台页面上操作:看商品、翻页、勾选、对比、再手工录入到内部 ERP。流程重复、易错、效率低。

如果能把「识别商品 → 判断是否已存在 → 批量采集 → 同款比价 → 关键词选品」嵌进页面本身,就能把人工链路压缩成几次点击。这正是这类插件的价值:

  1. 就地增强:不打断用户当前浏览路径,在目标站点页面上直接提供操作面板。
  2. 跨上下文协同:把第三方页面 DOM、页面内接口数据、企业内部 API 串成一条流水线。
  3. 可分发、可更新:通过扩展机制统一版本,比脚本油猴更规范、权限更清晰。

本文将以「电商采买助手」为业务壳,系统讲解一套 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 功能清单

  1. 登录态管理:Popup 输入授权码登录,Token 写入本地存储,并广播给已打开的目标站点标签页。
  2. 悬浮操作面板:在匹配域名的商品列表页右侧注入固定面板(查询、采购类型切换、全选、批量采集、翻页、同款比价、关键词选品)。
  3. 页面数据感知:通过注入主世界脚本拦截页面 XHR/Fetch,拿到商品列表数据后,在对应 DOM 上挂「勾选框 / 状态标签 / 采集按钮」。
  4. 业务 API 协同:调用企业内部商品中心接口做「是否已存在」「批量采集」「同款比对」「关键词绑定」等。
  5. 辅助能力: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 的核心价值:

  1. 用 TypeScript/defineManifest 声明清单,可按 env.mode 动态改名称(如开发态加 ➡️ Dev)。
  2. 自动处理 Content Script、Background、Popup 等多入口构建产物映射。
  3. 开发期与 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 几个容易踩坑的点

  1. outDir 固定:加载未打包扩展时指向 build/,与 zip 脚本输入目录一致。
  2. 主世界脚本路径固定lib/externalScript.js 不能带 hash,否则 chrome.runtime.getURL / WAR 配置要对齐。
  3. CORS:开发服务器允许 chrome-extension://,否则 HMR / 资源拉取可能失败。
  4. 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 脚本用 archiverbuild/ 打成 package/{name}-v{version}.zip,便于内部分发或上传商店。


七、Background:消息中枢与登录态同步

MV3 的 Background 是 Service Worker:无持久 DOM、可能被挂起,适合做事件驱动逻辑。

7.1 典型职责

  1. onInstalled:首次安装 / 更新时初始化默认配置(可打开 Options)。
  2. onMessage:处理 getUser / setUser / token_expired 等协议。
  3. (可选)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 是用户感知最强的入口之一。采买助手常见流程:

  1. 未登录:输入「授权码 / PDA 码 / 工号令牌」(具体形态按公司 SSO 定)。
  2. 调用企业内部登录接口,拿回 token 与用户信息。
  3. 写入 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.tsAuthorization 可从当前页面的 localStorage 读取。注意:这是「挂在业务站点源下的 key」,命名要加插件前缀,避免与站点自身 key 冲突。


九、Content Script:把 Vue 挂到别人的网页上

9.1 三层 Content 入口

  1. contentScript/index.ts:只做一件事——把主世界脚本插进页面。
  2. contentScript/panel/:悬浮操作条(查询 / 批量采集 / 翻页等)。
  3. 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-indexposition: 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 后典型处理:

  1. 抽取商品 ID 列表。
  2. 调企业内部「是否已存在」接口。
  3. 按返回结果在对应商品卡片 DOM 上插入:
    • 可采集 → checkbox
    • 已存在 → 状态文案
    • 不支持 → 「不支持」标记
  4. 预触发「同款比对预处理」接口,减少用户打开比价弹层时的等待。

10.3 DOM 挂载要注意的坑

  1. 列表虚拟滚动 / 懒加载:接口条数与当前 DOM 节点数可能不一致,需要兜底选择器。
  2. 翻页后清理:翻页前移除旧按钮/勾选框,避免重复插入。
  3. 事件冒泡:采集按钮 clickstopPropagation,否则会触发站点卡片跳转。
  4. 延时渲染:部分站点脚本后置渲染图片区,可在回调里 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 影响。常见做法:

  1. 在 manifest 里声明 host_permissions(访问企业内部 API 域名)。
  2. 或把请求统一走 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:

  1. 打开前可对已勾选 ID 做「比对预处理」接口,后端异步算结果。
  2. Modal 内展示图片、价格、日期等(图片 URL 做 CDN 拼装时注意访问权限)。
  3. 关键词模块:拉取用户关键词 → 内部词查询 → 绑定 → 再驱动选品。

十三、工具函数与体验细节

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 加载扩展

  1. Chrome 打开 chrome://extensions/
  2. 开启「开发者模式」
  3. 「加载已解压的扩展程序」→ 选择项目的 build/ 目录
  4. 打开匹配的目标站点页面,验证面板是否出现

14.3 调试入口对照

要调试的内容 怎么打开 DevTools
Popup 右键插件图标 → 检查弹出内容
Background SW 扩展管理页 → Service Worker「检查视图」
Content Script 目标网页 F12 → Console / Sources 里找扩展上下文
主世界脚本 目标网页 F12(与页面同上下文)

14.4 常见问题

1)面板不出现

  • matches 是否覆盖当前 URL(注意 www / 子域 / http vs https
  • Content 入口是否构建进产物
  • 控制台是否有 CSP / 扩展上下文失效错误

2)收不到 XHR 数据

  • 主世界脚本是否成功插入(Network 里能否看到 chrome-extension://.../lib/externalScript.js
  • WATCH_APIS 是否与站点真实 api 参数一致(站点改版最常见)
  • postMessagetype 是否与监听一致

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 版本管理建议

  1. package.jsonversion 与 manifest version 同源(本方案从 package 读取)。
  2. 破坏性改动(选择器大改、协议变更)升次版本;文案/样式升修订号。
  3. 内部分发附带「更新说明 + 兼容站点版本说明」。

十六、安全与合规(务必重视)

做「页面增强型」插件,安全边界比普通站点更模糊,建议至少做到:

16.1 权限与数据

  1. 最小权限:域名、API、storage 能少则少。
  2. Token 保护:优先 Background 代理请求;若必须放 Content,注意 XSS 风险(第三方页面脚本可读同页面 localStorage!)。
  3. 日志规范:Console 不要打印完整 Token、手机号、完整用户结构。
  4. HTTPS:生产 API 强制 HTTPS。

特别提醒:把 Token 写入目标站点源下的 localStorage,意味着 该站点任意脚本都能读到。内网工具可接受时需明确风险;对外分发应改为 chrome.storage + Background 代理。

16.2 注入与 XSS

  1. 尽量少用字符串拼接 HTML;用 createElement + textContent
  2. 若必须 innerHTML,对用户/接口字段做转义。
  3. postMessage 校验 event.origin / event.source,不要盲目信任 * 消息(示例为简洁使用了 *,生产应收紧)。

16.3 合规与商店

  1. 明确隐私政策:采集哪些数据、发往何处、是否出境。
  2. 不要在未声明的情况下上传与业务无关的浏览数据。
  3. 尊重目标站点 ToS;企业内网工具与公开商店插件的合规标准不同,上架前法务评估。

十七、性能与可维护性实践

17.1 性能

  1. Content Script 拆分入口,避免大弹层逻辑阻塞首屏注入。
  2. DOM 操作合并、减少强制同步布局;列表重绘前先批量清理。
  3. 对高频 Mutation / scroll 使用防抖。
  4. Element Plus / Swiper 等仅在弹层用到时再引入(可后续做异步组件)。

17.2 可维护性

  1. 选择器集中管理:站点 DOM 选择器单独放 selectors.ts,改版只改一处。
  2. 消息协议枚举化type: 'getUser' | 'setUser' | ... 用 TS 联合类型。
  3. 功能开关:如 USE_SIDE_PANEL,避免半成品能力干扰生产。
  4. E2E 心态:关键路径「登录 → 查询 → 勾选 → 采集」写成手工检查清单;有条件再补 Playwright + 扩展加载。

17.3 站点改版应对策略

电商站点前端迭代频繁,插件最脆弱的是选择器与拦截 API 名。建议:

  1. 拦截层:API 名单配置化,支持远程下发(需注意安全)。
  2. DOM 层:主选择器 + 兜底选择器。
  3. 监控:采集失败率、面板挂载失败上报(上报字段避免包含敏感信息)。

十八、架构演进路线图

如果你从本实践继续演进,可以按优先级考虑:

阶段 方向 收益
P0 Token 迁出页面 localStorage,Background 代理 API 安全性
P0 选择器 / 拦截名单配置化 抗改版
P1 Shadow DOM 挂载面板 样式隔离
P1 统一错误码与 Toast,替代 alert 体验
P2 消息总线封装(带超时、重试、类型推断) 可维护
P2 多站点适配层(Strategy 按 host 分发) 扩展性
P3 自动化回归(扩展加载 + 页面 mock) 质量

十九、完整「最小可运行」心智模型

抛开业务细节,这类插件可以压缩成五句话:

  1. Manifest 声明:我在哪些页面注入、有哪些权限、哪些资源可被页面加载。
  2. Background 做中枢:存用户、转发消息、管生命周期。
  3. Popup 做人机入口:登录、退出、看状态。
  4. Content + Vue 做人机增强:在别人的页面上画自己的 UI。
  5. 主世界脚本 做数据感知:把页面网络数据桥回扩展世界。

把这五层画清楚,再往上堆「采集 / 比价 / 关键词」都只是业务插件。


二十、结语

浏览器插件开发的难点,往往不在 Vue 组件怎么写,而在 多个 JavaScript 世界之间的边界:扩展页面、Service Worker、Content Isolated World、页面主世界、企业后端。Manifest V3 让边界更清晰,也让「持久后台」思维失效——你必须用事件驱动与存储来设计状态。

本文以「电商采买助手」为样本,给出了一条可落地的工程路径:

  • Vue3 + Vite + @crxjs 的现代化脚手架
  • Content 注入面板 + 主世界拦截的数据闭环
  • Background 消息协议与登录态同步
  • 请求层、打包、调试、安全与抗改版建议

如果你正在把油猴脚本升级成正规 MV3 扩展,或要把内部 ERP 能力「嵌」进第三方业务页面,希望这篇文章能帮你少走弯路。


附录 A:推荐阅读

Logo

电商企业物流数字化转型必备!快递鸟 API 接口,72 小时快速完成物流系统集成。全流程实战1V1指导,营造开放的API技术生态圈。

更多推荐