如何从 0 到 1 开发一个浏览器插件(AI 赋能)

一、背景介绍

分享目的

旨在帮助有插件开发需求但缺乏相关经验的开发者,能够从零开始完成一个完整的浏览器插件开发。

目标群体

  • 具备一定前端开发基础(无基础建议直接跳转 Part 7
  • 有开发浏览器插件需求但缺乏实战经验
  • 希望了解插件开发全流程的开发者

技术门槛

  • 熟悉基础前端技术栈
  • 了解 Chrome DevTools 使用
  • 具备基本的调试能力

二、插件基本概念和作用

什么是浏览器插件?

浏览器插件(Browser Extension)是一种小型软件程序,用于扩展浏览器的功能。它可以修改浏览器行为、增强网页功能、提供额外的用户界面等。

插件的核心作用

  • 功能增强:为网页添加新功能
  • 用户体验优化:改善页面交互和视觉效果
  • 数据处理:收集、分析、存储网页数据
  • 自动化操作:批量处理重复性任务
  • 安全防护:阻止广告、恶意脚本等

插件架构组成

  • Manifest 文件:插件配置和权限声明
  • Background Script:后台脚本,处理事件和逻辑
  • Content Script:内容脚本,直接操作网页 DOM
  • Popup 页面:点击插件图标弹出的界面
  • Options 页面:插件设置页面

三、技术应用场景

常见应用场景

  1. 页面美化类

    1. 暗黑模式切换
    2. 字体大小调整
    3. 页面元素隐藏/显示
  2. 效率工具类

    1. 密码管理器
    2. 翻译工具
    3. 截图工具
    4. 笔记收集
  3. 数据分析类

    1. 页面性能监控
    2. 用户行为统计
    3. SEO 分析工具
  4. 自动化操作类

    1. 表单自动填充
    2. 定时刷新页面
    3. 批量下载资源

四、核心开发原则

  • 最小权限原则:只申请必需的权限
  • 性能优先:避免影响页面加载速度
  • 用户体验:界面简洁,操作直观
  • 兼容性考虑:支持主流浏览器版本

五、详细开发步骤

技术栈:React+Vite+Arco Design+TypeScript

5.1 开发环境搭建

工具准备

diff 复制代码
- Node.js (>=22.0.0)
- Chrome 浏览器
- Trae

项目初始化

perl 复制代码
# 创建 Vite 项目
npm create vite@latest my-browser-extension -- --template react-ts
cd my-browser-extension

# 安装依赖
npm install

# 安装 Arco Design
npm install @arco-design/web-react

# 安装扩展相关类型定义
npm install --save-dev @types/chrome

# 安装构建工具依赖
npm install --save-dev vite-plugin-web-extension @crxjs/vite-plugin

项目结构调整

bash 复制代码
# 创建扩展相关目录
mkdir src/background
mkdir src/content
mkdir src/popup
mkdir src/options
mkdir public/icons

# 项目最终结构
src/
├── background/
│   └── index.ts
├── content/
│   └── index.ts
├── popup/
│   ├── App.tsx
│   ├── index.tsx
│   └── index.html
├── options/
│   ├── App.tsx
│   ├── index.tsx
│   └── index.html
├── types/
│   └── chrome.d.ts
└── utils/
    └── storage.ts

5.2 Vite 配置 (vite.config.ts)

php 复制代码
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { crx } from '@crxjs/vite-plugin'
import manifest from './manifest.json'

export default defineConfig({
  plugins: [
    react(),
    crx({ manifest })
  ],
  build: {
    rollupOptions: {
      input: {
        popup: 'src/popup/index.html',
        options: 'src/options/index.html'
      },
      output: {
        entryFileNames: 'content/[name].js',
        chunkFileNames: 'content/[name].js',
        assetFileNames: 'assets/[name].[ext]',
      },
    }
  }
})

5.3 Manifest.json 配置

json 复制代码
{
  "manifest_version": 3,
  "name": "React 浏览器插件",
  "version": "1.0.0",
  "description": "使用 React+Vite+Arco Design+TypeScript 构建的浏览器插件",
  
  "permissions": [
    "activeTab",
    "storage"
  ],
  
  "background": {
    "service_worker": "src/background/index.ts",
    "type": "module"
  },
  
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["src/content/index.ts"],
      "run_at": "document_end"
    }
  ],
  
  "action": {
    "default_popup": "src/popup/index.html",
    "default_title": "React 插件",
    "default_icon": {
      "16": "public/icons/icon16.png",
      "32": "public/icons/icon32.png",
      "48": "public/icons/icon48.png",
      "128": "public/icons/icon128.png"
    }
  },
  
  "options_page": "src/options/index.html",
  
  "icons": {
    "16": "public/icons/icon16.png",
    "32": "public/icons/icon32.png",
    "48": "public/icons/icon48.png",
    "128": "public/icons/icon128.png"
  }
}

5.4 TypeScript 类型定义 (src/types/chrome.d.ts)

typescript 复制代码
// 扩展 Chrome API 类型定义
declare namespace chrome {
  namespace storage {
    interface StorageArea {
      get(keys?: string | string[] | null): Promise<any>;
      set(items: { [key: string]: any }): Promise<void>;
      remove(keys: string | string[]): Promise<void>;
    }
  }
}

