Chrome/Firefox 浏览器扩展开发完整指南

浏览器扩展

浏览器扩展(Browser Extension)是一种可以修改和增强浏览器功能的小型软件程序。它们使用 Web 技术(HTML、CSS、JavaScript)开发,可以在用户浏览网页时提供额外功能。

典型应用场景

类型 示例 技术要点
内容增强 广告拦截器(AdBlock)、语法检查(Grammarly) Content Scripts、DOM 操作
生产力工具 密码管理器、笔记工具、截图工具 Storage API、Context Menu
数据获取 价格追踪、天气查询、新闻订阅 Fetch API、定时任务
页面修改 暗黑模式、字体替换、样式调整 CSS 注入、动态 DOM
开发工具 React DevTools、Vue DevTools DevTools API、消息传递
标签管理 OneTab、Session Buddy Tabs API、Bookmarks API

扩展的优势

  • 直接访问浏览器 API:比网页应用有更强的能力
  • 无需服务器:可以完全离线运行
  • 跨页面工作:可以在所有标签页中生效
  • 持久化存储:使用浏览器提供的存储 API
  • 用户基数大:Chrome Web Store 和 Firefox Add-ons 有庞大用户群

核心概念

1. Manifest 文件:扩展的身份证

Manifest 是扩展的核心配置文件,定义了扩展的所有信息、权限和行为。

基础结构:

json 复制代码
{
  "manifest_version": 3,
  "name": "我的扩展",
  "version": "1.0.0",
  "description": "这是一个示例扩展",
  "icons": {
    "16": "icon-16.png",
    "48": "icon-48.png",
    "128": "icon-128.png"
  },
  "permissions": ["storage", "activeTab"],
  "host_permissions": ["https://*/*"],
  "action": {
    "default_popup": "popup.html",
    "default_icon": "icon-32.png",
    "default_title": "点击打开"
  }
}

Manifest主要有v2和v3两种,现在以 v3 为主

Manifest V2 vs V3 主要差异:

特性 Manifest V2 Manifest V3
后台脚本 background.page / background.scripts background.service_worker
网络请求 webRequest API(同步阻塞) declarativeNetRequest(声明式)
代码执行 executeScript scripting.executeScript
远程代码 允许 禁止(必须打包在扩展内)
安全性 一般 更强(CSP 更严格)

2. 五大核心组件详解

组件架构图

  • Background Script (后台脚本): 扩展的核心,持续运行在后台,处理事件和协调其他组件
  • Popup (弹窗UI): 用户点击扩展图标时显示的界面
  • Content Script (内容脚本): 注入到网页中的脚本,可以访问和修改页面DOM
  • Options Page (选项页面): 扩展的设置页面,用于配置选项
  • Web Page DOM: 实际网页的文档对象模型,Content Script可以直接操作

组件对比表

组件 运行环境 生命周期 能访问 DOM 能使用 Chrome API 通信方式
Background 独立上下文 长期/事件触发 ✅ 全部 runtime.sendMessage
Content Script 网页上下文 随页面加载 ✅ 页面 DOM ⚠️ 部分 runtime.sendMessage
Popup 独立窗口 弹窗打开时 ✅ 自身 DOM ✅ 全部 直接调用或消息
Options 独立页面 设置页打开时 ✅ 自身 DOM ✅ 全部 直接调用或消息
DevTools DevTools 面板 DevTools 打开时 ⚠️ 通过 inspectedWindow ✅ 部分 runtime.connect

3. 权限系统

权限类型

基本权限(permissions):

json 复制代码
{
  "permissions": [
    "storage",        // 使用 chrome.storage API
    "activeTab",      // 访问当前活动标签页
    "tabs",          // 访问标签页信息(URL、标题等)
    "notifications",  // 显示系统通知
    "alarms",        // 定时任务
    "contextMenus",  // 右键菜单
    "cookies",       // 访问 Cookie
    "downloads",     // 管理下载
    "bookmarks",     // 管理书签
    "history"        // 访问历史记录
  ]
}

主机权限(host_permissions):

json 复制代码
{
  "host_permissions": [
    "https://api.example.com/*",  // 特定域名
    "https://*/*",                 // 所有 HTTPS 网站
    "<all_urls>"                   // 所有网站(需谨慎)
  ]
}

权限最小化原则

⚠️ 重要:只请求必要的权限,过多权限会:

  • 降低用户信任度
  • 增加审核难度
  • 影响商店排名

示例:

json 复制代码
// ❌ 不好:请求了不必要的权限
{
  "permissions": ["tabs", "history", "bookmarks", "<all_urls>"]
}

// ✅ 好:只请求实际使用的权限
{
  "permissions": ["storage", "activeTab"]
}

项目架构与技术栈

技术选型

笔者推荐直接使用现成的开发模板 github.com/JohnBra/vit...

以 vite-web-extension 模板开发的每日金价小例子

1. 前置要求

  • Node.js: >= 16.x(推荐 18.x 或 20.x)
  • 包管理器: npm / yarn / pnpm(推荐 pnpm)
  • 浏览器: Chrome 或 Firefox 最新版

检查版本:

bash 复制代码
node --version   # v20.x.x
npm --version    # 10.x.x

2. 项目初始化

bash 复制代码
# 克隆项目或使用模板
git clone https://github.com/JohnBra/vite-web-extension

# 安装依赖
pnpm install

# 或使用 npm
npm install

3. 开发模式启动

bash 复制代码
# Chrome 开发模式(默认)
pnpm dev
# 或
pnpm dev:chrome

# Firefox 开发模式
pnpm dev:firefox

开发模式特性:

  • ✅ 自动监听文件变化
  • ✅ 热重载(nodemon 自动重新构建)
  • ✅ Source Map 支持
  • ✅ 开发环境专用图标
  • ✅ 详细的错误日志

4. 加载扩展到浏览器

Chrome / Edge

  1. 打开扩展管理页面:

    • 地址栏输入 chrome://extensions
    • 或通过菜单:更多工具 → 扩展程序
  2. 开启开发者模式(右上角开关)

  3. 点击 "加载已解压的扩展程序"

  4. 选择项目的 dist_chrome 文件夹

  5. 扩展加载成功,可以看到开发版图标

常见问题:

  • ⚠️ 如果修改了 Manifest,需要点击刷新按钮
  • ⚠️ Content Script 修改后,需要刷新目标网页
  • ⚠️ Popup 和 Options 页面可以直接刷新

Firefox

  1. 打开调试页面:

    • 地址栏输入 about:debugging#/runtime/this-firefox
    • 或通过菜单:附加组件和主题 → 管理扩展 → 调试附加组件
  2. 点击 "临时载入附加组件"

  3. 选择 dist_firefox 文件夹中的 manifest.json 文件

  4. 扩展加载成功

注意:

  • ⚠️ Firefox 的临时扩展在浏览器关闭后会被卸载
  • ⚠️ 每次重启浏览器需要重新加载

5. 验证安装

加载扩展后,验证以下功能:

typescript 复制代码
// 1. 打开 Popup
//    点击扩展图标,应该看到黄金价格查询界面

// 2. 查看 Background Script 日志
//    在扩展管理页面,点击"service worker"或"背景页"
//    控制台应该显示:'background script loaded'

// 3. 查看 Content Script
//    访问任意网站,打开开发者工具
//    应该看到页面左下角有黄色提示:"content script loaded"

// 4. 打开 Options 页面
//    右键点击扩展图标 → 选项
//    应该显示设置页面

Manifest 配置详解

完整配置文件

本项目的 manifest.json

json 复制代码
{
  "manifest_version": 3,
  "name": "",
  "description": "",
  "version": "1.0.0",
  
  "icons": {
    "16": "icon-16.png",
    "32": "icon-32.png",
    "48": "icon-48.png",
    "128": "icon-128.png"
  },
  
  "action": {
    "default_popup": "src/pages/popup/index.html",
    "default_icon": {
      "32": "icon-32.png"
    },
    "default_title": "查看黄金价格"
  },
  
  "background": {
    "service_worker": "src/pages/background/index.ts",
    "type": "module"
  },
  
  "content_scripts": [
    {
      "matches": ["http://*/*", "https://*/*", "<all_urls>"],
      "js": ["src/pages/content/index.tsx"],
      "css": ["contentStyle.css"],
      "run_at": "document_idle"
    }
  ],
  
  "permissions": [
    "storage",
    "activeTab"
  ],
  
  "host_permissions": [
    "https://chatbot-be-omega.vercel.app/*"
  ],
  
  "options_ui": {
    "page": "src/pages/options/index.html",
    "open_in_tab": true
  },
  
  "chrome_url_overrides": {
    "newtab": "src/pages/newtab/index.html"
  },
  
  "devtools_page": "src/pages/devtools/index.html",
  
  "web_accessible_resources": [
    {
      "resources": [
        "contentStyle.css",
        "icon-128.png",
        "icon-32.png"
      ],
      "matches": ["<all_urls>"]
    }
  ]
}

字段详解

基础信息

json 复制代码
{
  "manifest_version": 3,        // 必须是 3(新版)
  "name": "扩展名称",            // 最多 45 个字符
  "version": "1.0.0",           // 语义化版本号
  "description": "扩展描述",     // 最多 132 个字符
  "author": "作者名"            // 可选
}

图标配置

json 复制代码
{
  "icons": {
    "16": "icon-16.png",      // 扩展页面的 favicon
    "48": "icon-48.png",      // 扩展管理页面
    "128": "icon-128.png"     // 安装和 Chrome Web Store
  }
}

图标设计建议:

  • 使用简洁、可识别的设计
  • PNG 格式,透明背景
  • 不同尺寸保持一致性
  • 避免文字(太小看不清)

Action(工具栏图标)

json 复制代码
{
  "action": {
    "default_popup": "popup.html",       // 点击图标打开的页面
    "default_icon": {
      "16": "icon-16.png",
      "32": "icon-32.png"
    },
    "default_title": "鼠标悬停提示文字"
  }
}

动态修改:

typescript 复制代码
// 修改图标
chrome.action.setIcon({ path: "new-icon.png" });

// 修改标题
chrome.action.setTitle({ title: "新标题" });

// 修改徽章
chrome.action.setBadgeText({ text: "99+" });
chrome.action.setBadgeBackgroundColor({ color: "#FF0000" });

Content Scripts 配置

json 复制代码
{
  "content_scripts": [
    {
      "matches": [
        "https://www.example.com/*",    // 匹配模式
        "https://*.google.com/*"
      ],
      "js": ["content.js"],             // JavaScript 文件
      "css": ["content.css"],           // CSS 文件
      "run_at": "document_idle",        // 注入时机
      "all_frames": false,              // 是否注入到 iframe
      "match_about_blank": false        // 是否匹配 about:blank
    }
  ]
}

run_at 选项:

  • document_start: DOM 构建之前(最早)
  • document_end: DOM 构建完成后
  • document_idle: 页面空闲时(默认,推荐)

Web Accessible Resources

json 复制代码
{
  "web_accessible_resources": [
    {
      "resources": ["images/*", "styles.css"],
      "matches": ["https://example.com/*"]
    }
  ]
}

用途: 允许网页访问扩展内的资源文件

示例:

typescript 复制代码
// 在 Content Script 中使用扩展资源
const imageUrl = chrome.runtime.getURL('images/logo.png');
const img = document.createElement('img');
img.src = imageUrl;
document.body.appendChild(img);

核心组件开发实践

1. Background Script(后台脚本)

Background Script 是扩展的"大脑",负责处理事件、管理状态、协调各组件。

Manifest V3 的变化

在 Manifest V3 中,Background Page 被 Service Worker 取代:

特性 Background Page (V2) Service Worker (V3)
生命周期 持续运行 事件驱动,空闲时休眠
DOM 访问 有 DOM 无 DOM
定时器 setTimeout/setInterval chrome.alarms API
存储 变量持久化 需要使用 chrome.storage

基础示例

typescript 复制代码
// src/pages/background/index.ts

// 监听扩展安装或更新
chrome.runtime.onInstalled.addListener((details) => {
  if (details.reason === 'install') {
    console.log('扩展首次安装');
    // 初始化默认设置
    chrome.storage.local.set({
      settings: {
        refreshInterval: 30,
        notifications: true
      }
    });
  } else if (details.reason === 'update') {
    console.log('扩展已更新到', chrome.runtime.getManifest().version);
  }
});

// 监听来自其他组件的消息
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  console.log('收到消息:', message);
  console.log('来自:', sender.tab ? `标签页 ${sender.tab.id}` : '扩展页面');
  
  if (message.action === 'getGoldPrice') {
    // 异步处理
    fetchGoldPrice().then(data => {
      sendResponse({ success: true, data });
    }).catch(error => {
      sendResponse({ success: false, error: error.message });
    });
    
    // 返回 true 表示异步响应
    return true;
  }
  
  if (message.action === 'openOptionsPage') {
    chrome.runtime.openOptionsPage();
  }
});

// 监听标签页更新
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
  if (changeInfo.status === 'complete' && tab.url) {
    console.log('页面加载完成:', tab.url);
    // 可以在这里注入 Content Script 或执行其他操作
  }
});

// 使用 Alarms API 实现定时任务
chrome.alarms.create('fetchGoldPrice', {
  periodInMinutes: 30  // 每 30 分钟执行一次
});

chrome.alarms.onAlarm.addListener((alarm) => {
  if (alarm.name === 'fetchGoldPrice') {
    console.log('定时获取黄金价格');
    fetchGoldPrice();
  }
});

// 数据获取函数
async function fetchGoldPrice() {
  try {
    const response = await fetch('https://api.example.com/gold-price');
    const data = await response.json();
    
    // 保存到 storage
    await chrome.storage.local.set({ goldPrice: data });
    
    // 更新徽章
    chrome.action.setBadgeText({ 
      text: data.price.toString() 
    });
    
    return data;
  } catch (error) {
    console.error('获取数据失败:', error);
    throw error;
  }
}

