强曰为道

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

第 7 章:侧边栏(Side Panel)

第 7 章:侧边栏(Side Panel)

Side Panel(侧边栏)是 Chrome 114 引入的新 UI 组件,它在浏览器窗口右侧提供一个持久化的面板,与标签页并排显示。这使得扩展能够提供持续可见的 UI,非常适合笔记、翻译、AI 助手等场景。


7.1 Side Panel 概述

与 Popup 的对比

特性PopupSide Panel
持久显示❌ 点击外部关闭✅ 保持打开
与页面并排
每个标签页独立❌ 全局唯一✅ 每个标签页独立
DOM 访问✅ 自身✅ 自身
Chrome API✅ 完整✅ 完整
最低版本Chrome 支持Chrome 114+
适合场景快速操作持久交互

Side Panel 布局

┌─────────────────────────────────────────────────────────┐
│  Chrome 工具栏                                            │
├────────────────────────────────────┬────────────────────┤
│                                    │   Side Panel       │
│                                    │   ┌──────────────┐ │
│                                    │   │  扩展名称     │ │
│       网页内容                      │   │              │ │
│       (Web Page)                   │   │  面板内容     │ │
│                                    │   │              │ │
│                                    │   │              │ │
│                                    │   │              │ │
│                                    │   └──────────────┘ │
└────────────────────────────────────┴────────────────────┘

7.2 基本配置

manifest.json

{
  "manifest_version": 3,
  "name": "Side Panel Demo",
  "version": "1.0.0",
  "permissions": ["sidePanel"],
  "side_panel": {
    "default_path": "sidepanel/sidepanel.html"
  },
  "action": {
    "default_title": "打开扩展"
  }
}

Side Panel HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Side Panel</title>
  <link rel="stylesheet" href="sidepanel.css">
</head>
<body>
  <div class="panel-container">
    <header class="panel-header">
      <h1>📝 我的笔记</h1>
      <div class="header-actions">
        <button id="newNoteBtn" class="btn btn-sm">+ 新建</button>
      </div>
    </header>

    <div class="panel-toolbar">
      <input type="text" id="searchInput"
             placeholder="搜索笔记..." class="search-input">
    </div>

    <main class="panel-content" id="noteList">
      <!-- 笔记列表将动态渲染在此 -->
    </main>

    <footer class="panel-footer">
      <span id="noteCount">0 条笔记</span>
      <button id="settingsBtn" class="btn btn-sm btn-icon">⚙️</button>
    </footer>
  </div>

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

7.3 Side Panel CSS

/* sidepanel/sidepanel.css */
* { margin: 0; padding: 0; box-sizing: border-box; }

:root {
  --primary: #4285f4;
  --bg: #ffffff;
  --bg-secondary: #f8f9fa;
  --text: #202124;
  --text-secondary: #5f6368;
  --border: #dadce0;
  --radius: 8px;
}

body {
  font-family: 'Segoe UI', system-ui, sans-serif;
  font-size: 14px;
  color: var(--text);
  background: var(--bg);
  height: 100vh;
  display: flex;
  flex-direction: column;
}

.panel-container {
  display: flex;
  flex-direction: column;
  height: 100%;
}

.panel-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 12px 16px;
  border-bottom: 1px solid var(--border);
  background: var(--bg);
}

.panel-header h1 {
  font-size: 16px;
  font-weight: 600;
}

.panel-toolbar {
  padding: 8px 16px;
  background: var(--bg-secondary);
  border-bottom: 1px solid var(--border);
}

.search-input {
  width: 100%;
  padding: 8px 12px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  font-size: 14px;
  background: var(--bg);
  outline: none;
}

.search-input:focus {
  border-color: var(--primary);
  box-shadow: 0 0 0 2px rgba(66, 133, 244, 0.2);
}

.panel-content {
  flex: 1;
  overflow-y: auto;
  padding: 8px;
}

.panel-footer {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 8px 16px;
  border-top: 1px solid var(--border);
  background: var(--bg-secondary);
  font-size: 12px;
  color: var(--text-secondary);
}

/* 笔记卡片 */
.note-card {
  padding: 12px;
  margin-bottom: 8px;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  cursor: pointer;
  transition: all 0.2s;
}

.note-card:hover {
  border-color: var(--primary);
  box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}

.note-card.active {
  border-color: var(--primary);
  background: rgba(66, 133, 244, 0.04);
}

.note-title {
  font-weight: 600;
  font-size: 14px;
  margin-bottom: 4px;
}