// 消息类型定义
export interface ExtensionMessage {
  action: string;
  data?: any;
}

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

// 存储数据类型
export interface ExtensionSettings {
  enabled: boolean;
  theme: 'light' | 'dark';
  fontSize: number;
  highlightColor: string;
}

export interface PageInfo {
  title: string;
  url: string;
  elementCount: number;
  linkCount: number;
}

5.5 存储工具类 (src/utils/storage.ts)

csharp 复制代码
import { ExtensionSettings } from '../types/chrome';

export class ExtensionStorage {
  // 获取设置
  static async getSettings(): Promise<ExtensionSettings> {
    const result = await chrome.storage.local.get(['settings']);
    return result.settings || {
      enabled: true,
      theme: 'light',
      fontSize: 14,
      highlightColor: '#ffff00'
    };
  }

  // 保存设置
  static async saveSettings(settings: Partial<ExtensionSettings>): Promise<void> {
    const currentSettings = await this.getSettings();
    const newSettings = { ...currentSettings, ...settings };
    await chrome.storage.local.set({ settings: newSettings });
  }

  // 获取启用状态
  static async getEnabled(): Promise<boolean> {
    const settings = await this.getSettings();
    return settings.enabled;
  }

  // 设置启用状态
  static async setEnabled(enabled: boolean): Promise<void> {
    await this.saveSettings({ enabled });
  }
}

5.6 Background Script (src/background/index.ts)

javascript 复制代码
import { ExtensionMessage, ExtensionResponse } from '../types/chrome';
import { ExtensionStorage } from '../utils/storage';

// 插件安装时初始化
chrome.runtime.onInstalled.addListener(async () => {
  console.log('React 插件已安装');
  
  // 初始化默认设置
  await ExtensionStorage.saveSettings({
    enabled: true,
    theme: 'light',
    fontSize: 14,
    highlightColor: '#ffff00'
  });
});

// 监听来自其他脚本的消息
chrome.runtime.onMessage.addListener(
  (message: ExtensionMessage, sender, sendResponse) => {
    handleMessage(message, sender, sendResponse);
    return true; // 保持消息通道开启
  }
);

async function handleMessage(
  message: ExtensionMessage,
  sender: chrome.runtime.MessageSender,
  sendResponse: (response: ExtensionResponse) => void
) {
  try {
    switch (message.action) {
      case 'getSettings':
        const settings = await ExtensionStorage.getSettings();
        sendResponse({ success: true, data: settings });
        break;

      case 'saveSettings':
        await ExtensionStorage.saveSettings(message.data);
        sendResponse({ success: true });
        break;

      case 'toggleEnabled':
        const currentSettings = await ExtensionStorage.getSettings();
        await ExtensionStorage.setEnabled(!currentSettings.enabled);
        sendResponse({ success: true, data: !currentSettings.enabled });
        break;

      default:
        sendResponse({ success: false, error: '未知的操作类型' });
    }
  } catch (error) {
    console.error('Background script error:', error);
    sendResponse({ 
      success: false, 
      error: error instanceof Error ? error.message : '未知错误' 
    });
  }
}

5.7 Content Script (src/content/index.ts)

javascript 复制代码
import { ExtensionMessage, ExtensionResponse, PageInfo } from '../types/chrome';

// 全局状态
let isInjected = false;

// 初始化内容脚本
function init() {
  console.log('Content script 已加载');
  injectStyles();
  setupMessageListener();
}

// 注入样式
function injectStyles() {
  if (isInjected) return;

  const style = document.createElement('style');
  style.id = 'react-extension-styles';
  style.textContent = `
    .react-extension-highlight {
      background-color: var(--highlight-color, #ffff00) !important;
      transition: background-color 0.3s ease;
      outline: 2px solid var(--highlight-color, #ffff00);
      outline-offset: 2px;
    }
    
    .react-extension-dark {
      filter: invert(1) hue-rotate(180deg);
    }
  `;
  
  document.head.appendChild(style);
  isInjected = true;
}

// 高亮元素
function highlightElements(selector: string, color?: string): number {
  const elements = document.querySelectorAll(selector);
  const root = document.documentElement;
  
  if (color) {
    root.style.setProperty('--highlight-color', color);
  }
  
  elements.forEach(el => {
    el.classList.add('react-extension-highlight');
  });
  
  return elements.length;
}

// 移除高亮
function removeHighlight(): void {
  const elements = document.querySelectorAll('.react-extension-highlight');
  elements.forEach(el => {
    el.classList.remove('react-extension-highlight');
  });
}

// 获取页面信息
function getPageInfo(): PageInfo {
  return {
    title: document.title,
    url: window.location.href,
    elementCount: document.querySelectorAll('*').length,
    linkCount: document.querySelectorAll('a').length
  };
}

// 切换暗色模式
function toggleDarkMode(enabled: boolean): void {
  if (enabled) {
    document.documentElement.classList.add('react-extension-dark');
  } else {
    document.documentElement.classList.remove('react-extension-dark');
  }
}

