手把手教你写 Chrome 插件——Tab 管理器

本文将带你从零开始,用原生 HTML/CSS/JS 开发一个实用的 Chrome 插件。通过「Tab 管理器」这个实战项目,你将系统掌握 Chrome Extension Manifest V3 的核心技术:Service Worker、chrome.tabs API、权限系统、CSP 安全策略,以及现代化的 UI 设计。

一、Chrome 插件是什么?能做什么?

Chrome 扩展(Chrome Extension)是运行在 Chrome 浏览器中的小型程序,它通过 Chrome 提供的一组专用 API(chrome.*)与浏览器深度交互。借助扩展,你可以:

  • 操作浏览器标签页——创建、关闭、移动、查询标签页
  • 注入内容脚本——修改任意网页的 DOM、样式和行为
  • 拦截网络请求——重写请求头、重定向、缓存控制
  • 提供侧边栏/弹窗/独立页面——构建丰富的用户界面
  • 后台常驻运行——即使所有页面关闭,Service Worker 仍在工作

Chrome 插件的核心配置文件是 manifest.json,它声明了插件的元数据、权限、入口文件和资源。


二、Chrome 插件核心技术概览

在动手写代码之前,先了解 Manifest V3 的几个核心概念:

2.1 Manifest V3 与 V2 的区别

特性 Manifest V2 Manifest V3
后台进程 background.page (持久页面) service_worker (事件驱动,非持久)
远程代码 允许加载远程 JS 禁止,所有代码必须打包在扩展内
网络请求修改 webRequest 可阻塞 declarativeNetRequest 声明式规则
CSP 策略 较宽松 更严格,默认禁止内联脚本和事件处理器

Manifest V3 是 Google 强推的新标准,2024 年起 Chrome 网上应用店已不接受 V2 新提交。本文完全基于 V3 编写。

2.2 Service Worker

Service Worker 是插件的后台脚本,特点是:

  • 事件驱动:仅在需要时唤醒(如点击扩展图标、收到消息),完成后自动休眠
  • 无 DOM 访问权:不能操作页面 DOM,但可以调用所有 chrome.* API
  • 生命周期短暂:不要试图在全局变量中存储状态,应使用 chrome.storage

2.3 Action API

chrome.action 用于控制扩展图标(工具栏右侧的小图标)的行为:

  • onClicked:用户点击图标时触发
  • setBadgeText:在图标上显示小红点/数字
  • setIcon:动态更换图标

2.4 Tabs API

chrome.tabs 是操作标签页的核心 API:

// 查询当前窗口的所有标签页
const tabs = await chrome.tabs.query({ currentWindow: true });

// 关闭指定标签页
await chrome.tabs.remove([tabId1, tabId2]);

// 激活某个标签页
await chrome.tabs.update(tabId, { active: true });

// 创建新标签页
await chrome.tabs.create({ url: 'https://example.com' });

2.5 权限系统

Chrome 插件采用最小权限原则,你需要在 manifest.jsonpermissions 数组中显式声明所需权限。用户安装时会看到权限提示。

常见权限:

  • "tabs":访问标签页的 URL、标题、favicon 等信息
  • "activeTab":临时访问当前活动标签页(用户触发时)
  • "storage":读写扩展的本地存储
  • "scripting":向页面注入脚本

2.6 CSP(Content Security Policy)

Manifest V3 默认的 CSP 策略非常严格:

script-src 'self'

