强曰为道

与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

第 5 章:弹出页面(Popup)

第 5 章:弹出页面(Popup)

弹出页面(Popup)是用户与 Chrome 扩展最直接的交互界面。当用户点击浏览器工具栏上的扩展图标时,Popup 就会弹出。本章将详细介绍如何设计和实现美观、高效的 Popup 界面。


5.1 Popup 基础

5.1.1 什么是 Popup

Popup 是一个独立的 HTML 页面,当用户点击扩展图标时显示。它具有以下特性:

特性说明
独立 DOM与网页 DOM 完全隔离
完整 Web API可使用所有 Web API
Chrome API可访问扩展 API(与 Service Worker 相同)
自动关闭点击 Popup 外部时自动关闭
尺寸限制最大 800×600 像素,最小 宽度 25px
生命周期打开时创建,关闭时销毁

5.1.2 manifest.json 声明

{
  "action": {
    "default_popup": "popup/popup.html",
    "default_icon": {
      "16": "icons/icon-16.png",
      "32": "icons/icon-32.png",
      "48": "icons/icon-48.png",
      "128": "icons/icon-128.png"
    },
    "default_title": "打开我的扩展"
  }
}

5.2 Popup HTML 结构

基础模板

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Popup</title>
  <link rel="stylesheet" href="popup.css">
</head>
<body>
  <div class="popup-container">
    <!-- 头部 -->
    <header class="popup-header">
      <img src="../icons/icon-32.png" alt="Logo" class="logo">
      <h1>我的扩展</h1>
      <button id="settingsBtn" class="icon-btn" title="设置">⚙️</button>
    </header>

    <!-- 主内容区 -->
    <main class="popup-content">
      <div id="loading" class="loading">
        <div class="spinner"></div>
        <p>加载中...</p>
      </div>

      <div id="mainContent" class="hidden">
        <!-- 动态内容 -->
      </div>

      <div id="errorState" class="hidden">
        <p class="error-message"></p>
        <button id="retryBtn" class="btn btn-primary">重试</button>
      </div>
    </main>

    <!-- 底部操作栏 -->
    <footer class="popup-footer">
      <button id="actionBtn" class="btn btn-primary">执行操作</button>
      <span class="version">v1.0.0</span>
    </footer>
  </div>

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

5.3 Popup CSS 设计

5.3.1 基础样式

/* popup/popup.css */

/* 重置与基础 */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

:root {
  --primary: #4285f4;
  --primary-hover: #3367d6;
  --success: #34a853;
  --warning: #fbbc04;
  --error: #ea4335;
  --bg: #ffffff;
  --bg-secondary: #f8f9fa;
  --text: #202124;
  --text-secondary: #5f6368;
  --border: #dadce0;
  --shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
  --radius: 8px;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
               'Helvetica Neue', Arial, sans-serif;
  font-size: 14px;
  color: var(--text);
  background: var(--bg);
  min-width: 350px;
  max-width: 400px;
}

/* 容器 */
.popup-container {
  display: flex;
  flex-direction: column;
  height: 100%;
  min-height: 200px;
}

/* 头部 */
.popup-header {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 12px 16px;
  background: var(--primary);
  color: white;
  border-bottom: 1px solid var(--border);
}

.popup-header h1 {
  font-size: 16px;
  font-weight: 600;
  flex: 1;
}

.popup-header .logo {
  width: 24px;
  height: 24px;
  border-radius: 4px;
}

/* 主内容区 */
.popup-content {
  flex: 1;
  padding: 16px;
  overflow-y: auto;
  max-height: 400px;
}

/* 底部 */
.popup-footer {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 12px 16px;
  background: var(--bg-secondary);
  border-top: 1px solid var(--border);
}

.version {
  font-size: 12px;
  color: var(--text-secondary);
}