// 处理消息
function handleMessage(
  message: ExtensionMessage,
  sendResponse: (response: ExtensionResponse) => void
): void {
  try {
    switch (message.action) {
      case 'highlight':
        const count = highlightElements(
          message.data.selector, 
          message.data.color
        );
        sendResponse({ success: true, data: { count } });
        break;

      case 'removeHighlight':
        removeHighlight();
        sendResponse({ success: true });
        break;

      case 'getPageInfo':
        const pageInfo = getPageInfo();
        sendResponse({ success: true, data: pageInfo });
        break;

      case 'toggleDarkMode':
        toggleDarkMode(message.data.enabled);
        sendResponse({ success: true });
        break;

      default:
        sendResponse({ success: false, error: '未知的操作类型' });
    }
  } catch (error) {
    console.error('Content script error:', error);
    sendResponse({ 
      success: false, 
      error: error instanceof Error ? error.message : '未知错误' 
    });
  }
}

// 设置消息监听器
function setupMessageListener(): void {
  chrome.runtime.onMessage.addListener(
    (message: ExtensionMessage, sender, sendResponse) => {
      handleMessage(message, sendResponse);
      return true; // 保持消息通道开启
    }
  );
}

// 页面卸载时清理资源
function cleanup(): void {
  // 移除注入的样式
  const injectedStyle = document.getElementById('react-extension-styles');
  if (injectedStyle) {
    injectedStyle.remove();
  }
  
  // 移除所有高亮
  removeHighlight();
  
  // 移除暗色模式
  document.documentElement.classList.remove('react-extension-dark');
  
  isInjected = false;
}

// 监听页面卸载事件
window.addEventListener('beforeunload', cleanup);

// 初始化内容脚本
init();
typescript 复制代码
import React, { useState, useEffect } from 'react';
import {
  Card,
  Switch,
  Button,
  Select,
  Space,
  Message,
  Typography,
  Divider,
  Badge,
  Row,
  Col
} from '@arco-design/web-react';
import {
  IconHighlight,
  IconInfo,
  IconMoon,
  IconSettings,
  IconRefresh
} from '@arco-design/web-react/icon';
import { ExtensionSettings, PageInfo } from '../types/chrome';
import { ExtensionStorage } from '../utils/storage';

const { Title, Text } = Typography;

const PopupApp: React.FC = () => {
  const [settings, setSettings] = useState<ExtensionSettings>({
    enabled: true,
    theme: 'light',
    fontSize: 14,
    highlightColor: '#ffff00'
  });
  
  const [pageInfo, setPageInfo] = useState<PageInfo | null>(null);
  const [loading, setLoading] = useState(false);

  // 加载设置
  useEffect(() => {
    loadSettings();
  }, []);

  const loadSettings = async () => {
    try {
      const currentSettings = await ExtensionStorage.getSettings();
      setSettings(currentSettings);
    } catch (error) {
      Message.error('加载设置失败');
    }
  };

  // 保存设置
  const saveSettings = async (newSettings: Partial<ExtensionSettings>) => {
    try {
      await ExtensionStorage.saveSettings(newSettings);
      setSettings(prev => ({ ...prev, ...newSettings }));
      Message.success('设置已保存');
    } catch (error) {
      Message.error('保存设置失败');
    }
  };

  // 发送消息到 content script
  const sendMessageToCurrentTab = (message: any): Promise<any> => {
    return new Promise((resolve, reject) => {
      chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
        if (tabs[0]?.id) {
          chrome.tabs.sendMessage(tabs[0].id, message, (response) => {
            if (chrome.runtime.lastError) {
              reject(chrome.runtime.lastError);
            } else {
              resolve(response);
            }
          });
        } else {
          reject(new Error('无法获取当前标签页'));
        }
      });
    });
  };

  // 高亮链接
  const handleHighlightLinks = async () => {
    setLoading(true);
    try {
      const response = await sendMessageToCurrentTab({
        action: 'highlight',
        data: { selector: 'a', color: settings.highlightColor }
      });
      
      if (response.success) {
        Message.success(`已高亮 ${response.data.count} 个链接`);
      }
    } catch (error) {
      Message.error('高亮操作失败');
    } finally {
      setLoading(false);
    }
  };

  // 移除高亮
  const handleRemoveHighlight = async () => {
    try {
      await sendMessageToCurrentTab({ action: 'removeHighlight' });
      Message.success('已移除高亮');
    } catch (error) {
      Message.error('移除高亮失败');
    }
  };

  // 获取页面信息
  const handleGetPageInfo = async () => {
    setLoading(true);
    try {
      const response = await sendMessageToCurrentTab({ action: 'getPageInfo' });
      if (response.success) {
        setPageInfo(response.data);
        Message.success('页面信息获取成功');
      }
    } catch (error) {
      Message.error('获取页面信息失败');
    } finally {
      setLoading(false);
    }
  };

  // 切换暗色模式
  const handleToggleDarkMode = async () => {
    const newTheme = settings.theme === 'light' ? 'dark' : 'light';
    await saveSettings({ theme: newTheme });
    
    try {
      await sendMessageToCurrentTab({
        action: 'toggleDarkMode',
        data: { enabled: newTheme === 'dark' }
      });
    } catch (error) {
      Message.error('切换主题失败');
    }
  };

  return (
    <div style={{ width: 360, padding: 16 }}>
      <Card>
        <Title heading={6} style={{ margin: '0 0 16px 0' }}>
          React 浏览器插件
        </Title>

        {/* 基本控制 */}
        <Space direction="vertical" size="medium" style={{ width: '100%' }}>
          <Row justify="space-between" align="center">
            <Col>
              <Text>启用插件</Text>
            </Col>
            <Col>
              <Switch
                checked={settings.enabled}
                onChange={(checked) => saveSettings({ enabled: checked })}
              />
            </Col>
          </Row>

          <Divider />

          {/* 功能按钮 */}
          <Space direction="vertical" size="small" style={{ width: '100%' }}>
            <Button
              type="primary"
              icon={<IconHighlight />}
              onClick={handleHighlightLinks}
              loading={loading}
              disabled={!settings.enabled}
              long
            >
              高亮页面链接
            </Button>

            <Button
              icon={<IconRefresh />}
              onClick={handleRemoveHighlight}
              disabled={!settings.enabled}
              long
            >
              移除高亮
            </Button>

            <Button
              icon={<IconInfo />}
              onClick={handleGetPageInfo}
              loading={loading}
              disabled={!settings.enabled}
              long
            >
              获取页面信息
            </Button>

            <Button
              icon={<IconMoon />}
              onClick={handleToggleDarkMode}
              disabled={!settings.enabled}
              long
            >
              切换 {settings.theme === 'light' ? '暗色' : '浅色'} 模式
            </Button>
          </Space>

          <Divider />

          {/* 设置 */}
          <Space direction="vertical" size="small" style={{ width: '100%' }}>
            <Row justify="space-between" align="center">
              <Col>
                <Text>高亮颜色</Text>
              </Col>
              <Col>
                <Select
                  value={settings.highlightColor}
                  onChange={(value) => saveSettings({ highlightColor: value })}
                  style={{ width: 120 }}
                >
                  <Select.Option value="#ffff00">黄色</Select.Option>
                  <Select.Option value="#ff6b6b">红色</Select.Option>
                  <Select.Option value="#4ecdc4">青色</Select.Option>
                  <Select.Option value="#45b7d1">蓝色</Select.Option>
                  <Select.Option value="#96ceb4">绿色</Select.Option>
                </Select>
              </Col>
            </Row>

            <Button
              icon={<IconSettings />}
              onClick={() => chrome.runtime.openOptionsPage()}
              long
            >
              高级设置
            </Button>
          </Space>

          {/* 页面信息展示 */}
          {pageInfo && (
            <>
              <Divider />
              <Space direction="vertical" size="small" style={{ width: '100%' }}>
                <Text bold>页面信息</Text>
                <Space direction="vertical" size="mini">
                  <Text type="secondary">标题: {pageInfo.title}</Text>
                  <Text type="secondary">
                    元素数量: <Badge count={pageInfo.elementCount} />
                  </Text>
                  <Text type="secondary">
                    链接数量: <Badge count={pageInfo.linkCount} />
                  </Text>
                </Space>
              </Space>
            </>
          )}
        </Space>
      </Card>
    </div>
  );
};

