Chrome 扩展开发完全指南 / 第 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 |