前端浏览器插件的开发一步搞定

0 阅读7分钟

效率革命:为什么我选择用 Plasmo 开发浏览器插件?

摘要:浏览器插件开发曾经是一场与 manifest.json 和繁琐构建流程的搏斗。直到我遇到了 Plasmo。本文将分享我使用 Plasmo 框架从零开发、调试到发布浏览器插件的完整流程与心得,带你体验“下一代”插件开发的丝滑感。


前言:插件开发的“痛点”

作为一名前端开发者,我一直想写一个浏览器插件来优化我的工作流(比如自动填充表单、提取页面数据等)。但每次打开官方文档,看到以下内容时,热情总会冷却一半:

  1. Manifest V3 的复杂性:权限配置、Service Worker 的生命周期、CSP 限制,配置稍错就报错。
  2. 构建工具缺失:原生开发没有热更新(HMR),改一行代码要手动重载插件,调试效率极低。
  3. 多浏览器适配:Chrome、Firefox、Edge 的 API 细微差别,需要写多套代码或复杂的兼容逻辑。
  4. UI 开发割裂:Popup 和 Content Script 的样式隔离,无法复用 React/Vue 组件生态。

直到我发现了 Plasmo。官方称它为 "The Browser Extension Framework",社区里则更直白地叫它 "浏览器插件界的 Next.js"

经过一个周末的实际项目演练,我决定写下这篇博客,记录这段“真香”的开发体验。


为什么是 Plasmo?

在开始之前,先说说 Plasmo 打动我的几个核心特性:

  • 🚀 极速开发体验:支持热模块替换(HMR),修改代码秒级生效,无需手动重载插件。
  • 📦 开箱即用:内置 TypeScript、ESLint、Prettier,无需配置 Webpack 或 Vite。
  • 🌍 跨浏览器构建:一套代码,同时构建出 Chrome、Firefox、Safari、Edge 可用的安装包。
  • ⚛️ 框架无关但 React 优先:虽然支持 Svelte/Vue,但对 React 的支持最完善,可以直接使用 Shadcn、AntD 等 UI 库。
  • 💾 简化的存储 API@plasmo/storage 让跨上下文(Popup、Content、Background)的数据共享变得异常简单。

系统要求:

  • Node.js 16.14.x 或更高版本
  • (强烈推荐)pnpm

快速开始:5 分钟 scaffolding

1. 初始化项目

pnpm create plasmo
# OR
yarn create plasmo
# OR
npm create plasmo

按照提示输入项目名称,选择模板(默认 React + TypeScript)。 在这里插入图片描述

2. 目录结构解析

初始化后,目录结构非常清晰,符合前端直觉:

my-extension/
├── assets/          # 静态资源
├── popup.tsx        # 插件点击图标后的弹窗页面
├── background.ts    # 后台服务脚本 (Service Worker)
└── contents.ts      # 内容脚本(注入到网页中)
├── package.json
└── tsconfig.json

亮点:Plasmo 采用基于文件的路由系统

  • 想写 Popup?创建 popup.tsx
  • 想写注入脚本?直接创建contents.ts
  • 想写后台?直接创建background.ts。 无需手动配置 manifest.json 中的 actioncontent_scripts,框架会自动处理。

3. 启动开发服务器

pnpm dev

运行后,Plasmo 会自动打开一个 Chrome 窗口。此时,你在代码里保存修改,页面会自动刷新。这对于调试 Content Script 来说,简直是救星。


4. 在浏览器中调试插件

这里以谷歌浏览器为例。 1.点击拓展程序打开开发助手 在这里插入图片描述 2.找到你项目的build文件夹,将里面的chrome-mv3-dev文件直接拖到此页面 在这里插入图片描述 3.然后你的浏览器就多了你的插件,然后固定,点击打开就能看到页面了 在这里插入图片描述

核心功能开发实战

假设我们要开发一个 “网页刷新助手”:用户可以一键深度刷新网页,而且可以配置快捷键,可以更改插件主题色