export default PopupApp;

src/popup/index.tsx

javascript 复制代码
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import '@arco-design/web-react/dist/css/arco.css';

const container = document.getElementById('root');
const root = createRoot(container!);

root.render(<App />);

src/popup/index.html

xml 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>React 插件</title>
</head>
<body>
  <div id="root"></div>
  <script type="module" src="./index.tsx"></script>
</body>
</html>

5.10 Options 设置页面

src/options/App.tsx

ini 复制代码
import React, { useState, useEffect } from 'react';
import {
  Layout,
  Card,
  Form,
  Input,
  Select,
  Switch,
  Button,
  Message,
  Typography,
  Space,
  Divider,
  Grid,
  InputNumber
} from '@arco-design/web-react';
import { IconSave, IconRefresh } from '@arco-design/web-react/icon';
import { ExtensionSettings } from '../types/chrome';
import { ExtensionStorage } from '../utils/storage';

const { Header, Content } = Layout;
const { Title, Text } = Typography;
const { Row, Col } = Grid;

const OptionsApp: React.FC = () => {
  const [form] = Form.useForm();
  const [loading, setLoading] = useState(false);
  const [settings, setSettings] = useState<ExtensionSettings>({
    enabled: true,
    theme: 'light',
    fontSize: 14,
    highlightColor: '#ffff00'
  });

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

  const loadSettings = async () => {
    setLoading(true);
    try {
      const currentSettings = await ExtensionStorage.getSettings();
      setSettings(currentSettings);
      form.setFieldsValue(currentSettings);
    } catch (error) {
      Message.error('加载设置失败');
    } finally {
      setLoading(false);
    }
  };

  const handleSave = async (values: ExtensionSettings) => {
    setLoading(true);
    try {
      await ExtensionStorage.saveSettings(values);
      setSettings(values);
      Message.success('设置保存成功');
    } catch (error) {
      Message.error('保存设置失败');
    } finally {
      setLoading(false);
    }
  };

  const handleReset = async () => {
    try {
      const defaultSettings: ExtensionSettings = {
        enabled: true,
        theme: 'light',
        fontSize: 14,
        highlightColor: '#ffff00'
      };
      
      await ExtensionStorage.saveSettings(defaultSettings);
      setSettings(defaultSettings);
      form.setFieldsValue(defaultSettings);
      Message.success('设置已重置为默认值');
    } catch (error) {
      Message.error('重置设置失败');
    }
  };

  return (
    <Layout style={{ minHeight: '100vh' }}>
      <Header style={{ backgroundColor: '#fff', borderBottom: '1px solid #e8e8e8' }}>
        <Title heading={4} style={{ margin: 0 }}>
          React 浏览器插件 - 设置
        </Title>
      </Header>
      
      <Content style={{ padding: '24px' }}>
        <Row gutter={24}>
          <Col span={16}>
            <Card title="基本设置" style={{ marginBottom: 24 }}>
              <Form
                form={form}
                layout="vertical"
                onSubmit={handleSave}
                initialValues={settings}
              >
                <Form.Item
                  label="启用插件"
                  field="enabled"
                  triggerPropName="checked"
                >
                  <Switch />
                </Form.Item>

                <Form.Item
                  label="主题模式"
                  field="theme"
                  rules={[{ required: true, message: '请选择主题模式' }]}
                >
                  <Select placeholder="请选择主题模式">
                    <Select.Option value="light">浅色模式</Select.Option>
                    <Select.Option value="dark">深色模式</Select.Option>
                  </Select>
                </Form.Item>

                <Form.Item
                  label="字体大小"
                  field="fontSize"
                  rules={[
                    { required: true, message: '请输入字体大小' },
                    { type: 'number', min: 10, max: 24, message: '字体大小应在 10-24 之间' }
                  ]}
                >
                  <InputNumber
                    min={10}
                    max={24}
                    suffix="px"
                    style={{ width: '100%' }}
                  />
                </Form.Item>

                <Form.Item
                  label="高亮颜色"
                  field="highlightColor"
                  rules={[{ required: true, message: '请选择高亮颜色' }]}
                >
                  <Select placeholder="请选择高亮颜色">
                    <Select.Option value="#ffff00">
                      <div style={{ display: 'flex', alignItems: 'center' }}>
                        <div 
                          style={{ 
                            width: 16, 
                            height: 16, 
                            backgroundColor: '#ffff00',
                            marginRight: 8,
                            border: '1px solid #ccc'
                          }} 
                        />
                        黄色
                      </div>
                    </Select.Option>
                    <Select.Option value="#ff6b6b">
                      <div style={{ display: 'flex', alignItems: 'center' }}>
                        <div 
                          style={{ 
                            width: 16, 
                            height: 16, 
                            backgroundColor: '#ff6b6b',
                            marginRight: 8,
                            border: '1px solid #ccc'
                          }} 
                        />
                        红色
                      </div>
                    </Select.Option>
                    <Select.Option value="#4ecdc4">
                      <div style={{ display: 'flex', alignItems: 'center' }}>
                        <div 
                          style={{ 
                            width: 16, 
                            height: 16, 
                            backgroundColor: '#4ecdc4',
                            marginRight: 8,
                            border: '1px solid #ccc'
                          }} 
                        />
                        青色
                      </div>
                    </Select.Option>
                    <Select.Option value="#45b7d1">
                      <div style={{ display: 'flex', alignItems: 'center' }}>
                        <div 
                          style={{ 
                            width: 16, 
                            height: 16, 
                            backgroundColor: '#45b7d1',
                            marginRight: 8,
                            border: '1px solid #ccc'
                          }} 
                        />
                        蓝色
                      </div>
                    </Select.Option>
                    <Select.Option value="#96ceb4">
                      <div style={{ display: 'flex', alignItems: 'center' }}>
                        <div 
                          style={{ 
                            width: 16, 
                            height: 16, 
                            backgroundColor: '#96ceb4',
                            marginRight: 8,
                            border: '1px solid #ccc'
                          }} 
                        />
                        绿色
                      </div>
                    </Select.Option>
                  </Select>
                </Form.Item>

                <Form.Item>
                  <Space>
                    <Button
                      type="primary"
                      htmlType="submit"
                      icon={<IconSave />}
                      loading={loading}
                    >
                      保存设置
                    </Button>
                    <Button
                      icon={<IconRefresh />}
                      onClick={handleReset}
                    >
                      重置为默认
                    </Button>
                  </Space>
                </Form.Item>
              </Form>
            </Card>
          </Col>

          <Col span={8}>
            <Card title="使用说明" style={{ marginBottom: 24 }}>
              <Space direction="vertical" size="medium">
                <div>
                  <Text bold>基本功能:</Text>
                  <ul style={{ margin: '8px 0', paddingLeft: '20px' }}>
                    <li>高亮页面链接</li>
                    <li>获取页面信息</li>
                    <li>切换暗色模式</li>
                    <li>自定义高亮颜色</li>
                  </ul>
                </div>
                
                <Divider />
                
                <div>
                  <Text bold>快捷操作:</Text>
                  <ul style={{ margin: '8px 0', paddingLeft: '20px' }}>
                    <li>点击插件图标打开弹窗</li>
                    <li>在弹窗中进行快速设置</li>
                    <li>通过此页面进行详细配置</li>
                  </ul>
                </div>
              </Space>
            </Card>

            <Card title="当前设置预览">
              <Space direction="vertical" size="small" style={{ width: '100%' }}>
                <div>
                  <Text type="secondary">插件状态: </Text>
                  <Text strong={settings.enabled} type={settings.enabled ? 'success' : 'warning'}>
                    {settings.enabled ? '已启用' : '已禁用'}
                  </Text>
                </div>
                <div>
                  <Text type="secondary">主题模式: </Text>
                  <Text>{settings.theme === 'light' ? '浅色模式' : '深色模式'}</Text>
                </div>
                <div>
                  <Text type="secondary">字体大小: </Text>
                  <Text>{settings.fontSize}px</Text>
                </div>
                <div>
                  <Text type="secondary">高亮颜色: </Text>
                  <div style={{ display: 'inline-flex', alignItems: 'center' }}>
                    <div
                      style={{
                        width: 16,
                        height: 16,
                        backgroundColor: settings.highlightColor,
                        border: '1px solid #ccc',
                        marginLeft: 8
                      }}
                    />
                  </div>
                </div>
              </Space>
            </Card>
          </Col>
        </Row>
      </Content>
    </Layout>
  );
};