.note-preview {
  font-size: 13px;
  color: var(--text-secondary);
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.note-meta {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-top: 8px;
  font-size: 11px;
  color: var(--text-secondary);
}

.note-tag {
  display: inline-block;
  padding: 2px 6px;
  background: var(--bg-secondary);
  border-radius: 4px;
  font-size: 11px;
}

.btn {
  padding: 6px 12px;
  border: none;
  border-radius: var(--radius);
  cursor: pointer;
  font-size: 13px;
  transition: all 0.2s;
}

.btn-sm { padding: 4px 8px; font-size: 12px; }

.btn-icon {
  background: none;
  border: none;
  font-size: 16px;
  padding: 4px;
}

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

/* 空状态 */
.empty-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  height: 100%;
  color: var(--text-secondary);
  text-align: center;
}

.empty-state .icon { font-size: 48px; margin-bottom: 12px; }

@media (prefers-color-scheme: dark) {
  :root {
    --bg: #202124;
    --bg-secondary: #292a2d;
    --text: #e8eaed;
    --text-secondary: #9aa0a6;
    --border: #5f6368;
  }
}

7.4 Side Panel JavaScript

7.4.1 基础交互

// sidepanel/sidepanel.js

class SidePanelApp {
  constructor() {
    this.notes = [];
    this.activeNoteId = null;
    this.init();
  }

  async init() {
    await this.loadNotes();
    this.bindEvents();
    this.render();

    // 监听存储变更
    chrome.storage.onChanged.addListener((changes) => {
      if (changes.notes) {
        this.notes = changes.notes.newValue || [];
        this.render();
      }
    });
  }

  async loadNotes() {
    const { notes = [] } = await chrome.storage.local.get('notes');
    this.notes = notes;
  }

  async saveNotes() {
    await chrome.storage.local.set({ notes: this.notes });
  }

  bindEvents() {
    document.getElementById('newNoteBtn')
      .addEventListener('click', () => this.createNote());

    document.getElementById('searchInput')
      .addEventListener('input', (e) => this.filterNotes(e.target.value));

    document.getElementById('settingsBtn')
      .addEventListener('click', () => {
        chrome.runtime.openOptionsPage();
      });
  }

  async createNote() {
    const note = {
      id: Date.now().toString(),
      title: '新笔记',
      content: '',
      tags: [],
      createdAt: Date.now(),
      updatedAt: Date.now()
    };

    this.notes.unshift(note);
    await this.saveNotes();
    this.activeNoteId = note.id;
    this.render();
  }

  async deleteNote(noteId) {
    this.notes = this.notes.filter(n => n.id !== noteId);
    if (this.activeNoteId === noteId) {
      this.activeNoteId = null;
    }
    await this.saveNotes();
  }

  filterNotes(query) {
    const lowerQuery = query.toLowerCase();
    const filtered = this.notes.filter(note =>
      note.title.toLowerCase().includes(lowerQuery) ||
      note.content.toLowerCase().includes(lowerQuery)
    );
    this.render(filtered);
  }

  render(notesToRender = null) {
    const notes = notesToRender || this.notes;
    const container = document.getElementById('noteList');
    const countEl = document.getElementById('noteCount');

    countEl.textContent = `${this.notes.length} 条笔记`;

    if (notes.length === 0) {
      container.innerHTML = `
        <div class="empty-state">
          <span class="icon">📝</span>
          <p>暂无笔记</p>
          <p>点击"新建"开始记录</p>
        </div>
      `;
      return;
    }

    container.innerHTML = notes.map(note => `
      <div class="note-card ${note.id === this.activeNoteId ? 'active' : ''}"
           data-id="${note.id}">
        <div class="note-title">${this.escapeHtml(note.title)}</div>
        <div class="note-preview">
          ${this.escapeHtml(note.content.substring(0, 100))}
        </div>
        <div class="note-meta">
          <span>${this.formatDate(note.updatedAt)}</span>
          <div class="note-tags">
            ${note.tags.map(
              tag => `<span class="note-tag">${tag}</span>`
            ).join('')}
          </div>
        </div>
      </div>
    `).join('');

    // 绑定卡片事件
    container.querySelectorAll('.note-card').forEach(card => {
      card.addEventListener('click', () => {
        this.activeNoteId = card.dataset.id;
        this.render();
      });
    });
  }

  escapeHtml(text) {
    const div = document.createElement('div');
    div.textContent = text;
    return div.innerHTML;
  }

  formatDate(timestamp) {
    const date = new Date(timestamp);
    const now = new Date();
    const diffMs = now - date;
    const diffMins = Math.floor(diffMs / 60000);

    if (diffMins < 1) return '刚刚';
    if (diffMins < 60) return `${diffMins} 分钟前`;
    if (diffMins < 1440) return `${Math.floor(diffMins / 60)} 小时前`;
    return date.toLocaleDateString('zh-CN');
  }
}