高级用法:长连接

typescript 复制代码
// Background Script
chrome.runtime.onConnect.addListener((port) => {
  console.log('建立连接:', port.name);
  
  port.onMessage.addListener((message) => {
    console.log('收到消息:', message);
    // 处理消息
    port.postMessage({ response: 'processed' });
  });
  
  port.onDisconnect.addListener(() => {
    console.log('连接断开');
  });
});

// Popup 或 Content Script
const port = chrome.runtime.connect({ name: 'my-channel' });
port.postMessage({ action: 'subscribe' });
port.onMessage.addListener((message) => {
  console.log('收到推送:', message);
});

2. Content Script(内容脚本)

Content Script 运行在网页上下文中,可以读取和修改页面 DOM,但有独立的 JavaScript 运行环境。

特性与限制

可以做:

  • ✅ 访问和修改页面 DOM
  • ✅ 监听页面事件
  • ✅ 使用部分 Chrome API(runtime、storage、i18n 等)
  • ✅ 与 Background Script 通信

不能做:

  • ❌ 访问页面的 JavaScript 变量和函数
  • ❌ 使用大部分 Chrome API(tabs、windows、bookmarks 等)
  • ❌ 跨域请求(需要通过 Background)

React 集成示例

typescript 复制代码
// src/pages/content/index.tsx
import { createRoot } from 'react-dom/client';
import React, { useState, useEffect } from 'react';
import './style.css';

// 创建容器
const container = document.createElement('div');
container.id = 'gold-price-extension-root';
document.body.appendChild(container);

// React 组件
function GoldPriceWidget() {
  const [price, setPrice] = useState<number | null>(null);
  const [visible, setVisible] = useState(false);
  
  useEffect(() => {
    // 从 storage 获取价格
    chrome.storage.local.get(['goldPrice'], (result) => {
      if (result.goldPrice) {
        setPrice(result.goldPrice.price);
      }
    });
    
    // 监听 storage 变化
    chrome.storage.onChanged.addListener((changes) => {
      if (changes.goldPrice) {
        setPrice(changes.goldPrice.newValue.price);
      }
    });
  }, []);
  
  if (!visible) {
    return (
      <button 
        className="gold-price-toggle"
        onClick={() => setVisible(true)}
      >
        💰
      </button>
    );
  }
  
  return (
    <div className="gold-price-widget">
                // 。。。
    </div>
  );
}

// 渲染
const root = createRoot(container);
root.render(<GoldPriceWidget />);

// 监听页面 DOM 变化
const observer = new MutationObserver((mutations) => {
  mutations.forEach((mutation) => {
    // 检测特定元素并进行处理
    if (mutation.type === 'childList') {
      const targetElement = document.querySelector('.target-class');
      if (targetElement) {
        // 在目标元素上添加功能
        addCustomFeature(targetElement);
      }
    }
  });
});

observer.observe(document.body, {
  childList: true,
  subtree: true
});

// 自定义功能
function addCustomFeature(element: Element) {
  const button = document.createElement('button');
  button.textContent = '查看金价';
  button.addEventListener('click', () => {
    chrome.runtime.sendMessage({ action: 'openPopup' });
  });
  element.appendChild(button);
}

CSS 隔离

css 复制代码
/* src/pages/content/style.css */

/* 使用唯一的类名前缀,避免与页面样式冲突 */
.gold-price-extension-root {
  all: initial; /* 重置所有样式 */
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}

.gold-price-widget {
  position: fixed;
  bottom: 20px;
  right: 20px;
  width: 300px;
  background: white;
  border-radius: 12px;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
  z-index: 999999; /* 确保在最上层 */
  padding: 16px;
}

/* 使用 CSS-in-JS 也是不错的选择 */

与页面脚本通信

Content Script 和页面脚本运行在不同的上下文中,需要通过 window.postMessage 通信:

typescript 复制代码
// Content Script → 页面
window.postMessage({
  type: 'FROM_EXTENSION',
  action: 'getData'
}, '*');

// 页面 → Content Script
window.addEventListener('message', (event) => {
  if (event.source !== window) return;
  
  if (event.data.type === 'FROM_PAGE') {
    console.log('来自页面的消息:', event.data);
    
    // 转发到 Background
    chrome.runtime.sendMessage(event.data);
  }
});

3. Popup(弹窗页面)

Popup 是用户与扩展交互的主要界面,点击工具栏图标时弹出。

特点

  • ⏱️ 临时性:关闭后页面和状态会被销毁
  • 📏 尺寸限制:最小 25x25px,最大 800x600px
  • 快速加载:应该在 1 秒内完成初始化

完整示例

typescript 复制代码
// src/pages/popup/Popup.tsx
import React, { useEffect, useState } from "react";
import { GoldPriceData } from "./types";
import { fetchGoldPriceData, refreshGoldPriceData } from "./services/goldPriceService";
import { JewelryCard } from "./components/JewelryCard";
import { BankCard } from "./components/BankCard";
import { LoadingSpinner } from "./components/LoadingSpinner";

export default function Popup() {
  const [data, setData] = useState<GoldPriceData | null>(null);
  const [loading, setLoading] = useState(true);
  const [refreshing, setRefreshing] = useState(false);
  const [activeTab, setActiveTab] = useState<"jewelry" | "bank">("jewelry");

  useEffect(() => {
    loadData();
  }, []);

  const loadData = async () => {
    try {
      setLoading(true);
      const goldData = await fetchGoldPriceData();
      setData(goldData);
    } catch (error) {
      console.error("加载数据失败:", error);
    } finally {
      setLoading(false);
    }
  };

  const handleRefresh = async () => {
    try {
      setRefreshing(true);
      const goldData = await refreshGoldPriceData();
      setData(goldData);
    } catch (error) {
      console.error("刷新数据失败:", error);
    } finally {
      setRefreshing(false);
    }
  };

  if (loading) {
    return (
      <div className="w-[400px] h-[600px] flex items-center justify-center">
        <LoadingSpinner />
      </div>
    );
  }

  return (
    <div className="w-[400px] h-[600px] bg-gradient-to-br from-amber-50 via-yellow-50 to-orange-50 flex flex-col">
      {/* 头部 */}
      <header className="bg-white/60 backdrop-blur-md shadow-sm px-4 py-3 flex items-center justify-between">
        <h1 className="text-base font-semibold">每日金价</h1>
        <button
          onClick={handleRefresh}
          disabled={refreshing}
          className="p-1.5 rounded-full hover:bg-amber-100/50 transition-colors"
        >
          <RefreshIcon spinning={refreshing} />
        </button>
      </header>

      {/* 标签切换 */}
      <div className="flex border-b border-amber-100">
        <button
          onClick={() => setActiveTab("jewelry")}
          className={`flex-1 py-2.5 text-sm ${
            activeTab === "jewelry" ? "text-amber-600" : "text-gray-500"
          }`}
        >
          金饰品
        </button>
        <button
          onClick={() => setActiveTab("bank")}
          className={`flex-1 py-2.5 text-sm ${
            activeTab === "bank" ? "text-amber-600" : "text-gray-500"
          }`}
        >
          银行金条
        </button>
      </div>

      {/* 内容区域 */}
      <div className="flex-1 overflow-y-auto p-3">
        {activeTab === "jewelry" ? (
          <div className="grid grid-cols-2 gap-2">
            {data?.jewelryBrands.map((brand) => (
              <JewelryCard key={brand.id} brand={brand} />
            ))}
          </div>
        ) : (
          <div className="flex flex-col gap-2">
            {data?.bankGoldBars.map((bank) => (
              <BankCard key={bank.id} bank={bank} />
            ))}
          </div>
        )}
      </div>

      {/* 底部信息 */}
      <footer className="px-4 py-2 text-center">
        <p className="text-xs text-gray-500">更新时间: {data?.lastUpdate}</p>
      </footer>
    </div>
  );
}