export default OptionsApp;

src/options/index.tsx

javascript 复制代码
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import '@arco-design/web-react/dist/css/arco.css';

const container = document.getElementById('root');
const root = createRoot(container!);

root.render(<App />);

src/options/index.html

xml 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>React 插件 - 设置</title>
</head>
<body>
  <div id="root"></div>
  <script type="module" src="./index.tsx"></script>
</body>
</html>

5.11 构建脚本配置 (package.json)

perl 复制代码
{
  "name": "react-browser-extension",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview",
    "build:extension": "npm run build && npm run zip",
    "zip": "cd dist && zip -r ../extension.zip .",
    "clean": "rm -rf dist"
  },
  "dependencies": {
    "@arco-design/web-react": "^2.62.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@crxjs/vite-plugin": "^2.0.0-beta.19",
    "@types/chrome": "^0.0.246",
    "@types/react": "^18.2.37",
    "@types/react-dom": "^18.2.15",
    "@typescript-eslint/eslint-plugin": "^6.10.0",
    "@typescript-eslint/parser": "^6.10.0",
    "@vitejs/plugin-react": "^4.1.1",
    "eslint": "^8.53.0",
    "eslint-plugin-react-hooks": "^4.6.0",
    "eslint-plugin-react-refresh": "^0.4.4",
    "typescript": "^5.2.2",
    "vite": "^5.0.0"
  }
}