1. 开发 Popup 界面 (src/popup.tsx)

这就是一个标准的 React 组件。

import { useState, useEffect } from "react"
import "../style/index.css"

function IndexPopup() {
  const [mounted, setMounted] = useState(false)
  const [iconUrl, setIconUrl] = useState("")
  const [themeColor, setThemeColor] = useState("#4a90e2")

  useEffect(() => {
    setMounted(true)
    // 默认恢复上次的主题色(如果有持久存储)
  }, [])

  const handleRefresh = async () => {
    const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
    if (!tab?.id) return

    chrome.tabs.reload(tab.id, { bypassCache: true })
    
    if (tab.url && tab.url.startsWith("http")) {
      try {
        const url = new URL(tab.url)
        chrome.browsingData.remove(
          { origins: [url.origin] },
          { cache: true, cacheStorage: true, cookies: true, localStorage: true }
        )
      } catch (e) {}
    }
    window.close()
  }

  // 替换悬浮按钮图标
  const handleReplaceIcon = async () => {
    const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
    if (tab?.id) {
      chrome.tabs.sendMessage(tab.id, { 
        action: "replace-floating-icon", 
        iconUrl: iconUrl 
      })
    }
  }

  // 切换主题色
  const handleThemeChange = async (color: string) => {
    setThemeColor(color)
    const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
    if (tab?.id) {
      chrome.tabs.sendMessage(tab.id, { 
        action: "change-theme", 
        color: color 
      })
    }
    // 动态更新 Popup 本身的样式
    document.documentElement.style.setProperty('--popup-theme-color', color)
  }

  const openShortcuts = () => {
    chrome.tabs.create({ url: "chrome://extensions/shortcuts" })
  }

  if (!mounted) {
    return <div style={{ minWidth: "280px", minHeight: "450px" }} />
  }

  return (
    <div className="outbody">
      <div className="title" style={{ color: themeColor }}>页面辅助助手</div>
      
      <button 
        className="refresh-button" 
        onClick={handleRefresh}
        style={{ backgroundColor: themeColor }}
      >
        🚀 深度刷新页面
      </button>

      {/* 主题颜色选择器 */}
      <div className="theme-section">
        <span className="section-label">主题切换</span>
        <div className="color-options">
          {['#4a90e2', '#50c878', '#f28b82', '#fbbc04', '#9334e6'].map(color => (
            <div 
              key={color} 
              className={`color-box ${themeColor === color ? 'active' : ''}`}
              style={{ backgroundColor: color }}
              onClick={() => handleThemeChange(color)}
            />
          ))}
          <input 
            type="color" 
            value={themeColor} 
            onChange={(e) => handleThemeChange(e.target.value)}
            title="自定义颜色"
          />
        </div>
      </div>

      {/* 悬浮图标替换 */}
      <div className="image-replace-section">
        <span className="section-label">悬浮图标替换</span>
        <input 
          type="text" 
          placeholder="输入图片/Emoji替换右下角图标" 
          value={iconUrl} 
          onChange={(e) => setIconUrl(e.target.value)}
          className="url-input"
        />
        <button 
          className="replace-btn" 
          onClick={handleReplaceIcon}
          style={{ backgroundColor: themeColor }}
        >
          🖼️ 替换图标
        </button>
      </div>

      <div className="tip">清除缓存并强制刷新当前页面</div>

      <div className="shortcut-section">
        <div className="shortcut-header">
          <span>键盘快捷键</span>
          <button 
            className="config-btn" 
            onClick={openShortcuts}
            style={{ color: themeColor, borderColor: themeColor }}
          >
            去设置
          </button>
        </div>
        <div className="shortcut-keys">
          <kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>R</kbd>
        </div>
      </div>
    </div>
  )
}

export default IndexPopup

2. 开发内容脚本 (src/contents.ts)

Content Script 是注入到网页中的,此脚本运行在目标网页的内容上下文中,即使它没有直接操作 DOM,也是扩展与网页交互的桥梁。