这意味着:

  • 禁止使用内联 <script> 标签(除非用 sha256 哈希或 nonce
  • 禁止使用内联事件处理器(如 onclick="..."onerror="..."
  • 禁止 eval()new Function()

这是最容易踩坑的地方!本文的 Tab 管理器最初就因为 onerror 内联事件处理器报了 CSP 错误,后面会详细讲解如何修复。


三、实战:Tab 管理器从 0 到 1

3.1 需求分析

我们要做一个能按域名整理标签页检测重复的管理工具:

  1. 按域名分组展示:提取每个标签页的 hostname,相同域名的标签页归入一个卡片
  2. 跨窗口管理:管理所有 Chrome 窗口的标签页,显示窗口 ID
  3. 重复检测:同一域名下 URL 完全相同的标签页标红提示
  4. 批量关闭:支持关闭单个标签页、关闭整组、关闭重复(保留第一个)
  5. 标签页休眠:释放非活动标签页的内存占用
  6. 实时搜索:按标题或 URL 过滤标签页,支持搜索历史
  7. 导出/导入:将标签页列表导出为 JSON,导入为书签文件夹
  8. 拖拽排序:拖拽标签页在分组间移动,跨窗口重组
  9. 快捷键支持:Ctrl+Shift+T 打开管理器,Ctrl+Shift+D 关闭重复
  10. 数据持久化:自动保存分组的折叠状态和搜索历史
  11. 独立标签页:点击扩展图标打开一个新标签页展示管理界面(而非小弹窗)

3.2 项目结构

tabs-manager/
├── manifest.json          # 扩展的配置文件(入口)
├── background.js          # Service Worker,处理图标点击
├── tab.html               # 独立管理页面的 HTML 结构
├── tab.css                # 样式:Grid 卡片布局、动画、响应式
├── tab.js                 # 核心逻辑:获取标签页、分组、渲染、交互
├── icons/
│   ├── icon16.png
│   ├── icon32.png
│   ├── icon48.png
│   └── icon128.png
└── README.md

3.3 第一步:manifest.json —— 插件的身份证

manifest.json 是 Chrome 插件唯一必需的入口文件,浏览器通过它了解你的插件是什么、需要什么权限、有哪些入口文件。

{
  "manifest_version": 3,
  "name": "Tab管理器",
  "version": "2.0.0",
  "description": "跨窗口管理标签页,支持重复检测、休眠、拖拽排序、导出导入",
  "permissions": [
    "tabs"
  ],
  "background": {
    "service_worker": "background.js"
  },
  "action": {
    "default_icon": {
      "16": "icons/icon16.png",
      "32": "icons/icon32.png",
      "48": "icons/icon48.png",
      "128": "icons/icon128.png"
    }
  },
  "icons": {
    "16": "icons/icon16.png",
    "32": "icons/icon32.png",
    "48": "icons/icon48.png",
    "128": "icons/icon128.png"
  }
}

关键字段解析:

  • manifest_version: 3 —— 声明使用 V3 标准
  • permissions: ["tabs"] —— 申请 tabs 权限,这是操作标签页的前提。没有这个权限,chrome.tabs.query() 会报错
  • background.service_worker —— 指定后台脚本为 background.js。V3 不再支持 background.page
  • action —— 配置工具栏图标。default_icon 指定了不同尺寸的图标,Chrome 会根据 DPI 自动选择最合适的
  • icons —— 扩展管理页面、Chrome 网上应用店等位置显示的图标

3.4 第二步:background.js —— Service Worker 处理图标点击

传统插件点击图标会打开一个小弹窗(popup),但弹窗空间太小。我们希望点击图标打开一个独立的新标签页,空间更大、功能更丰富。

chrome.action.onClicked.addListener(async () => {
  const url = chrome.runtime.getURL('tab.html');
  const tabs = await chrome.tabs.query({ url });
  if (tabs.length > 0) {
    // 如果管理页面已打开,直接激活它
    await chrome.tabs.update(tabs[0].id, { active: true });
    await chrome.windows.update(tabs[0].windowId, { focused: true });
  } else {
    // 否则创建新标签页
    await chrome.tabs.create({ url });
  }
});

技术要点:

  1. chrome.action.onClicked —— 监听用户点击扩展图标的事件。注意:如果 manifest 中配置了 action.default_popup,这个事件就不会触发!所以我们的 manifest 里没有 default_popup
  2. chrome.runtime.getURL('tab.html') —— 将扩展内的相对路径转换为完整的 chrome-extension://<id>/tab.html URL
  3. chrome.tabs.query({ url }) —— 查询是否已有相同 URL 的标签页打开,避免重复创建
  4. chrome.tabs.update(tabId, { active: true }) —— 激活标签页
  5. chrome.windows.update(windowId, { focused: true }) —— 将所在窗口置于最前

3.5 第三步:tab.html —— 管理页面的骨架

这是一个标准的 HTML 页面,没有任何框架依赖,纯原生:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Tab 管理器</title>
  <link rel="stylesheet" href="tab.css">
</head>
<body>
  <div class="app">
    <header class="app-header">
      <div class="header-main">
        <div class="brand">
          <div class="brand-icon">
            <svg width="22" height="22" viewBox="0 0 24 24">...</svg>
          </div>
          <h1>Tab 管理器</h1>
        </div>
        <div class="header-actions">
          <button id="close-all-dupes" class="btn btn-danger">...</button>
          <button id="refresh-btn" class="btn btn-secondary">刷新</button>
        </div>
      </div>
      <div class="header-bar">
        <div id="header-stats" class="stats"></div>
        <div class="search-box">
          <input id="search-input" type="text" placeholder="搜索标题或 URL...">
        </div>
      </div>
    </header>

    <main id="tab-list" class="tab-list"></main>
    <div id="tooltip" class="tooltip"></div>
  </div>

  <script src="tab.js"></script>
</body>
</html>

结构很清晰:顶部是品牌区+操作按钮+统计搜索,中间是标签页卡片网格,底部有一个悬浮 tooltip。

3.6 第四步:tab.css —— Grid 卡片布局与灵动动效

这是 UI 最出彩的部分。我们用 CSS Grid 实现响应式卡片布局,配合微动效让界面灵动起来。

Grid 卡片布局

.tab-list {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(460px, 1fr));
  gap: 16px;
  align-items: start;
}

repeat(auto-fill, minmax(460px, 1fr)) 是 CSS Grid 的精髓:每列最小 460px,自动填充尽可能多的列,剩余空间平均分配。在宽屏上每行 2~3 个卡片,平板以下自动变为单列。

卡片入场 Stagger 动画

.domain-group {
  animation: cardIn 0.35s ease both;
}

.domain-group:nth-child(1) { animation-delay: 0ms; }
.domain-group:nth-child(2) { animation-delay: 40ms; }
/* ... 依次递增 ... */

@keyframes cardIn {
  from {
    opacity: 0;
    transform: translateY(12px) scale(0.98);
  }
  to {
    opacity: 1;
    transform: translateY(0) scale(1);
  }
}

每个卡片依次延迟 40ms 入场,形成一种" cascade "的视觉效果,比所有元素同时出现要优雅得多。

悬浮交互

.domain-group:hover {
  transform: translateY(-3px);
  box-shadow: var(--shadow-lg);
}

.tab-item:hover {
  background: var(--bg-surface-hover);
  transform: translateX(2px);
}

卡片悬浮时轻微上浮(translateY(-3px)),阴影加深;标签项悬浮时微微右移。这些都是只改变 transformbox-shadow 的属性,不会触发重排(reflow),性能极佳。

暗色模式

@media (prefers-color-scheme: dark) {
  :root {
    --bg-body: #0f172a;
    --bg-surface: #1e293b;
    --text-primary: #f1f5f9;
    /* ... */
  }
}

利用 CSS 变量和 prefers-color-scheme 媒体查询,无需任何 JS 即可自动适配系统的明暗主题。

减少动画偏好

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    transition: none !important;
    animation-duration: 0.01ms !important;
  }
}