document.addEventListener('DOMContentLoaded', () => {
  new SidePanelApp();
});

7.5 Side Panel 的行为控制

7.5.1 打开与关闭

// Service Worker 中控制 Side Panel

// 打开 Side Panel
chrome.sidePanel.open({ tabId: tab.id });

// 关闭 Side Panel(Chrome 116+)
chrome.sidePanel.close({ tabId: tab.id });

// 获取 Side Panel 状态
const options = await chrome.sidePanel.getOptions({ tabId: tab.id });
console.log('当前面板路径:', options.path);

7.5.2 设置行为

// 配置 Side Panel 行为
chrome.sidePanel.setPanelBehavior({
  openPanelOnActionClick: true  // 点击扩展图标时打开 Side Panel
});

// 取消默认行为,改为自定义处理
chrome.sidePanel.setPanelBehavior({
  openPanelOnActionClick: false
});

// 自定义点击行为
chrome.action.onClicked.addListener(async (tab) => {
  // 根据条件决定是否打开
  if (tab.url.includes('example.com')) {
    await chrome.sidePanel.open({ tabId: tab.id });
  } else {
    // 其他逻辑
    await chrome.sidePanel.setOptions({
      path: 'sidepanel/alternative.html',
      tabId: tab.id
    });
    await chrome.sidePanel.open({ tabId: tab.id });
  }
});

7.5.3 每个标签页不同面板

// 根据标签页 URL 显示不同的 Side Panel
chrome.tabs.onActivated.addListener(async (activeInfo) => {
  const tab = await chrome.tabs.get(activeInfo.tabId);

  if (tab.url?.includes('github.com')) {
    await chrome.sidePanel.setOptions({
      path: 'sidepanel/github-panel.html',
      tabId: tab.id,
      enabled: true
    });
  } else if (tab.url?.includes('docs.google.com')) {
    await chrome.sidePanel.setOptions({
      path: 'sidepanel/docs-panel.html',
      tabId: tab.id,
      enabled: true
    });
  } else {
    await chrome.sidePanel.setOptions({
      path: 'sidepanel/default-panel.html',
      tabId: tab.id,
      enabled: true
    });
  }
});

7.6 Side Panel 与 Content Script 协作

实时页面分析

// sidepanel.js — 显示当前页面的分析数据
class PageAnalyzer {
  constructor() {
    this.init();
  }

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

    // 请求 Content Script 分析页面
    this.analyzePage(tab.id);

    // 监听标签页切换
    chrome.tabs.onActivated.addListener(async (info) => {
      this.analyzePage(info.tabId);
    });

    // 监听页面更新
    chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
      if (changeInfo.status === 'complete') {
        const [currentTab] = chrome.tabs.query({
          active: true, currentWindow: true
        });
        if (tabId === currentTab?.id) {
          this.analyzePage(tabId);
        }
      }
    });
  }

  async analyzePage(tabId) {
    try {
      const response = await chrome.tabs.sendMessage(tabId, {
        type: 'ANALYZE_PAGE'
      });

      this.renderAnalysis(response.data);
    } catch (error) {
      this.renderError('无法分析此页面');
    }
  }

  renderAnalysis(data) {
    document.getElementById('analysis').innerHTML = `
      <div class="analysis-section">
        <h3>页面信息</h3>
        <div class="stat-grid">
          <div class="stat">
            <span class="stat-value">${data.wordCount}</span>
            <span class="stat-label">字数</span>
          </div>
          <div class="stat">
            <span class="stat-value">${data.linkCount}</span>
            <span class="stat-label">链接</span>
          </div>
          <div class="stat">
            <span class="stat-value">${data.imageCount}</span>
            <span class="stat-label">图片</span>
          </div>
        </div>
      </div>
      <div class="analysis-section">
        <h3>标题结构</h3>
        <ul class="heading-list">
          ${data.headings.map(h =>
            `<li class="heading-${h.level}">${h.text}</li>`
          ).join('')}
        </ul>
      </div>
    `;
  }
}

7.7 注意事项

问题说明解决方案
Side Panel 不显示Chrome 版本 < 114升级 Chrome 或检测版本
面板空白manifest 配置错误检查 side_panel.default_path
标签页关闭后面板状态丢失面板与标签页生命周期绑定使用 Storage 保存状态
setPanelBehavior 不生效仅影响 action 点击行为确认 openPanelOnActionClick
多标签页面板切换慢每个标签页独立面板减少面板初始化开销

7.8 扩展阅读