Popup 设计原则:

  • 🎯 单一目标:每个 Popup 应该有明确的用途
  • 快速响应:优先显示缓存数据,后台刷新
  • 📱 简洁 UI:空间有限,避免复杂交互
  • 💾 状态管理 :使用 chrome.storage 持久化重要状态

组件间通信机制

浏览器扩展的各个组件之间需要频繁通信,Chrome 提供了多种通信方式。

1. 一次性消息传递

适用场景: 简单的请求-响应模式

Content Script → Background

typescript 复制代码
// Content Script
chrome.runtime.sendMessage(
  { action: 'getPrice', item: 'gold' },
  (response) => {
    if (response.success) {
      console.log('价格:', response.data);
    }
  }
);

// Background Script
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
  if (request.action === 'getPrice') {
    fetchPrice(request.item).then(price => {
      sendResponse({ success: true, data: price });
    }).catch(error => {
      sendResponse({ success: false, error: error.message });
    });
    
    // 必须返回 true 表示异步响应
    return true;
  }
});
typescript 复制代码
// Popup
const response = await chrome.runtime.sendMessage({
  action: 'getData'
});

// 或者使用 callback
chrome.runtime.sendMessage(
  { action: 'getData' },
  (response) => {
    console.log(response);
  }
);

Background → Content Script

typescript 复制代码
// Background: 向特定标签页发送消息
chrome.tabs.sendMessage(
  tabId,
  { action: 'updatePrice', price: 520 },
  (response) => {
    console.log('Content Script 响应:', response);
  }
);

// Background: 向所有标签页广播
chrome.tabs.query({}, (tabs) => {
  tabs.forEach(tab => {
    if (tab.id) {
      chrome.tabs.sendMessage(tab.id, { action: 'refresh' });
    }
  });
});

// Content Script: 监听消息
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
  if (request.action === 'updatePrice') {
    updatePriceDisplay(request.price);
    sendResponse({ updated: true });
  }
});

2. 长连接(Long-lived Connections)

适用场景: 需要持续通信、实时推送数据

typescript 复制代码
// Content Script: 建立连接
const port = chrome.runtime.connect({ name: 'price-updates' });

// 发送消息
port.postMessage({ action: 'subscribe', symbol: 'GOLD' });

// 接收消息
port.onMessage.addListener((message) => {
  console.log('收到更新:', message);
  updateUI(message.price);
});

// 监听断开
port.onDisconnect.addListener(() => {
  console.log('连接已断开');
});

// Background Script: 监听连接
const connections = new Map();

chrome.runtime.onConnect.addListener((port) => {
  console.log('新连接:', port.name);
  
  connections.set(port.name, port);
  
  port.onMessage.addListener((message) => {
    if (message.action === 'subscribe') {
      // 开始推送数据
      startPriceUpdates(port, message.symbol);
    }
  });
  
  port.onDisconnect.addListener(() => {
    connections.delete(port.name);
  });
});

function startPriceUpdates(port, symbol) {
  const intervalId = setInterval(() => {
    fetchPrice(symbol).then(price => {
      try {
        port.postMessage({ price, symbol, timestamp: Date.now() });
      } catch (e) {
        // 端口已断开
        clearInterval(intervalId);
      }
    });
  }, 5000);
}

3. Storage 变化监听

适用场景: 多个组件共享状态

typescript 复制代码
// 任意组件:设置数据
chrome.storage.local.set({ goldPrice: 520 });

// 任意组件:监听变化
chrome.storage.onChanged.addListener((changes, areaName) => {
  if (areaName === 'local' && changes.goldPrice) {
    console.log('价格更新:');
    console.log('  旧值:', changes.goldPrice.oldValue);
    console.log('  新值:', changes.goldPrice.newValue);
    
    // 更新 UI
    updatePriceDisplay(changes.goldPrice.newValue);
  }
});

4. 消息传递最佳实践

typescript 复制代码
// 定义消息类型(TypeScript)
interface Message {
  action: string;
  data?: any;
}

interface Response {
  success: boolean;
  data?: any;
  error?: string;
}

// 封装消息发送
async function sendMessage(message: Message): Promise<Response> {
  return new Promise((resolve, reject) => {
    chrome.runtime.sendMessage(message, (response) => {
      if (chrome.runtime.lastError) {
        reject(new Error(chrome.runtime.lastError.message));
      } else {
        resolve(response);
      }
    });
  });
}

// 使用
try {
  const response = await sendMessage({
    action: 'getPrice',
    data: { symbol: 'GOLD' }
  });
  
  if (response.success) {
    console.log(response.data);
  }
} catch (error) {
  console.error('通信失败:', error);
}

数据存储方案

1. chrome.storage API

Chrome Extension 提供了专门的存储 API,比 localStorage 更强大。

存储类型对比

存储类型 容量 同步 用途
storage.local 10 MB 本地数据缓存
storage.sync 100 KB 用户设置(跨设备同步)
storage.session 10 MB 临时会话数据(MV3)

基础用法

typescript 复制代码
// 保存数据
await chrome.storage.local.set({
  user: { name: 'John', age: 30 },
  settings: { theme: 'dark' },
  prices: [520, 530, 525]
});

// 读取数据
const result = await chrome.storage.local.get(['user', 'settings']);
console.log(result.user);      // { name: 'John', age: 30 }
console.log(result.settings);  // { theme: 'dark' }

// 读取所有数据
const allData = await chrome.storage.local.get(null);

// 删除数据
await chrome.storage.local.remove(['user']);

// 清空所有数据
await chrome.storage.local.clear();

// 获取存储使用量
const bytes = await chrome.storage.local.getBytesInUse(['prices']);
console.log(`使用了 ${bytes} 字节`);

封装 Storage 工具类

typescript 复制代码
// utils/storage.ts
class StorageManager {
  async get<T>(key: string): Promise<T | null> {
    const result = await chrome.storage.local.get(key);
    return result[key] ?? null;
  }

  async set<T>(key: string, value: T): Promise<void> {
    await chrome.storage.local.set({ [key]: value });
  }

  async remove(key: string): Promise<void> {
    await chrome.storage.local.remove(key);
  }