5.12 TypeScript 配置 (tsconfig.json)

json 复制代码
{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,

    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx",

    /* Linting */
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,

    /* Chrome Extension */
    "types": ["chrome", "vite/client"]
  },
  "include": ["src"],
  "references": [{ "path": "./tsconfig.node.json" }]
}

5.13 项目构建和调试

开发环境运行

bash 复制代码
# 安装依赖
npm install

# 开发模式构建(监听文件变化)
npm run dev

# 生产环境构建
npm run build

# 打包为 zip 文件
npm run build:extension

加载开发中的插件

  1. 运行 npm run build 构建项目
  2. 打开 Chrome 浏览器,进入 chrome://extensions/
  3. 打开右上角的"开发者模式"
  4. 点击"加载已解压的扩展程序"
  5. 选择项目的 dist 目录

调试技巧

  1. Background script 调试
  1. Content script 调试

内容脚本会被注入当前页面,可直接在页面中打开开发者工具

  1. Popup 调试:右键点击插件图标 → 检查弹出窗口

5.14 热重载开发体验

热重载对于插件开发意义不是太大,因为就算热重载也是需要去插件管理里刷新插件才会在浏览器中生效,热重载脚本也只是帮你自动执行 build 命令

创建一个开发用的脚本 scripts/dev.js

javascript 复制代码
import { exec } from 'child_process';
import { watch } from 'fs';
import path from 'path';

console.log('🚀 开始监听文件变化...');

// 监听源码目录
watch('./src', { recursive: true }, (eventType, filename) => {
  if (filename && (filename.endsWith('.ts') || filename.endsWith('.tsx'))) {
    console.log(`📝 文件变化: ${filename}`);
    
    // 重新构建
    exec('npm run build', (error, stdout, stderr) => {
      if (error) {
        console.error('❌ 构建失败:', error);
        return;
      }
      console.log('✅ 构建完成,请在浏览器中刷新插件');
    });
  }
});

// 监听 manifest.json
watch('./manifest.json', (eventType, filename) => {
  console.log('📄 Manifest 文件变化,请重新加载插件');
});

package.json 中添加脚本:

json 复制代码
{
  "scripts": {
    "dev:watch": "node scripts/dev.js"
  }
}

5.15 插件安装和调试

加载开发中的插件

  1. 打开 Chrome 浏览器
  2. 进入 chrome://extensions/
  3. 打开"开发者模式"
  4. 点击"加载已解压的扩展程序"
  5. 选择项目根目录

5.16 打包和发布

本地打包

python 复制代码
# 创建 zip 压缩包
zip -r dist.zip dist

Chrome Web Store 发布流程

参考官网:developer.chrome.com/docs/websto...

六、一些坑点分享

6.1 消息传递

在浏览器插件架构中,不同组件(如背景页、内容脚本、Popup 页面)运行在独立的沙盒环境中,无法直接访问彼此的变量或函数。消息传递(Message Passing) 是实现组件间数据交互的核心机制,也是插件开发的关键技术点,也是个人觉得坑点较多的地方

浏览器插件的消息传递主要围绕以下 4 类场景展开:

暂时无法在飞书文档外展示此内容