/**
 * Content Script (内容脚本)
 * 
 * 此脚本运行在目标网页的内容上下文中。
 * 即使它没有直接操作 DOM,也是扩展与网页交互的桥梁。
 */
export {}

// 1. 在页面上注入一个支持拖拽的悬浮按钮
const createFloatingButton = () => {
  const btn = document.createElement("div")
  btn.id = "page-helper-floating-btn"
  
  // 初始图标内容
  const iconSpan = document.createElement("span")
  iconSpan.id = "floating-btn-icon"
  iconSpan.innerText = "🚀"
  btn.appendChild(iconSpan)

  const style = btn.style
  style.position = "fixed"
  style.bottom = "20px"
  style.right = "20px"
  style.width = "40px"
  style.height = "40px"
  style.backgroundColor = "var(--theme-color, #4a90e2)"
  style.color = "white"
  style.borderRadius = "50%"
  style.display = "flex"
  style.alignItems = "center"
  style.justifyContent = "center"
  style.cursor = "move" // 设置为移动指针
  style.zIndex = "999999"
  style.boxShadow = "0 4px 12px rgba(0,0,0,0.15)"
  style.transition = "transform 0.2s ease, background-color 0.3s ease"
  style.fontSize = "20px"
  style.userSelect = "none"

  // 简单的拖拽实现
  let isDragging = false
  let currentX: number, currentY: number
  let initialX: number, initialY: number

  btn.onmousedown = (e) => {
    isDragging = false // 初始设为 false
    initialX = e.clientX - btn.offsetLeft
    initialY = e.clientY - btn.offsetTop
    
    const onMouseMove = (ev: MouseEvent) => {
      isDragging = true // 只要移动了就判定为拖拽
      currentX = ev.clientX - initialX
      currentY = ev.clientY - initialY
      
      // 限制不超出屏幕
      const minX = 0, minY = 0
      const maxX = window.innerWidth - btn.offsetWidth
      const maxY = window.innerHeight - btn.offsetHeight
      
      btn.style.left = Math.max(minX, Math.min(maxX, currentX)) + "px"
      btn.style.top = Math.max(minY, Math.min(maxY, currentY)) + "px"
      btn.style.bottom = "auto"
      btn.style.right = "auto"
    }

    const onMouseUp = () => {
      document.removeEventListener("mousemove", onMouseMove)
      document.removeEventListener("mouseup", onMouseUp)
      
      // 如果没有拖拽,则视为点击
      if (!isDragging) {
        chrome.runtime.sendMessage({ action: "deep-refresh-signal" })
      }
    }

    document.addEventListener("mousemove", onMouseMove)
    document.addEventListener("mouseup", onMouseUp)
  }

  document.body.appendChild(btn)
}

// 2. 替换悬浮图标的功能
const replaceFloatingIcon = (newImageUrl: string) => {
  const iconSpan = document.getElementById("floating-btn-icon")
  if (iconSpan) {
    if (newImageUrl.startsWith("http") || newImageUrl.startsWith("data:")) {
      iconSpan.innerHTML = `<img src="${newImageUrl}" style="width: 24px; height: 24px; border-radius: 50%; object-fit: cover;" />`
    } else {
      iconSpan.innerText = newImageUrl // 支持输入 Emoji
    }
  }
}

// 3. 切换主题色
const changeThemeColor = (color: string) => {
  document.documentElement.style.setProperty('--theme-color', color)
  const btn = document.getElementById("page-helper-floating-btn")
  if (btn) btn.style.backgroundColor = color
}

// 监听来自 Popup 的消息
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
  if (request.action === "replace-floating-icon") {
    replaceFloatingIcon(request.iconUrl)
    sendResponse({ status: "done" })
  } else if (request.action === "change-theme") {
    changeThemeColor(request.color)
    sendResponse({ status: "done" })
  }
  return true
})

// 初始化
const init = () => {
  if (!document.getElementById("page-helper-floating-btn")) {
    createFloatingButton()
  }
}

if (document.readyState === "complete" || document.readyState === "interactive") {
  init()
} else {
  window.addEventListener("DOMContentLoaded", init)
}