  async getMultiple<T extends Record<string, any>>(
    keys: string[]
  ): Promise<Partial<T>> {
    return await chrome.storage.local.get(keys);
  }

  async setMultiple(items: Record<string, any>): Promise<void> {
    await chrome.storage.local.set(items);
  }

  onChange(callback: (changes: any, areaName: string) => void): void {
    chrome.storage.onChanged.addListener(callback);
  }
}

export const storage = new StorageManager();

// 使用
import { storage } from './utils/storage';

// 保存
await storage.set('goldPrice', { price: 520, timestamp: Date.now() });

// 读取
const goldPrice = await storage.get<{ price: number; timestamp: number }>('goldPrice');

// 监听变化
storage.onChange((changes, areaName) => {
  if (changes.goldPrice) {
    console.log('价格更新:', changes.goldPrice.newValue);
  }
});

2. IndexedDB(大量数据)

对于需要存储大量结构化数据的场景,可以使用 IndexedDB。

typescript 复制代码
// utils/indexedDB.ts
class GoldPriceDB {
  private dbName = 'GoldPriceExtension';
  private version = 1;
  private db: IDBDatabase | null = null;

  async init(): Promise<void> {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.dbName, this.version);

      request.onerror = () => reject(request.error);
      request.onsuccess = () => {
        this.db = request.result;
        resolve();
      };