/* 按钮 */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  padding: 8px 16px;
  font-size: 14px;
  font-weight: 500;
  border: none;
  border-radius: var(--radius);
  cursor: pointer;
  transition: all 0.2s ease;
}

.btn-primary {
  background: var(--primary);
  color: white;
}

.btn-primary:hover {
  background: var(--primary-hover);
}

.btn-primary:disabled {
  background: #ccc;
  cursor: not-allowed;
}

.icon-btn {
  background: none;
  border: none;
  cursor: pointer;
  padding: 4px;
  font-size: 18px;
  opacity: 0.8;
  transition: opacity 0.2s;
}

.icon-btn:hover {
  opacity: 1;
}

/* 加载状态 */
.loading {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 32px;
}

.spinner {
  width: 32px;
  height: 32px;
  border: 3px solid var(--border);
  border-top-color: var(--primary);
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

/* 工具类 */
.hidden { display: none !important; }
.error-message { color: var(--error); }

5.3.2 深色模式支持

/* 支持系统深色模式 */
@media (prefers-color-scheme: dark) {
  :root {
    --bg: #292a2d;
    --bg-secondary: #35363a;
    --text: #e8eaed;
    --text-secondary: #9aa0a6;
    --border: #5f6368;
  }

  .popup-header {
    background: #35363a;
  }
}

5.4 Popup JavaScript 逻辑

5.4.1 与 Service Worker 通信

// popup/popup.js

class PopupApp {
  constructor() {
    this.elements = {
      loading: document.getElementById('loading'),
      mainContent: document.getElementById('mainContent'),
      errorState: document.getElementById('errorState'),
      errorMessage: document.querySelector('.error-message'),
      actionBtn: document.getElementById('actionBtn'),
      settingsBtn: document.getElementById('settingsBtn'),
      retryBtn: document.getElementById('retryBtn')
    };

    this.init();
  }

  async init() {
    this.bindEvents();
    await this.loadData();
  }

  bindEvents() {
    this.elements.actionBtn.addEventListener('click', () => this.handleAction());
    this.elements.settingsBtn.addEventListener('click', () => this.openSettings());
    this.elements.retryBtn.addEventListener('click', () => this.loadData());
  }

  showLoading() {
    this.elements.loading.classList.remove('hidden');
    this.elements.mainContent.classList.add('hidden');
    this.elements.errorState.classList.add('hidden');
  }

  showContent() {
    this.elements.loading.classList.add('hidden');
    this.elements.mainContent.classList.remove('hidden');
    this.elements.errorState.classList.add('hidden');
  }

  showError(message) {
    this.elements.loading.classList.add('hidden');
    this.elements.mainContent.classList.add('hidden');
    this.elements.errorState.classList.remove('hidden');
    this.elements.errorMessage.textContent = message;
  }

  async loadData() {
    this.showLoading();

    try {
      // 获取当前标签页
      const [tab] = await chrome.tabs.query({
        active: true,
        currentWindow: true
      });

      // 从 Service Worker 获取数据
      const response = await chrome.runtime.sendMessage({
        type: 'GET_POPUP_DATA',
        url: tab.url,
        tabId: tab.id
      });

      if (response.success) {
        this.renderData(response.data, tab);
        this.showContent();
      } else {
        this.showError(response.error || '加载失败');
      }
    } catch (error) {
      this.showError('无法连接到后台服务');
    }
  }

  renderData(data, tab) {
    this.elements.mainContent.innerHTML = `
      <div class="data-card">
        <h2>当前页面</h2>
        <p class="url">${tab.title || tab.url}</p>
        <div class="stats">
          <div class="stat-item">
            <span class="stat-value">${data.visits || 0}</span>
            <span class="stat-label">访问次数</span>
          </div>
          <div class="stat-item">
            <span class="stat-value">${data.lastVisit || '首次'}</span>
            <span class="stat-label">上次访问</span>
          </div>
        </div>
      </div>
    `;
  }

  async handleAction() {
    this.elements.actionBtn.disabled = true;
    this.elements.actionBtn.textContent = '处理中...';

    try {
      const [tab] = await chrome.tabs.query({
        active: true, currentWindow: true
      });

      const response = await chrome.runtime.sendMessage({
        type: 'EXECUTE_ACTION',
        tabId: tab.id,
        url: tab.url
      });

      if (response.success) {
        this.elements.actionBtn.textContent = '✓ 完成';
        setTimeout(() => window.close(), 1000);
      }
    } catch (error) {
      this.elements.actionBtn.textContent = '重试';
    } finally {
      this.elements.actionBtn.disabled = false;
    }
  }

  openSettings() {
    chrome.runtime.openOptionsPage();
  }
}

// 初始化
document.addEventListener('DOMContentLoaded', () => {
  new PopupApp();
});

5.4.2 直接操作当前标签页

// 在 Popup 中直接向 Content Script 发送消息
async function sendToContentScript(message) {
  const [tab] = await chrome.tabs.query({
    active: true,
    currentWindow: true
  });

  try {
    const response = await chrome.tabs.sendMessage(tab.id, message);
    return response;
  } catch (error) {
    // Content Script 可能未注入
    console.warn('Content Script 未响应,尝试注入...');
    await chrome.scripting.executeScript({
      target: { tabId: tab.id },
      files: ['content/content.js']
    });

    // 重新发送
    return await chrome.tabs.sendMessage(tab.id, message);
  }
}

// 使用示例
document.getElementById('highlightBtn').addEventListener('click', async () => {
  const result = await sendToContentScript({
    type: 'HIGHLIGHT',
    selector: '.important',
    color: '#FFEB3B'
  });

  if (result?.success) {
    showNotification('已高亮标记');
  }
});

5.5 Popup 交互模式

5.5.1 标签页切换

<div class="tabs">
  <nav class="tab-nav">
    <button class="tab-link active" data-tab="overview">概览</button>
    <button class="tab-link" data-tab="history">历史</button>
    <button class="tab-link" data-tab="settings">设置</button>
  </nav>

  <div class="tab-content active" id="tab-overview">
    <!-- 概览内容 -->
  </div>
  <div class="tab-content" id="tab-history">
    <!-- 历史内容 -->
  </div>
  <div class="tab-content" id="tab-settings">
    <!-- 快捷设置 -->
  </div>
</div>
// Tab 切换逻辑
document.querySelectorAll('.tab-link').forEach(link => {
  link.addEventListener('click', (e) => {
    const tabId = e.target.dataset.tab;

    // 切换按钮状态
    document.querySelectorAll('.tab-link').forEach(
      l => l.classList.remove('active')
    );
    e.target.classList.add('active');

    // 切换内容
    document.querySelectorAll('.tab-content').forEach(
      c => c.classList.remove('active')
    );
    document.getElementById(`tab-${tabId}`).classList.add('active');
  });
});

5.5.2 列表与搜索

// 带搜索的列表组件
function renderSearchableList(items, container) {
  container.innerHTML = `
    <div class="search-bar">
      <input type="text" id="searchInput"
             placeholder="搜索..." class="search-input">
    </div>
    <ul class="item-list" id="itemList">
      ${items.map(item => `
        <li class="item" data-id="${item.id}">
          <img src="${item.icon}" class="item-icon" alt="">
          <div class="item-info">
            <span class="item-title">${item.title}</span>
            <span class="item-desc">${item.description}</span>
          </div>
          <button class="item-action" data-id="${item.id}">操作</button>
        </li>
      `).join('')}
    </ul>
  `;

  // 搜索过滤
  document.getElementById('searchInput').addEventListener('input', (e) => {
    const query = e.target.value.toLowerCase();
    document.querySelectorAll('.item').forEach(item => {
      const title = item.querySelector('.item-title').textContent.toLowerCase();
      const desc = item.querySelector('.item-desc').textContent.toLowerCase();
      item.style.display =
        (title.includes(query) || desc.includes(query)) ? '' : 'none';
    });
  });
}

5.6 动态控制 Popup 行为

5.6.1 条件性禁用 Popup

// Service Worker 中:某些页面不显示 Popup
chrome.tabs.onActivated.addListener(async (activeInfo) => {
  const tab = await chrome.tabs.get(activeInfo.tabId);

  if (tab.url?.startsWith('chrome://') ||
      tab.url?.startsWith('chrome-extension://')) {
    // 禁用 Popup,改为处理点击事件
    chrome.action.setPopup({ popup: '' });
  } else {
    // 启用 Popup
    chrome.action.setPopup({ popup: 'popup/popup.html' });
  }
});
// Service Worker 中处理无 Popup 时的点击事件
chrome.action.onClicked.addListener((tab) => {
  // 仅在 Popup 未设置时触发
  chrome.tabs.create({ url: 'https://example.com/help' });
});

5.6.2 动态更新 Popup 内容

// Popup 打开时主动刷新数据
document.addEventListener('DOMContentLoaded', async () => {
  // 注册实时更新监听
  chrome.storage.onChanged.addListener((changes, area) => {
    if (area === 'local' && changes.popupData) {
      updateUI(changes.popupData.newValue);
    }
  });
});

5.7 业务场景

场景一:网页剪藏工具

// popup.js — 剪藏当前页面
document.getElementById('clipBtn').addEventListener('click', async () => {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });

  // 让 Content Script 提取页面内容
  const pageContent = await chrome.tabs.sendMessage(tab.id, {
    type: 'EXTRACT_CONTENT'
  });

  // 发送到 Service Worker 处理
  const result = await chrome.runtime.sendMessage({
    type: 'SAVE_CLIP',
    data: {
      title: tab.title,
      url: tab.url,
      content: pageContent.html,
      text: pageContent.text,
      clippedAt: Date.now()
    }
  });

  if (result.success) {
    document.getElementById('clipBtn').textContent = '✓ 已保存';
  }
});