通信场景 适用场景 核心 API
背景页 ↔ 内容脚本 插件后台逻辑控制页面内容 chrome.tabs.sendMessage直接通讯
Popup 页面 ↔ 背景页 弹窗界面与后台状态同步 sendMessage直接通讯
内容脚本 ↔ 内容脚本 不同标签页 / 框架间的页面脚本通信 因为它们彼此是独立的执行上下文,每个脚本文件各自作用域,不自动共享,需要通过 Background 转发
插件 ↔ 外部服务器 插件向后端接口请求数据 Popup 和 Background 可以直接请求content 需要通过 Background 转发

关键点:

  1. 内容脚本无法直接访问外部服务器,需通过 background 进行消息转发

  2. 内容脚本与内容脚本之间需要通过 Background 转发消息

  3. sendMessage 无响应或报错,可能的原因:

    1. 因为 Background 是 Service Worker,可能被休眠;

    2. 异步回调时必须 return true

6.2 内容脚本注入

注入时机问题

如果你遇到诸如:

  1. TypeError: Cannot read properties of null/undefined
  2. Cannot read properties of null
  3. Could not establish connection. Receiving end does not exist.
  4. The message port closed before a response was received.

的问题,通常可以排查下脚本注入时机是否正确

页面生命周期与注入时序全览

典型加载序列(简化):

  1. 用户导航到 URL(或 SPA 内部路由变更)
  2. 浏览器开始请求并"提交导航"(commit)
  3. 创建 document、注入 documentElement
  4. run_at = document_start 的内容脚本执行
  5. 浏览器解析 HTML、同步执行页面脚本
  6. DOM 解析完成(readyState: interactive)
  7. 触发 DOMContentLoaded
  8. run_at = document_end 的内容脚本执行(紧随 DOMContentLoaded 之后)
  9. 子资源加载接近完成,页面进入"空闲"阶段
  10. run_at = document_idle 的内容脚本执行(通常在 onload 附近或之后不久)
  11. 触发 load(资源加载完成)

关键点:同一个扩展内,多个内容脚本的执行顺序按 manifest 中声明顺序。不同扩展之间的先后顺序不保证

三种 run_at 的语义与适用场景
  • run_at: "document_start"

    • 执行时机:在解析 HTML 之前尽早注入(documentElement 已存在)
    • 适用:尽早注入 CSS 防止闪烁(FOUC)、阻止页面早期行为(需 MAIN world)、埋监听器/拦截器、预先占位
    • 注意:DOM 可能尚未可用,若需操作 DOM,请自管 ready 流程
  • run_at: "document_end"

    • 执行时机:DOM 解析完成后、load 之前(接近 DOMContentLoaded)
    • 适用:需要完整 DOM 树但不依赖图片/样式加载的逻辑
  • run_at: "document_idle"(推荐默认)

    • 执行时机:页面"空闲"时注入,通常在 load 附近或之后
    • 适用:大多数需要读写 DOM 的功能;对页面渲染影响最小
    • 备注:若页面已加载完毕,idle 脚本会"立刻"执行

内容脚本获取页面 window 对象

content script 运行在一个"隔离的世界(isolated world)",它和页面本身的 JavaScript 运行环境(window)是分开的。

这意味着:

  • content script 访问的 window 对象和页面里 <script> 标签访问的 window 不是同一个。
  • 页面上通过 <script> 标签挂载到 window 的变量(如 window.gfdatav1),content script 直接访问不到。

如果想要在内容脚本中获取到页面 window 对象,需要将脚本注入到页面环境中,实现步骤:

  1. content script 注入 js 代码到页面
javascript 复制代码
// content/inject.js
(function() {
  // 这里可以访问 window.gfdatav1
  if (window.gfdatav1) {
    // 比如把数据写到 DOM 或发送自定义事件
    window.dispatchEvent(new CustomEvent('GFDataV1Ready', {
      detail: window.gfdatav1
    }));
  }
})();
  1. content script 动态插入 inject.js 到页面
javascript 复制代码
// content/content-script.js
const script = document.createElement('script');
script.src = chrome.runtime.getURL('content/inject.js');
script.onload = function() {
  this.remove();
};
(document.head || document.documentElement).appendChild(script);
// 监听自定义事件获取数据
window.addEventListener('GFDataV1Ready', function(e) {
  console.log('gfdatav1 from page:', e.detail);
  // 这里拿到 gfdatav1 实例,可以继续处理
});
  1. 在 manifest.json 里声明 inject.js

如果你用 Vite 打包,确保 inject.js 能被正确打包到输出目录。

七、基于 Trae 的 AI 自动项目搭建

推荐使用 prompt:

diff 复制代码
请你充当前端工程脚手架与代码生成器,为我生成一个 Chrome 浏览器扩展(Manifest V3)项目,项目名称为 my_plugin。请严格按照下述技术栈、目录结构与功能要求输出完整代码与说明。

#### 一、技术栈要求
- 框架与工具:React 18 + Vite + TypeScript
- UI 组件库:Arco Design
- 浏览器扩展:Chrome Extension Manifest V3
- 代码规范:ESLint + Prettier(可选但建议)
- 模块化与类型:使用 ES Modules 和 TypeScript 严格类型

