Реализация Side Panel браузерного расширения (Chrome)
Side Panel — боковая панель Chrome, появившаяся в Chrome 114. Она работает как постоянная панель сбоку от контента страницы, не закрывается при переключении вкладок и имеет значительно больше места, чем Popup. Это первый нативный механизм для «прикреплённых» интерфейсов в Chrome.
Отличия Side Panel от Popup
| Характеристика | Popup | Side Panel |
|---|---|---|
| Ширина | 800 px макс. | ~400 px (фиксированная Chrome) |
| Жизненный цикл | До потери фокуса | Постоянная, переживает смену вкладок |
| Контекст вкладки | Привязан к активной | Может быть глобальной или per-tab |
| Доступность | С Chrome 4 | С Chrome 114 |
| Firefox/Safari | Нет аналога | Нет аналога |
Подключение в Manifest V3
{
"manifest_version": 3,
"name": "My Side Panel Extension",
"version": "1.0.0",
"permissions": ["storage", "tabs", "activeTab", "scripting", "sidePanel"],
"background": {
"service_worker": "background.js"
},
"action": {
"default_icon": "icons/icon48.png",
"default_title": "Открыть панель"
},
"side_panel": {
"default_path": "panel/panel.html"
}
}
Открытие панели по клику на иконку
По умолчанию клик на иконку расширения ничего не делает с Side Panel. Нужно явно настроить поведение:
// background.js (Service Worker)
// Вариант 1: открывать панель при каждом клике на иконку
chrome.sidePanel
.setPanelBehavior({ openPanelOnActionClick: true })
.catch(console.error);
// Вариант 2: открывать программно (требует user gesture)
chrome.action.onClicked.addListener(async (tab) => {
await chrome.sidePanel.open({ tabId: tab.id });
});
Per-tab vs глобальная панель
Панель может быть разной для каждой вкладки или общей для всех:
// background.js
// Разная панель в зависимости от сайта
chrome.tabs.onUpdated.addListener(async (tabId, info, tab) => {
if (info.status !== 'complete') return;
if (tab.url?.includes('github.com')) {
await chrome.sidePanel.setOptions({
tabId,
path: 'panel/github-panel.html',
enabled: true,
});
} else if (tab.url?.includes('figma.com')) {
await chrome.sidePanel.setOptions({
tabId,
path: 'panel/design-panel.html',
enabled: true,
});
} else {
// Выключить панель для обычных страниц
await chrome.sidePanel.setOptions({ tabId, enabled: false });
}
});
React-приложение в Side Panel
Структура panel/panel.html аналогична Popup, но имеет больше пространства:
// panel/App.tsx
import { useEffect, useState, useRef } from 'react';
import browser from 'webextension-polyfill';
export function SidePanel() {
const [notes, setNotes] = useState<string[]>([]);
const [currentUrl, setCurrentUrl] = useState('');
const portRef = useRef<browser.Runtime.Port | null>(null);
useEffect(() => {
// Постоянное соединение с background для стриминга данных
portRef.current = browser.runtime.connect({ name: 'side-panel' });
portRef.current.onMessage.addListener((msg) => {
if (msg.type === 'PAGE_CHANGED') {
setCurrentUrl(msg.url);
loadNotesForUrl(msg.url);
}
if (msg.type === 'SELECTION_CHANGED') {
// Пользователь выделил текст на странице
handleNewSelection(msg.text);
}
});
// Загрузить начальное состояние
browser.tabs.query({ active: true, currentWindow: true }).then(([tab]) => {
if (tab.url) {
setCurrentUrl(tab.url);
loadNotesForUrl(tab.url);
}
});
return () => portRef.current?.disconnect();
}, []);
async function loadNotesForUrl(url: string) {
const domain = new URL(url).hostname;
const { notes } = await browser.storage.local.get(`notes_${domain}`);
setNotes(notes ?? []);
}
async function addNote(text: string) {
const domain = new URL(currentUrl).hostname;
const updated = [...notes, text];
setNotes(updated);
await browser.storage.local.set({ [`notes_${domain}`]: updated });
}
return (
<div className="side-panel">
<header className="side-panel__header">
<h2>Заметки</h2>
<span className="side-panel__url">{new URL(currentUrl).hostname}</span>
</header>
<div className="side-panel__notes">
{notes.map((note, i) => (
<div key={i} className="note">{note}</div>
))}
</div>
<NoteInput onAdd={addNote} />
</div>
);
}
Связь с Content Script через Port
Side Panel живёт долго — удобно использовать постоянные соединения вместо разовых sendMessage:
// background.js
const sidePanelPorts = new Map(); // tabId -> Port
chrome.runtime.onConnect.addListener((port) => {
if (port.name !== 'side-panel') return;
// Определяем, к какой вкладке относится панель
chrome.tabs.query({ active: true, currentWindow: true }, ([tab]) => {
sidePanelPorts.set(tab.id, port);
port.onDisconnect.addListener(() => {
sidePanelPorts.delete(tab.id);
});
});
});
// Когда content script присылает событие — пересылаем в панель
chrome.runtime.onMessage.addListener((msg, sender) => {
if (msg.type === 'SELECTION_CHANGED') {
const port = sidePanelPorts.get(sender.tab?.id);
port?.postMessage({ type: 'SELECTION_CHANGED', text: msg.text });
}
});
// content.js — следит за выделением текста
document.addEventListener('mouseup', () => {
const selection = window.getSelection()?.toString().trim();
if (selection && selection.length > 3) {
chrome.runtime.sendMessage({ type: 'SELECTION_CHANGED', text: selection });
}
});
Определение смены вкладки
Side Panel должен реагировать на переключение вкладок пользователем:
// background.js
chrome.tabs.onActivated.addListener(async ({ tabId }) => {
const tab = await chrome.tabs.get(tabId);
const port = sidePanelPorts.get(tabId);
port?.postMessage({ type: 'PAGE_CHANGED', url: tab.url, title: tab.title });
});
chrome.tabs.onUpdated.addListener((tabId, info, tab) => {
if (info.status !== 'complete') return;
const port = sidePanelPorts.get(tabId);
port?.postMessage({ type: 'PAGE_CHANGED', url: tab.url, title: tab.title });
});
Сроки
Side Panel с React, постоянным соединением к background, синхронизацией с вкладками и хранением данных — 3–5 рабочих дней. Панель с реалтайм-взаимодействием с контентом страницы, историей и поиском — 6–9 дней.