场景二:快捷笔记

// popup.js — 快速记录笔记
class QuickNoteApp {
  constructor() {
    this.noteInput = document.getElementById('noteInput');
    this.saveBtn = document.getElementById('saveBtn');
    this.noteList = document.getElementById('noteList');

    this.init();
  }

  async init() {
    this.saveBtn.addEventListener('click', () => this.saveNote());
    await this.loadNotes();

    // Ctrl+Enter 快速保存
    this.noteInput.addEventListener('keydown', (e) => {
      if (e.ctrlKey && e.key === 'Enter') this.saveNote();
    });
  }

  async saveNote() {
    const text = this.noteInput.value.trim();
    if (!text) return;

    const [tab] = await chrome.tabs.query({
      active: true, currentWindow: true
    });

    await chrome.runtime.sendMessage({
      type: 'SAVE_NOTE',
      data: {
        text,
        source: tab.url,
        timestamp: Date.now()
      }
    });

    this.noteInput.value = '';
    await this.loadNotes();
  }

  async loadNotes() {
    const { notes = [] } = await chrome.storage.local.get('notes');
    this.noteList.innerHTML = notes.slice(0, 10).map(note => `
      <div class="note-item">
        <p>${note.text}</p>
        <span class="note-time">
          ${new Date(note.timestamp).toLocaleString()}
        </span>
      </div>
    `).join('');
  }
}

5.8 注意事项

问题原因解决方案
Popup 打开后空白JS 报错阻止渲染检查 Console 错误日志
点击图标无反应未设置 default_popup检查 manifest.json 配置
尺寸过大/过小CSS 未适配设置合理的 min/max width
状态丢失Popup 关闭后重新打开使用 Storage 持久化数据
chrome.tabs 未定义缺少 tabs 权限添加 "tabs" 权限或使用 activeTab

5.9 扩展阅读