#### 二、项目结构
请生成如下目录与文件,并在相应文件中给出完整可运行的示例代码(非伪代码):
src/
├── background/
│   └── index.ts
├── content/
│   └── index.ts
├── popup/
│   ├── App.tsx
│   ├── index.tsx
│   └── index.html
├── options/
│   ├── App.tsx
│   ├── index.tsx
│   └── index.html
├── types/
│   └── chrome.d.ts
└── utils/
    └── storage.ts

此外,请生成项目根目录必要文件:
- manifest.json(MV3)
- index.html(若 Vite 必需)
- package.json(包含脚本与依赖)
- tsconfig.json、vite.config.ts
- .eslintrc.cjs、.prettierrc

#### 三、功能实现
1) 弹窗(Popup)
- 点击扩展图标弹出 Popup 页面。
- 使用 Arco Design 实现一个登录表单:输入用户名与密码,支持校验与提交按钮。
- 使用 src/utils/storage.ts 封装对 chrome.storage.local 的读写,将用户名持久化存储与读取(示例:login 后保存 username)。

2) 内容脚本(Content Script)
- 在用户访问任意网页时自动注入。
- 检测当前页面请求头中是否包含 key 为 x-tt-env 的值。如果存在,将该 value 明显显示在页面 header 顶部(例如在页面顶部插入一个固定定位的提示条)。
- 说明:由于内容脚本无法直接读取网络请求的请求头,请通过 background 的 chrome.webRequest API 监听并将捕获到的 header 值通过 chrome.runtime.sendMessage 或 chrome.storage 传递给内容脚本;内容脚本接收后渲染到页面。

3) 右键菜单(Context Menus)
- 当用户在页面中选中文本后,右键菜单出现一个选项:search {选中的文本} by baidu。
- 点击该选项时,使用 chrome.tabs.create 在新标签页打开 https://www.baidu.com/s?wd={选中的文本}(URL 需进行 encodeURI 处理)。
- 该菜单在扩展安装或启动时创建,文本选中时可见。

#### 四、权限与 Manifest 配置
- 必须配置 Manifest V3,字段包括 name、version、manifest_version、action、icons、permissions、host_permissions、background、content_scripts、options_ui 等。
- permissions 需包含:storage、contextMenus、tabs、scripting、activeTab、webRequest、webRequestBlocking(若需要拦截)等。
- host_permissions 允许针对 http/https 任意站点监听请求头(如 ["<all_urls>"])。
- background 使用 service_worker,入口为 src/background/index.ts(由 Vite 构建到 dist)。

#### 五、构建与开发
- 使用 Vite 构建多入口(popup、options、content、background)。
- 请在 vite.config.ts 中配置多页面输入与打包到 dist,同时正确处理 Manifest 拷贝。
- 提供开发与构建脚本:
  - dev:支持本地开发热更新(HMR 针对 popup/options 页面)
  - build:打包输出 dist
  - zip:将 dist 打包为 zip 便于安装
- 说明如何在 Chrome 中加载:chrome://extensions -> 开发者模式 -> 加载已解压的扩展 -> 选择 dist。

#### 六、类型与工具
- 在 src/types/chrome.d.ts 中补充必要的类型声明或引用 @types/chrome(若使用则在 package.json 添加依赖)。
- 在代码中为关键对象与函数提供类型注解,避免 any。
- storage.ts 封装:
  - getItem<T>(key: string): Promise<T | undefined>
  - setItem<T>(key: string, value: T): Promise<void>
  - removeItem(key: string): Promise<void>

#### 七、代码与注释规范
- 所有关键逻辑配有简要注释,特别是 background 与 content 的消息传递部分。
- 统一使用 Arco Design 组件构建 UI,包含基本表单校验与统一样式。

#### 八、输出格式要求
- 请按文件维度依次给出内容,使用代码块分别提供每个文件的完整代码。
- 在开始部分给出依赖列表与安装命令。
- 在结尾提供一段简短的使用说明(如何构建、如何加载、如何测试三项)。
- 代码应可直接复制后安装依赖并构建通过,避免伪代码与省略号。

请按照以上要求生成完整项目代码与说明。

八、推荐学习资源

相关推荐
大圣编程11 小时前
Python中continue语句的用法是什么?
开发语言·前端·python
yuhaiqiang11 小时前
随手 vibecoding 的浏览器插件已经 6000 多次下载,聊聊他的产品设计
前端·后端·面试
之歆11 小时前
Vue商品详情与放大镜组件
前端·javascript·vue.js
再吃一根胡萝卜12 小时前
如何把小米 MiMo 接入 CodeBuddy,打造私有 Agent
前端
负责的蛋挞13 小时前
异步HttpModule的实现方式
java·服务器·前端
丹宇码农16 小时前
把 HLS 字幕玩出花:zwPlayer 如何让 M3U8 视频支持全文搜索、翻译与码率自适应
前端·javascript·音视频·hls·视频播放器
2501_9437823516 小时前
【共创季稿事节】猜数字游戏:二分法思维与交互式反馈
前端·游戏·microsoft·harmonyos·鸿蒙·鸿蒙系统
GV191rLvq16 小时前
基于Socket实现的最简单的Web服务器【ASP.NET原理分析】
服务器·前端·asp.net
吠品16 小时前
LangChain 里 tool_call_id 为空?一次 MCP 工具集成的排查记录
前端