第 7 章:侧边栏(Side Panel)
第 7 章:侧边栏(Side Panel)
Side Panel(侧边栏)是 Chrome 114 引入的新 UI 组件,它在浏览器窗口右侧提供一个持久化的面板,与标签页并排显示。这使得扩展能够提供持续可见的 UI,非常适合笔记、翻译、AI 助手等场景。
7.1 Side Panel 概述
| 特性 | Popup | Side 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 扩展阅读