尊重用户的「减少动画」系统设置,这是可访问性(a11y)的重要一环。

3.7 第五步:tab.js —— 核心逻辑

5.7.1 获取并分组标签页

const SELF_URL = chrome.runtime.getURL('tab.html');

async function fetchTabs() {
  const tabs = await chrome.tabs.query({ currentWindow: true });
  const groups = {};
  for (const tab of tabs) {
    if (tab.url === SELF_URL) continue; // 排除自身
    const domain = getDomain(tab.url);
    if (!groups[domain]) groups[domain] = [];
    groups[domain].push(tab);
  }
  // 排序:普通域名按字母排,"其他"放最后
  const sortedDomains = Object.keys(groups).sort((a, b) => {
    if (a === '其他') return 1;
    if (b === '其他') return -1;
    return a.localeCompare(b);
  });
  // ... 重复检测逻辑 ...
  return { groups, sortedDomains, allTabs: tabs };
}

chrome.tabs.query({ currentWindow: true }) 获取当前窗口的所有标签页。每个标签页对象包含:

  • id:标签页唯一标识
  • url:完整 URL
  • title:页面标题
  • favIconUrl:网站图标 URL
  • active:是否是当前激活的标签页

5.7.2 域名提取

function getDomain(url) {
  try {
    if (!url || url.startsWith('about:') || url.startsWith('chrome:')
        || url.startsWith('edge:') || url.startsWith('file:')
        || url.startsWith('data:') || url.startsWith('javascript:')) {
      return '其他';
    }
    const parsed = new URL(url);
    return parsed.hostname || '其他';
  } catch {
    return '其他';
  }
}