console.log("Page Helper: Content Script 启动成功,已注入悬浮按钮并准备好图片替换功能。")

3. 后台脚本

扩展的后台逻辑中心,负责监听浏览器事件、快捷键命令。

/**
 * Background Script (Service Worker)
 * 
 * 扩展的后台逻辑中心,负责监听浏览器事件、快捷键命令
 * 以及执行跨页面的特权 API 操作(如清除浏览数据)。
 */

// 监听在 manifest.json 中定义的键盘命令 (commands)
chrome.commands.onCommand.addListener(async (command) => {
  // 检查是否为定义的 "deep-refresh" 深度刷新指令
  if (command === "deep-refresh") {
    executeDeepRefresh()
  }
})

// 监听来自 Content Script 的信号
chrome.runtime.onMessage.addListener((message, sender) => {
  if (message.action === "deep-refresh-signal") {
    executeDeepRefresh()
  }
})

const executeDeepRefresh = async () => {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
  if (!tab?.id) return

  // 1. 优先触发刷新指令,减少用户等待感
  chrome.tabs.reload(tab.id, { bypassCache: true })

  // 2. 异步执行重型数据清理
  if (tab.url && tab.url.startsWith("http")) {
    try {
      const url = new URL(tab.url)
      chrome.browsingData.remove(
        { origins: [url.origin] },
        { cache: true, cacheStorage: true, cookies: true, localStorage: true }
      )
    } catch (e) {}
  }
}

export {}

4. 权限管理

传统开发需要手动编辑 manifest.json。在 Plasmo 中,你可以直接在 package.json 中配置,或者在代码中使用特定注释。

推荐在 package.json 中配置:

{
  "manifest": {
    "host_permissions": [
      "https://*/*",
      "http://*/*"
    ],
    "permissions": [
      "browsingData",
      "tabs",
      "activeTab"
    ],
    "commands": {
      "deep-refresh": {
        "suggested_key": {
          "default": "Ctrl+Shift+R",
          "mac": "Command+Shift+R"
        },
        "description": "深度刷新当前页面并清除缓存"
      }
    }
  }
}

Plasmo 构建时会自动合并这些配置到最终的 manifest.json 中。


构建与发布

开发完成后,发布流程同样简单。

1. 构建

pnpm build

构建产物在 build/chrome-mv3-prod 目录下。这是一个标准的插件文件夹结构。

2. 打包

你可以手动压缩为 zip,或者使用 Plasmo 的提交命令(需配置 Store 凭证)。

# 提交到 Chrome Web Store (需要配置 .plasmo/store-credentials)
pnpm package

3. 多浏览器

Plasmo 允许你指定目标浏览器:

pnpm build --target=firefox-mv2
pnpm build --target=safari-mv3

这解决了我最头疼的兼容性问题,无需维护多套代码库。


踩坑记录与思考

虽然 Plasmo 很强大,但在开发过程中也遇到了一些需要注意的地方:

  1. 版本更新快:Plasmo 目前处于快速迭代期,偶尔会有 Breaking Changes。建议锁定 package.json 中的版本号,升级前阅读 Changelog。
  2. Safari 适配:虽然支持构建,但 Safari 的扩展机制与 Chromium 系差异较大,真机调试相对麻烦,需要 Mac 设备和 Xcode。

总体评价: 对于 95% 的前端开发者来说,Plasmo 带来的开发效率提升远远大于那一点点体积开销。它让插件开发回归到了“写组件”的本质,而不是“配置清单”。


结语

浏览器插件是前端技术落地的绝佳场景,它能让你直接触达用户,解决实际问题。而 Plasmo 的出现,抹平了基础设施的门槛。

如果你一直想写一个插件却迟迟未动,不妨现在就试试:

pnpm create plasmo

相信我,当你第一次体验到修改代码、浏览器自动刷新的那一刻,你会回来感谢我的。

你的插件灵感是什么?欢迎在评论区分享!


参考链接