      request.onupgradeneeded = (event) => {
        const db = (event.target as IDBOpenDBRequest).result;
        
        // 创建对象存储
        if (!db.objectStoreNames.contains('prices')) {
          const store = db.createObjectStore('prices', {
            keyPath: 'id',
            autoIncrement: true
          });
          
          // 创建索引
          store.createIndex('timestamp', 'timestamp', { unique: false });
          store.createIndex('brand', 'brand', { unique: false });
        }
      };
    });
  }

  async addPrice(price: any): Promise<number> {
    const transaction = this.db!.transaction(['prices'], 'readwrite');
    const store = transaction.objectStore('prices');
    
    return new Promise((resolve, reject) => {
      const request = store.add(price);
      request.onsuccess = () => resolve(request.result as number);
      request.onerror = () => reject(request.error);
    });
  }

  async getPrices(limit = 100): Promise<any[]> {
    const transaction = this.db!.transaction(['prices'], 'readonly');
    const store = transaction.objectStore('prices');
    
    return new Promise((resolve, reject) => {
      const request = store.getAll(undefined, limit);
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }

  async getPricesByBrand(brand: string): Promise<any[]> {
    const transaction = this.db!.transaction(['prices'], 'readonly');
    const store = transaction.objectStore('prices');
    const index = store.index('brand');
    
    return new Promise((resolve, reject) => {
      const request = index.getAll(brand);
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }
}

export const goldPriceDB = new GoldPriceDB();

// 使用
await goldPriceDB.init();
await goldPriceDB.addPrice({
  brand: '周大福',
  price: 520,
  timestamp: Date.now()
});

const prices = await goldPriceDB.getPricesByBrand('周大福');

实战案例:黄金价格查询扩展

让我们深入分析本项目的完整实现。

1. 项目架构

复制代码
数据流向:
API → Service Layer → Storage → UI Components
 ↓         ↓             ↓          ↓
外部数据  业务逻辑     缓存层    用户界面

2. 类型定义系统

typescript 复制代码
// src/pages/popup/types.ts

/** 金饰品品牌价格 */
export interface JewelryBrand {
  id: string;
  name: string;        // 品牌名称
  price: number;       // 价格(元/克)
  change: number;      // 涨跌幅度(元)
  updateTime: string;  // 更新时间
}

/** 银行金条价格 */
export interface BankGoldBar {
  id: string;
  bankName: string;    // 银行名称
  buyPrice: number;    // 买入价(元/克)
  sellPrice: number;   // 卖出价(元/克)
  change: number;      // 涨跌幅度(元)
  updateTime: string;  // 更新时间
}

/** 黄金价格数据 */
export interface GoldPriceData {
  jewelryBrands: JewelryBrand[];
  bankGoldBars: BankGoldBar[];
  lastUpdate: string;
}

/** API 响应类型 */
export interface BrandPriceData {
  brand: string;
  goldPrice: number;
  platinumPrice: number | string;
}

export interface PriceResponse {
  status: string;
  timestamp: string;
  data: BrandPriceData[];
}

3. Service Layer实现

typescript 复制代码
// src/pages/popup/services/goldPriceService.ts

const API_URL = "https://chatbot-be-omega.vercel.app/api/goldPrice";

// 定义品牌分类
const JEWELRY_BRANDS = [
  "周大福", "老凤祥", "周六福", "周生生", 
  "六福珠宝", "老庙", "金至尊", "菜百"
  // ... 更多品牌
];

const BANK_BRANDS = ["中国黄金", "高赛尔"];

/**
 * 从 API 获取黄金价格数据
 */
export const fetchGoldPriceFromAPI = async (): Promise<PriceResponse> => {
  try {
    const response = await fetch(API_URL, {
      method: "GET",
      headers: { "Content-Type": "application/json" },
    });

    if (!response.ok) {
      throw new Error(`API 请求失败: ${response.status}`);
    }

    const data: PriceResponse = await response.json();

    if (data.status !== "ok") {
      throw new Error("API 返回状态异常");
    }

    return data;
  } catch (error) {
    console.error("获取黄金价格失败:", error);
    throw error;
  }
};

/**
 * 将 API 数据转换为应用格式
 */
const transformAPIData = (apiResponse: PriceResponse): GoldPriceData => {
  // 转换金饰品数据
  const jewelryBrands: JewelryBrand[] = apiResponse.data
    .filter(item => JEWELRY_BRANDS.includes(item.brand))
    .map((item, index) => ({
      id: `jewelry-${index}`,
      name: item.brand,
      price: item.goldPrice,
      change: 0,
      updateTime: new Date(apiResponse.timestamp).toLocaleString("zh-CN", {
        month: "2-digit",
        day: "2-digit",
        hour: "2-digit",
        minute: "2-digit",
      }),
    }));

  // 转换银行金条数据
  const bankGoldBars: BankGoldBar[] = apiResponse.data
    .filter(item => BANK_BRANDS.includes(item.brand))
    .map((item, index) => ({
      id: `bank-${index}`,
      bankName: item.brand,
      buyPrice: item.goldPrice,
      sellPrice: typeof item.platinumPrice === "number" 
        ? item.platinumPrice 
        : item.goldPrice,
      change: 0,
      updateTime: new Date(apiResponse.timestamp).toLocaleString("zh-CN", {
        month: "2-digit",
        day: "2-digit",
        hour: "2-digit",
        minute: "2-digit",
      }),
    }));

  const lastUpdate = new Date(apiResponse.timestamp).toLocaleString("zh-CN", {
    year: "numeric",
    month: "2-digit",
    day: "2-digit",
    hour: "2-digit",
    minute: "2-digit",
  });

  return { jewelryBrands, bankGoldBars, lastUpdate };
};

/**
 * 获取黄金价格数据(对外接口)
 * 带缓存机制
 */
export const fetchGoldPriceData = async (): Promise<GoldPriceData> => {
  try {
    // 先尝试从缓存读取
    const cached = await chrome.storage.local.get(['goldPriceData', 'cacheTime']);
    const now = Date.now();
    const cacheAge = now - (cached.cacheTime || 0);
    
    // 缓存有效期:5 分钟
    if (cached.goldPriceData && cacheAge < 5 * 60 * 1000) {
      console.log('使用缓存数据');
      return cached.goldPriceData;
    }

    // 缓存过期,重新获取
    console.log('获取新数据');
    const apiResponse = await fetchGoldPriceFromAPI();
    const data = transformAPIData(apiResponse);

    // 保存到缓存
    await chrome.storage.local.set({
      goldPriceData: data,
      cacheTime: now
    });

    return data;
  } catch (error) {
    console.error("获取并转换黄金价格数据失败:", error);
    
    // 如果有缓存数据,即使过期也返回
    const cached = await chrome.storage.local.get(['goldPriceData']);
    if (cached.goldPriceData) {
      console.log('API 失败,使用过期缓存');
      return cached.goldPriceData;
    }
    
    throw error;
  }
};

/**
 * 强制刷新数据
 */
export const refreshGoldPriceData = async (): Promise<GoldPriceData> => {
  // 清除缓存时间,强制重新获取
  await chrome.storage.local.remove(['cacheTime']);
  return fetchGoldPriceData();
};

4. UI 组件实现

typescript 复制代码
// src/pages/popup/components/JewelryCard.tsx
import React from "react";
import { JewelryBrand } from "../types";

interface JewelryCardProps {
  brand: JewelryBrand;
}

export const JewelryCard: React.FC<JewelryCardProps> = ({ brand }) => {
  const isPositive = brand.change >= 0;

  return (
    <div className="bg-white/80 backdrop-blur-sm rounded-lg p-3 shadow-sm hover:shadow-md transition-shadow">
      {/* 品牌名和涨跌 */}
      <div className="flex items-center justify-between mb-2">
        <h3 className="text-sm font-medium text-gray-800">{brand.name}</h3>
        {brand.change !== 0 && (
          <span
            className={`text-xs px-2 py-0.5 rounded-full ${
              isPositive 
                ? "bg-red-50 text-red-600" 
                : "bg-green-50 text-green-600"
            }`}
          >
            {isPositive ? "+" : ""}{brand.change}
          </span>
        )}
      </div>
      
      {/* 价格显示 */}
      <div className="flex items-baseline">
        <span className="text-xl font-semibold text-amber-600">
          ¥{brand.price}
        </span>
        <span className="text-xs text-gray-500 ml-1">/克</span>
      </div>
      
      {/* 更新时间 */}
      <div className="mt-1 text-xs text-gray-400">
        {brand.updateTime}
      </div>
    </div>
  );
};
typescript 复制代码
// src/pages/popup/components/BankCard.tsx
import React from "react";
import { BankGoldBar } from "../types";

interface BankCardProps {
  bank: BankGoldBar;
}

export const BankCard: React.FC<BankCardProps> = ({ bank }) => {
  const isPositive = bank.change >= 0;

  return (
    <div className="bg-white/80 backdrop-blur-sm rounded-lg p-3 shadow-sm hover:shadow-md transition-shadow">
      {/* 银行名和涨跌 */}
      <div className="flex items-center justify-between mb-2">
        <h3 className="text-sm font-medium text-gray-800">{bank.bankName}</h3>
        {bank.change !== 0 && (
          <span
            className={`text-xs px-2 py-0.5 rounded-full ${
              isPositive 
                ? "bg-red-50 text-red-600" 
                : "bg-green-50 text-green-600"
            }`}
          >
            {isPositive ? "+" : ""}{bank.change}
          </span>
        )}
      </div>
      
      {/* 买入/卖出价格 */}
      <div className="flex justify-between items-center">
        <div className="flex flex-col">
          <span className="text-xs text-gray-500 mb-1">买入</span>
          <span className="text-base font-semibold text-blue-600">
            ¥{bank.buyPrice}
          </span>
        </div>
        <div className="h-6 w-px bg-gray-200"></div>
        <div className="flex flex-col items-end">
          <span className="text-xs text-gray-500 mb-1">卖出</span>
          <span className="text-base font-semibold text-purple-600">
            ¥{bank.sellPrice}
          </span>
        </div>
      </div>
      
      {/* 更新时间 */}
      <div className="mt-2 text-xs text-gray-400 text-center">
        {bank.updateTime}
      </div>
    </div>
  );
};

5. 关键技术点

错误处理与降级策略

typescript 复制代码
// 多层次的错误处理
async function fetchWithRetry(url: string, retries = 3): Promise<Response> {
  for (let i = 0; i < retries; i++) {
    try {
      const response = await fetch(url);
      if (response.ok) return response;
      
      if (i === retries - 1) throw new Error(`请求失败: ${response.status}`);
    } catch (error) {
      if (i === retries - 1) throw error;
      
      // 指数退避
      await new Promise(resolve => setTimeout(resolve, Math.pow(2, i) * 1000));
    }
  }
  throw new Error('Max retries reached');
}

// 使用
try {
  const response = await fetchWithRetry(API_URL);
  const data = await response.json();
} catch (error) {
  // 降级到缓存数据
  const cached = await getCachedData();
  if (cached) return cached;
  
  // 显示错误提示
  showErrorNotification(error);
}

性能优化

typescript 复制代码
// 1. 防抖刷新
function debounce<T extends (...args: any[]) => any>(
  func: T,
  wait: number
): (...args: Parameters<T>) => void {
  let timeout: NodeJS.Timeout;
  return function(...args: Parameters<T>) {
    clearTimeout(timeout);
    timeout = setTimeout(() => func(...args), wait);
  };
}

const debouncedRefresh = debounce(refreshGoldPriceData, 1000);

// 2. 数据预加载
chrome.runtime.onInstalled.addListener(() => {
  // 安装时预先获取数据
  fetchGoldPriceData().catch(console.error);
});

// 3. 后台定时更新
chrome.alarms.create('updatePrices', {
  periodInMinutes: 30
});

chrome.alarms.onAlarm.addListener((alarm) => {
  if (alarm.name === 'updatePrices') {
    fetchGoldPriceData().catch(console.error);
  }
});

跨浏览器兼容性处理

1. 使用 webextension-polyfill

typescript 复制代码
// 安装
npm install webextension-polyfill
npm install --save-dev @types/webextension-polyfill

// 使用
import Browser from 'webextension-polyfill';

// 统一的 Promise 风格 API
const data = await Browser.storage.local.get('key');
const tabs = await Browser.tabs.query({ active: true });

// 自动处理回调转 Promise
Browser.runtime.sendMessage({ action: 'test' })
  .then(response => console.log(response))
  .catch(error => console.error(error));

2. Vite 配置差异

typescript 复制代码
// vite.config.chrome.ts
export default defineConfig({
  plugins: [
    crx({
      manifest: {
        ...baseManifest,
        background: {
          service_worker: 'src/pages/background/index.ts',  // Chrome: Service Worker
          type: 'module'
        },
      },
      browser: 'chrome',
    })
  ],
});

// vite.config.firefox.ts
export default defineConfig({
  plugins: [
    crx({
      manifest: {
        ...baseManifest,
        background: {
          scripts: ['src/pages/background/index.ts']  // Firefox: Scripts Array
        },
      },
      browser: 'firefox',
    })
  ],
});

3. 浏览器特性检测

typescript 复制代码
// utils/browser.ts
export const getBrowserInfo = () => {
  const userAgent = navigator.userAgent;
  
  if (userAgent.includes('Edg/')) {
    return { name: 'Edge', isChromium: true };
  } else if (userAgent.includes('Chrome')) {
    return { name: 'Chrome', isChromium: true };
  } else if (userAgent.includes('Firefox')) {
    return { name: 'Firefox', isChromium: false };
  } else if (userAgent.includes('Safari')) {
    return { name: 'Safari', isChromium: false };
  }
  
  return { name: 'Unknown', isChromium: false };
};

// 根据浏览器调整行为
export const isSidePanel Supported = () => {
  const { name } = getBrowserInfo();
  return name === 'Chrome' || name === 'Edge';
};

// 条件性使用 API
if ('sidePanel' in chrome) {
  chrome.sidePanel.setOptions({ path: 'panel.html' });
}

调试技巧与最佳实践

1. 调试工具

Background Script 调试

bash 复制代码
# Chrome
1. 打开 chrome://extensions
2. 找到你的扩展
3. 点击 "service worker" 或 "背景页"
4. 打开 DevTools 控制台

# Firefox
1. 打开 about:debugging
2. 点击 "检查"
3. 打开浏览器控制台

Content Script 调试

bash 复制代码
# 直接在网页中调试
1. 打开目标网页
2. 按 F12 打开开发者工具
3. 在 Console 中可以看到 Content Script 的日志
4. 在 Sources/Debugger 标签可以找到注入的脚本
bash 复制代码
# Chrome
1. 右键点击扩展图标
2. 选择 "检查弹出内容"
3. DevTools 会附加到 Popup

# Firefox
1. 右键点击扩展图标
2. 选择 "调试此扩展"

2. 日志系统

typescript 复制代码
// utils/logger.ts
enum LogLevel {
  DEBUG = 0,
  INFO = 1,
  WARN = 2,
  ERROR = 3
}

class Logger {
  private level: LogLevel = LogLevel.INFO;
  private prefix: string;

  constructor(prefix: string) {
    this.prefix = `[${prefix}]`;
  }

  setLevel(level: LogLevel) {
    this.level = level;
  }

  debug(...args: any[]) {
    if (this.level <= LogLevel.DEBUG) {
      console.log(this.prefix, '[DEBUG]', ...args);
    }
  }

  info(...args: any[]) {
    if (this.level <= LogLevel.INFO) {
      console.info(this.prefix, '[INFO]', ...args);
    }
  }

  warn(...args: any[]) {
    if (this.level <= LogLevel.WARN) {
      console.warn(this.prefix, '[WARN]', ...args);
    }
  }

  error(...args: any[]) {
    if (this.level <= LogLevel.ERROR) {
      console.error(this.prefix, '[ERROR]', ...args);
    }
  }
}

export const logger = new Logger('GoldPrice');

// 使用
logger.info('获取价格数据');
logger.error('API 请求失败', error);

3. 性能监控

typescript 复制代码
// 性能计时
const startTime = performance.now();
await fetchGoldPriceData();
const endTime = performance.now();
console.log(`数据获取耗时: ${(endTime - startTime).toFixed(2)}ms`);

// 使用 Performance API
performance.mark('fetch-start');
await fetchData();
performance.mark('fetch-end');
performance.measure('fetch-duration', 'fetch-start', 'fetch-end');

const measures = performance.getEntriesByName('fetch-duration');
console.log(`数据获取耗时: ${measures[0].duration}ms`);

性能优化策略

1. Content Script 优化

typescript 复制代码
// ❌ 不好:阻塞主线程
document.querySelectorAll('.item').forEach(item => {
  // 大量 DOM 操作
  item.innerHTML = generateComplexHTML();
});

// ✅ 好:批量操作,使用 DocumentFragment
const fragment = document.createDocumentFragment();
document.querySelectorAll('.item').forEach(item => {
  const newElement = document.createElement('div');
  newElement.innerHTML = generateComplexHTML();
  fragment.appendChild(newElement);
});
document.body.appendChild(fragment);

// ✅ 更好:使用 IntersectionObserver 延迟加载
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      enhanceElement(entry.target);
      observer.unobserve(entry.target);
    }
  });
});