使用原生 URL 构造函数解析域名,特殊页面(chrome://about:blank 等)统一归入「其他」分组。

5.7.3 重复检测算法

for (const domain of sortedDomains) {
  const tabsInGroup = groups[domain];
  const urlCount = new Map();
  const urlFirstIndex = new Map();

  tabsInGroup.forEach((tab, idx) => {
    const count = urlCount.get(tab.url) || 0;
    urlCount.set(tab.url, count + 1);
    if (count === 0) urlFirstIndex.set(tab.url, idx);
  });

  tabsInGroup.forEach((tab, idx) => {
    const count = urlCount.get(tab.url);
    tab._isDuplicate = count > 1;
    tab._isFirstDuplicate = count > 1 && urlFirstIndex.get(tab.url) === idx;
    tab._duplicateCount = count;
  });
}

算法思路:先遍历一遍统计每个 URL 出现次数和第一次出现的索引;再遍历一遍标记每个标签页是否为重复、是否为第一个重复。时间复杂度 O(n),非常高效。

5.7.4 实时搜索过滤

function filterTabs(groups, sortedDomains) {
  if (!searchQuery.trim()) return { groups, sortedDomains };
  const query = searchQuery.toLowerCase();
  const filteredGroups = {};
  const filteredDomains = [];
  for (const domain of sortedDomains) {
    const filtered = groups[domain].filter(tab =>
      (tab.title || '').toLowerCase().includes(query) ||
      (tab.url || '').toLowerCase().includes(query)
    );
    if (filtered.length > 0) {
      filteredGroups[domain] = filtered;
      filteredDomains.push(domain);
    }
  }
  return { groups: filteredGroups, sortedDomains: filteredDomains };
}

搜索框的 input 事件触发重新渲染,通过简单的字符串 includes 匹配实现实时过滤。搜索结果中匹配的关键词会用 <mark> 标签高亮显示。

5.7.5 渲染函数

渲染函数是核心中的核心,它负责将数据结构转化为 DOM:

async function render() {
  tabListEl.innerHTML = '...loading...';
  try {
    const data = await fetchTabs();
    const { groups, sortedDomains } = filterTabs(data.groups, data.sortedDomains);
    renderStats(data.groups, data.sortedDomains);

    tabListEl.innerHTML = '';
    for (const domain of sortedDomains) {
      const tabs = groups[domain];
      // 创建卡片 DOM...
      const groupEl = document.createElement('div');
      groupEl.className = 'domain-group';
      // ... 组装 header + tabs list ...
      tabListEl.appendChild(groupEl);
    }
    bindEvents();
  } catch (err) {
    // 错误状态展示...
  }
}

关键点:

  • 使用 document.createElement 而非 innerHTML 拼接(虽然这里两者混用,但 innerHTML 只用于静态模板部分)
  • 所有事件通过 addEventListener 绑定,绝对不用内联事件处理器

5.7.6 自动刷新机制

chrome.tabs.onRemoved.addListener(() => setTimeout(render, 300));
chrome.tabs.onCreated.addListener(() => setTimeout(render, 300));
chrome.tabs.onUpdated.addListener(() => setTimeout(render, 300));

监听标签页的创建、关闭、更新事件,300ms 后自动重新渲染列表。这样用户在其他地方开关标签页时,管理页面会实时同步。


四、踩坑与解决:CSP 安全策略

4.1 问题现象

开发过程中,浏览器控制台突然报错:

Refused to execute inline event handler because it violates the following
Content Security Policy directive: "script-src 'self'".

4.2 根因分析

我们在 HTML 模板中使用了内联事件处理器:

<!-- 错误示范 -->
<img onerror="this.style.display='none'" ...>
<img onerror="this.src='fallback.png'" ...>

Manifest V3 的默认 CSP 策略 script-src 'self' 完全禁止 onerroronclick 等内联事件处理器。这是为了防止 XSS 攻击——攻击者如果能在你的 HTML 中注入代码,内联事件处理器会成为最直接的执行通道。

4.3 解决方案

将所有内联事件处理器替换为 addEventListener

// 创建元素后,用 JS 绑定事件
const domainFav = headerEl.querySelector('.domain-favicon');
if (domainFav) {
  domainFav.addEventListener('error', () => {
    domainFav.style.display = 'none';
  });
}

const tabFav = tabEl.querySelector('.tab-favicon');
if (tabFav) {
  tabFav.addEventListener('error', () => {
    tabFav.src = tabFav.dataset.fallback;
  });
}

同时,我们把 fallback 的 URL 存在 data-fallback 属性中,而不是写在 onerror 字符串里:

<!-- 正确示范 -->
<img class="tab-favicon" data-fallback="fallback.png" src="primary.png">

4.4 CSP 最佳实践

禁止 替代方案
<script>alert(1)</script> 外部 JS 文件 <script src="app.js">
<button onclick="fn()"> btn.addEventListener('click', fn)
<img onerror="fn()"> img.addEventListener('error', fn)
eval('1+1') 直接写 1+1 或用 JSON.parse
new Function('return 1') 普通函数声明

五、插件安装

5.1 准备图标

Chrome 要求扩展提供多个尺寸的图标(16px、32px、48px、128px)。你可以:

  1. 使用 AI 生成:用任意文生图工具生成一个简洁的图标
  2. 使用在线工具:如 favicon.ioIcons8
  3. 使用 SVG 转 PNG:本项目提供了 icons/ 目录,放入对应尺寸的 PNG 即可

图标命名规范:

icons/
├── icon16.png    # 工具栏图标
├── icon32.png    # Retina 屏幕工具栏
├── icon48.png    # 扩展管理页面
└── icon128.png   # Chrome 网上应用店

5.2 加载扩展(开发者模式)

  1. 打开 Chrome,地址栏输入 chrome://extensions/
  2. 右上角开启 开发者模式(Developer mode)
  3. 点击左上角 加载已解压的扩展程序(Load unpacked)
  4. 选择 tabs-manager 文件夹
  5. 扩展图标出现在工具栏,点击即可使用

5.3 更新扩展

修改代码后,回到 chrome://extensions/ 页面,点击扩展卡片上的 刷新按钮(圆形箭头图标),或按 Ctrl+R(Mac 上 Cmd+R)。

5.4 调试技巧

调试目标 方法
Service Worker chrome://extensions/ → 找到扩展 → 点击「Service Worker」链接,打开 DevTools
独立页面(tab.html) 右键页面 → 检查,和普通网页一样调试
查看扩展 ID chrome://extensions/ 卡片上显示的 ID,用于构造 chrome-extension://<id>/ URL
错误日志 Service Worker 的 Console 面板会显示所有后台报错

六、代码亮点回顾

6.1 架构设计

  • 单一职责background.js 只负责打开页面,tab.js 只负责管理逻辑,职责分离清晰
  • 无框架依赖:纯原生 HTML/CSS/JS,零依赖,加载极快
  • 事件驱动:所有交互通过事件监听实现,符合 CSP 规范

6.2 性能优化

  • CSS 动画仅使用 transformopacity:不触发重排,由 GPU 硬件加速
  • 事件委托与批量绑定结合:动态生成的元素在 bindEvents() 中批量绑定
  • 防抖渲染:搜索输入通过重新渲染实现,实际场景中可加入防抖(debounce)进一步优化

6.3 可访问性(a11y)

  • 所有按钮都有 aria-label 或文字说明
  • focus-visible 为键盘导航提供清晰的焦点环
  • prefers-reduced-motion 尊重用户的减少动画偏好
  • 颜色对比度符合 WCAG 标准

6.4 响应式设计

  • 桌面端:Grid 多列卡片布局
  • 平板(≤768px):单列,操作按钮始终可见
  • 手机(≤480px):紧凑排版,标题自动换行

七、扩展思路

掌握了这个基础框架后,你可以继续添加这些功能:

  1. 跨窗口管理:去掉 currentWindow: true 限制,管理所有窗口的标签页
  2. 标签页休眠/冻结:调用 chrome.tabs.discard(tabId) 释放内存
  3. 导出/导入:将标签页列表导出为 JSON 或书签文件夹
  4. 快捷键支持:通过 chrome.commands API 绑定键盘快捷键
  5. 数据持久化:用 chrome.storage.local 保存用户的折叠状态、搜索历史
  6. 拖拽排序:使用 HTML5 Drag and Drop API 实现标签页在分组间拖拽移动

八、总结

通过「Tab 管理器」这个项目,我们完整走通了 Chrome 插件的开发流程:

  • ✅ 理解了 Manifest V3 的核心结构和权限系统
  • ✅ 掌握了 Service Worker 的事件驱动模型和 chrome.commands 快捷键
  • ✅ 学会了 chrome.tabs API 的查询、更新、关闭、移动、休眠操作
  • ✅ 学会了 chrome.storage.local 数据持久化和 chrome.bookmarks 书签操作
  • ✅ 避开了 CSP 安全策略的常见坑(内联事件处理器)
  • ✅ 用原生技术实现了现代化的 Grid 卡片 UI、流畅动画和拖拽排序
  • ✅ 完成了扩展的安装、加载和调试

Chrome 插件开发门槛不高,但细节很多。希望这篇文章能成为你进入 Chrome 扩展开发世界的敲门砖。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容