document.querySelectorAll('.item').forEach(item => {
  observer.observe(item);
});

2. 资源优化

typescript 复制代码
// 代码分割
const loadHeavyFeature = async () => {
  const module = await import('./heavy-feature');
  module.initialize();
};

// 图片懒加载
const imageUrl = chrome.runtime.getURL('images/large-image.png');
const img = new Image();
img.loading = 'lazy';
img.src = imageUrl;

// 压缩数据
import pako from 'pako';

// 保存时压缩
const compressed = pako.deflate(JSON.stringify(largeData));
await chrome.storage.local.set({ data: compressed });

// 读取时解压
const { data } = await chrome.storage.local.get('data');
const decompressed = JSON.parse(pako.inflate(data, { to: 'string' }));

3. 内存管理

typescript 复制代码
// 及时清理监听器
let listener: ((changes: any) => void) | null = null;

function startMonitoring() {
  listener = (changes) => {
    console.log('Storage changed:', changes);
  };
  chrome.storage.onChanged.addListener(listener);
}

function stopMonitoring() {
  if (listener) {
    chrome.storage.onChanged.removeListener(listener);
    listener = null;
  }
}

// 限制缓存大小
async function addToCache(key: string, data: any) {
  const cache = await chrome.storage.local.get('cache') || {};
  const cacheKeys = Object.keys(cache);
  
  // 最多保留 100 条
  if (cacheKeys.length >= 100) {
    const oldest = cacheKeys[0];
    delete cache[oldest];
  }
  
  cache[key] = data;
  await chrome.storage.local.set({ cache });
}

打包构建与发布

1. 生产构建

bash 复制代码
# 构建 Chrome 版本
npm run build:chrome

# 构建 Firefox 版本
npm run build:firefox

# 输出目录
# dist_chrome/   # Chrome 扩展文件
# dist_firefox/  # Firefox 扩展文件

2. 打包为 ZIP

bash 复制代码
# 手动打包
cd dist_chrome
zip -r ../extension-chrome.zip *

cd ../dist_firefox
zip -r ../extension-firefox.zip *

# 或使用脚本
npm install --save-dev archiver

// scripts/zip.js
const archiver = require('archiver');
const fs = require('fs');

function zipDirectory(source, out) {
  const archive = archiver('zip', { zlib: { level: 9 } });
  const stream = fs.createWriteStream(out);

  return new Promise((resolve, reject) => {
    archive
      .directory(source, false)
      .on('error', reject)
      .pipe(stream);

    stream.on('close', resolve);
    archive.finalize();
  });
}

zipDirectory('./dist_chrome', './extension-chrome.zip');

3. 发布到 Chrome Web Store

准备工作

  1. 注册开发者账号

  2. 准备资料

    • 扩展 ZIP 文件
    • 图标(128x128px)
    • 截图(至少 1 张,1280x800 或 640x400)
    • 推广图片(可选,440x280px)
    • 详细描述(英文,最多 132 字符简介 + 详细说明)

发布流程

bash 复制代码
1. 登录 Developer Dashboard
2. 点击 "New Item"
3. 上传 ZIP 文件
4. 填写商店信息:
   - 名称和描述
   - 类别和语言
   - 截图和图标
   - 隐私政策(如果使用了用户数据)
5. 定价和分发:
   - 免费或付费
   - 发布地区
6. 提交审核

审核时间: 通常 1-3 天

4. 发布到 Firefox Add-ons

准备工作

  1. 注册账号(免费)

  2. 签名要求

    • Firefox 要求所有扩展必须签名
    • 可以选择自动签名(不上架)或完整审核(上架)

发布流程

bash 复制代码
1. 登录 Developer Hub
2. Submit a New Add-on
3. 选择分发方式:
   - On this site (完整审核,公开发布)
   - On your own (自动签名,自行分发)
4. 上传 ZIP 文件
5. 填写信息:
   - 名称、描述、分类
   - 版本说明
   - 许可证
   - 隐私政策
6. 提交审核

审核时间: 人工审核通常 1-5 天

5. GitHub Actions 自动化

yaml 复制代码
# .github/workflows/release.yml
name: Build and Release

on:
  push:
    tags:
      - 'v*'

jobs:
  build:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '20'
      
      - name: Install dependencies
        run: npm install
      
      - name: Build Chrome
        run: npm run build:chrome
      
      - name: Build Firefox
        run: npm run build:firefox
      
      - name: Create ZIP files
        run: |
          cd dist_chrome && zip -r ../extension-chrome.zip * && cd ..
          cd dist_firefox && zip -r ../extension-firefox.zip * && cd ..
      
      - name: Create Release
        uses: softprops/action-gh-release@v1
        with:
          files: |
            extension-chrome.zip
            extension-firefox.zip
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

常见问题与解决方案

1. Manifest 错误

问题: Manifest version 3 is not supported

解决:

  • 确保 Chrome 版本 >= 88
  • Firefox 需要特殊配置支持 MV3

问题: Permission 'tabs' is not recognized

解决:

json 复制代码
// ❌ 错误
{
  "permissions": ["tabs", "https://*/*"]
}

// ✅ 正确
{
  "permissions": ["tabs"],
  "host_permissions": ["https://*/*"]
}

2. Content Script 问题

问题: Content Script 无法访问页面变量

解决:

typescript 复制代码
// ❌ 不能直接访问
console.log(window.pageVariable);

// ✅ 通过 postMessage 通信
window.postMessage({ type: 'GET_DATA' }, '*');
window.addEventListener('message', (e) => {
  if (e.data.type === 'DATA_RESPONSE') {
    console.log(e.data.value);
  }
});

// 页面脚本
window.addEventListener('message', (e) => {
  if (e.data.type === 'GET_DATA') {
    window.postMessage({
      type: 'DATA_RESPONSE',
      value: window.pageVariable
    }, '*');
  }
});

3. CORS 问题

问题: Fetch API cannot load due to CORS policy

解决:

json 复制代码
// manifest.json 添加 host_permissions
{
  "host_permissions": [
    "https://api.example.com/*"
  ]
}
typescript 复制代码
// 或在 Background Script 中代理请求
// Content Script
chrome.runtime.sendMessage({
  action: 'fetch',
  url: 'https://api.example.com/data'
}, (response) => {
  console.log(response);
});

// Background Script
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
  if (request.action === 'fetch') {
    fetch(request.url)
      .then(r => r.json())
      .then(data => sendResponse(data))
      .catch(error => sendResponse({ error: error.message }));
    return true; // 异步响应
  }
});

4. Storage 配额问题

问题: QUOTA_BYTES quota exceeded

解决:

typescript 复制代码
// 检查存储使用量
const bytes = await chrome.storage.local.getBytesInUse();
console.log(`已使用: ${bytes} / ${chrome.storage.local.QUOTA_BYTES}`);

// 定期清理旧数据
async function cleanup() {
  const all = await chrome.storage.local.get(null);
  const keys = Object.keys(all);
  
  // 删除超过 7 天的数据
  const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
  const toRemove = keys.filter(key => {
    const item = all[key];
    return item.timestamp && item.timestamp < sevenDaysAgo;
  });
  
  if (toRemove.length > 0) {
    await chrome.storage.local.remove(toRemove);
    console.log(`清理了 ${toRemove.length} 条过期数据`);
  }
}

5. Service Worker 生命周期

问题: Background Service Worker 意外休眠

解决:

typescript 复制代码
// ❌ 不要依赖全局变量
let cache = {};  // Service Worker 休眠后会丢失

// ✅ 使用 chrome.storage
async function getCache() {
  const result = await chrome.storage.local.get('cache');
  return result.cache || {};
}

async function setCache(cache) {
  await chrome.storage.local.set({ cache });
}

// ❌ 不要使用 setTimeout
setTimeout(() => {
  doSomething();
}, 60000);  // 可能不会执行

// ✅ 使用 chrome.alarms
chrome.alarms.create('task', { delayInMinutes: 1 });
chrome.alarms.onAlarm.addListener((alarm) => {
  if (alarm.name === 'task') {
    doSomething();
  }
});

参考资料

官方文档

工具和库

社区资源

每日金价仓库地址是 github.com/ilcherry/da...

相关推荐
码途进化论2 小时前
从Chrome跳转到IE浏览器的完整解决方案
前端·javascript
笙年2 小时前
Vue 基础配置新手总结
前端·javascript·vue.js
哆啦A梦15882 小时前
40 token
前端·vue.js·node.js
炫饭第一名2 小时前
Cursor 一年深度开发实践:前端开发的效率革命🚀
前端·程序员·ai编程
摇滚侠2 小时前
Vue 项目实战《尚医通》,获取挂号医生的信息展示,笔记43
前端·javascript·vue.js·笔记·html5
晴殇i2 小时前
关于前端基础快速跨入鸿蒙HarmonyOS开发
前端·harmonyos
k09333 小时前
vue3中基于AntDesign的Form嵌套表单的校验
前端·javascript·vue.js
没头脑和不高兴y3 小时前
Element-Plus-X:基于Vue 3的AI交互组件库
前端·javascript
ErMao3 小时前
Proxy 与 Reflect:最硬核、最实用的解释
前端·javascript