本文档将两个 Chrome Manifest V3 插件的开发清单合并到同一个工程级计划中。
工程名称:
chrome_plugins
子插件 1:bookmark—— 书签快速搜索与打开插件
子插件 2:History—— 历史记录搜索与最近关闭恢复插件
chrome_plugins 不是一个单独的 Chrome 插件,而是一个工程仓库。仓库中包含两个彼此独立的 Chrome 插件:
bookmark/:专注于读取、搜索、展示和打开 Chrome 书签。history/:专注于读取、搜索、展示 Chrome 历史记录,并可扩展“最近关闭标签页/窗口”。
两个插件应该独立开发、独立加载、独立发布。不要把两个插件的权限和功能混在同一个 manifest.json 里,否则会导致权限过多、职责不清、后期维护困难。
chrome_plugins/
├─ README.md
├─ bookmark/
│ ├─ manifest.json
│ ├─ popup.html
│ ├─ popup.css
│ ├─ popup.js
│ └─ icons/
│ ├─ icon16.png
│ ├─ icon48.png
│ └─ icon128.png
└─ history/
├─ manifest.json
├─ popup.html
├─ popup.css
├─ popup.js
└─ icons/
├─ icon16.png
├─ icon48.png
└─ icon128.png
说明:
chrome_plugins/是总工程目录。bookmark/和history/分别是两个可以被 Chrome 独立加载的扩展目录。- 每个子目录都必须有自己的
manifest.json。 - 每个插件都应有自己的图标、Popup 页面、CSS 和 JS。
- 不建议第一版抽公共代码,因为两个插件都很小,过早抽象会增加复杂度。
| 插件 | 主要 API | 核心权限 | 主要功能 | 不建议混入 |
|---|---|---|---|---|
bookmark |
chrome.bookmarks |
bookmarks、可选 favicon |
读取书签树、递归展开、搜索书签、打开书签 | 历史记录、最近关闭标签页 |
History |
chrome.history、chrome.sessions |
history、可选 sessions、可选 tabs |
搜索历史记录、显示最近访问、恢复最近关闭 | 书签管理、书签树遍历 |
核心原则:
bookmark只关心“用户收藏过的网站”。History只关心“用户访问过的网站”和“最近关闭的会话”。- 两者 UI 风格可以统一,但权限和数据来源必须分开。
Chrome 插件权限越少,用户越容易信任,也越容易通过后续发布审核。
推荐第一版:
"permissions": ["bookmarks", "favicon"]解释:
bookmarks:读取 Chrome 书签树,必须申请。favicon:如果使用 MV3 的/_favicon/内置路径显示网页图标,建议申请。- 不需要
history。 - 不需要
sessions。 - 创建新标签页通常可以直接使用
chrome.tabs.create({ url }),不一定需要额外声明tabs权限;如实际测试受限,再补充。
基础版:
"permissions": ["history"]增强版:
"permissions": ["history", "sessions", "tabs"]解释:
history:读取浏览历史,必须申请。sessions:只有实现“最近关闭标签页/窗口”时才申请。tabs:如果要稳定通过 API 打开历史项、打开chrome://history/或恢复后的交互,可以申请;如果只用普通链接打开网页,可以先不加。
bookmark 是一个极简、美观、可搜索的 Chrome 书签 Popup 启动器。
它不是完整书签管理器,第一版不做书签增删改,不做拖拽排序,不做复杂文件夹管理。它的重点是:快速搜索、快速打开、界面好看。
- 点击插件图标后弹出 Popup。
- 通过
chrome.bookmarks.getTree()读取完整书签树。 - 编写递归函数遍历所有书签节点。
- 将树结构转换为平铺数组。
- 每个书签保留
id、title、url、dateAdded、path。 - 顶部提供搜索框。
- 搜索范围包括标题、URL、文件夹路径。
- 列表项显示 favicon、标题、简化 URL、所属文件夹路径。
- 点击书签后打开新标签页。
- 支持暗黑模式。
- 搜索无结果或没有书签时显示空状态。
- 书签新增、删除、编辑。
- 拖拽排序。
- 完整文件夹管理。
- 多设备同步识别。
- AI 分类书签。
- 置顶书签持久化。
原因:这些功能会显著增加权限、状态管理和 UI 复杂度。第一版先把“查找 + 打开”做好。
{
"manifest_version": 3,
"name": "bookmark",
"version": "0.1.0",
"description": "A clean popup for quickly searching and opening Chrome bookmarks.",
"permissions": ["bookmarks", "favicon"],
"action": {
"default_title": "bookmark",
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>bookmark</title>
<link rel="stylesheet" href="popup.css" />
</head>
<body>
<main class="app">
<header class="header">
<div>
<h1>bookmark</h1>
<p id="count-text">加载中...</p>
</div>
</header>
<section class="search-wrap">
<input
id="search-input"
type="search"
placeholder="搜索书签标题、网址或文件夹..."
autocomplete="off"
/>
</section>
<section id="bookmark-list" class="bookmark-list"></section>
<section id="empty-state" class="empty-state" hidden>
<div class="empty-icon">☆</div>
<p>没有找到匹配的书签</p>
</section>
</main>
<script src="popup.js"></script>
</body>
</html>:root {
--bg: rgba(248, 250, 252, 0.92);
--panel: rgba(255, 255, 255, 0.76);
--text: #111827;
--muted: #6b7280;
--border: rgba(17, 24, 39, 0.08);
--hover: rgba(15, 23, 42, 0.06);
--shadow: 0 18px 50px rgba(15, 23, 42, 0.16);
--radius: 16px;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: rgba(15, 23, 42, 0.96);
--panel: rgba(30, 41, 59, 0.78);
--text: #f8fafc;
--muted: #94a3b8;
--border: rgba(255, 255, 255, 0.08);
--hover: rgba(255, 255, 255, 0.08);
--shadow: 0 18px 50px rgba(0, 0, 0, 0.35);
}
}
* {
box-sizing: border-box;
}
body {
width: 390px;
max-height: 560px;
margin: 0;
color: var(--text);
background: var(--bg);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.app {
min-height: 520px;
padding: 14px;
background: var(--bg);
backdrop-filter: blur(14px);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.header h1 {
margin: 0;
font-size: 20px;
line-height: 1.2;
letter-spacing: -0.02em;
}
.header p {
margin: 4px 0 0;
color: var(--muted);
font-size: 12px;
}
.search-wrap {
position: sticky;
top: 0;
z-index: 10;
padding-bottom: 10px;
background: var(--bg);
}
#search-input {
width: 100%;
height: 40px;
padding: 0 12px;
color: var(--text);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 999px;
outline: none;
}
#search-input:focus {
border-color: rgba(59, 130, 246, 0.55);
}
.bookmark-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 450px;
overflow-y: auto;
padding-right: 2px;
}
.bookmark-item {
display: flex;
align-items: center;
gap: 10px;
min-height: 46px;
padding: 8px 10px;
color: inherit;
text-decoration: none;
background: transparent;
border: 1px solid transparent;
border-radius: 14px;
cursor: pointer;
}
.bookmark-item:hover {
background: var(--hover);
border-color: var(--border);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
}
.bookmark-icon {
width: 20px;
height: 20px;
flex: 0 0 auto;
border-radius: 5px;
}
.bookmark-main {
min-width: 0;
flex: 1;
}
.bookmark-title,
.bookmark-url,
.bookmark-path {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bookmark-title {
font-size: 13px;
font-weight: 600;
}
.bookmark-url {
margin-top: 2px;
color: var(--muted);
font-size: 11px;
}
.bookmark-path {
margin-top: 2px;
color: var(--muted);
font-size: 10px;
opacity: 0.78;
}
.empty-state {
padding: 54px 16px;
color: var(--muted);
text-align: center;
}
.empty-icon {
margin-bottom: 8px;
font-size: 28px;
}const listEl = document.getElementById('bookmark-list');
const searchInput = document.getElementById('search-input');
const emptyState = document.getElementById('empty-state');
const countText = document.getElementById('count-text');
let allBookmarks = [];
async function init() {
try {
const tree = await chrome.bookmarks.getTree();
allBookmarks = flattenBookmarks(tree);
renderBookmarks(allBookmarks);
bindSearch();
} catch (error) {
console.error('Failed to load bookmarks:', error);
countText.textContent = '书签加载失败';
emptyState.hidden = false;
emptyState.querySelector('p').textContent = '无法读取书签,请检查扩展权限。';
}
}
function flattenBookmarks(nodes, path = []) {
const result = [];
for (const node of nodes) {
const title = node.title || '';
if (node.children) {
const nextPath = title ? [...path, title] : path;
result.push(...flattenBookmarks(node.children, nextPath));
continue;
}
if (node.url) {
result.push({
id: node.id,
title: title || '无标题',
url: node.url,
path: path.filter(Boolean).join(' / '),
dateAdded: node.dateAdded || 0
});
}
}
return result;
}
function renderBookmarks(bookmarks) {
listEl.textContent = '';
countText.textContent = `${bookmarks.length} / ${allBookmarks.length} 个书签`;
emptyState.hidden = bookmarks.length > 0;
const fragment = document.createDocumentFragment();
for (const bookmark of bookmarks) {
fragment.appendChild(createBookmarkItem(bookmark));
}
listEl.appendChild(fragment);
}
function createBookmarkItem(bookmark) {
const item = document.createElement('button');
item.type = 'button';
item.className = 'bookmark-item';
item.title = `${bookmark.title}\n${bookmark.url}`;
const icon = document.createElement('img');
icon.className = 'bookmark-icon';
icon.alt = '';
icon.loading = 'lazy';
icon.src = getFaviconUrl(bookmark.url);
icon.onerror = () => {
icon.src = createFallbackIcon(bookmark.title);
};
const main = document.createElement('span');
main.className = 'bookmark-main';
const title = document.createElement('span');
title.className = 'bookmark-title';
title.textContent = bookmark.title;
const url = document.createElement('span');
url.className = 'bookmark-url';
url.textContent = simplifyUrl(bookmark.url);
const path = document.createElement('span');
path.className = 'bookmark-path';
path.textContent = bookmark.path || '书签栏';
main.append(title, url, path);
item.append(icon, main);
item.addEventListener('click', async () => {
await chrome.tabs.create({ url: bookmark.url });
window.close();
});
return item;
}
function getFaviconUrl(url) {
return chrome.runtime.getURL(
'/_favicon/?pageUrl=' + encodeURIComponent(url) + '&size=32'
);
}
function createFallbackIcon(title) {
const letter = (title || '?').trim().slice(0, 1).toUpperCase() || '?';
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<rect width="32" height="32" rx="8" fill="#e5e7eb"/>
<text x="16" y="21" text-anchor="middle" font-size="15" font-family="Arial" fill="#111827">${letter}</text>
</svg>
`;
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
}
function simplifyUrl(url) {
try {
const parsed = new URL(url);
return parsed.hostname + parsed.pathname.replace(/\/$/, '');
} catch {
return url;
}
}
function bindSearch() {
let timer = null;
searchInput.addEventListener('input', () => {
clearTimeout(timer);
timer = setTimeout(() => {
const keyword = searchInput.value.trim().toLowerCase();
if (!keyword) {
renderBookmarks(allBookmarks);
return;
}
const filtered = allBookmarks.filter((bookmark) => {
const text = [bookmark.title, bookmark.url, bookmark.path]
.join(' ')
.toLowerCase();
return text.includes(keyword);
});
renderBookmarks(filtered);
}, 150);
});
}
init();- 在
chrome://extensions/中能加载bookmark/。 - 点击工具栏图标后能打开 Popup。
- 能显示书签总数和当前筛选数量。
- 能显示所有书签链接。
- 能显示书签所属文件夹路径。
- 搜索标题、URL、文件夹名都有效。
- 点击书签能打开新标签页。
- favicon 失败时不出现破图。
- 暗黑模式下可读。
- 无结果时有空状态。
History 是一个高颜值、可搜索的 Chrome 历史记录 Popup 插件。
它第一版应该先做好最近历史记录展示和关键词搜索。第二步再加入“最近关闭标签页/窗口”。
- 点击插件图标后弹出 Popup。
- 通过
chrome.history.search()读取最近历史记录。 - 默认显示最近 20 条历史记录。
- 顶部搜索框支持实时搜索。
- 搜索输入加入 150-250ms debounce。
- 列表项显示标题、URL、访问时间。
- 点击历史项打开新标签页。
- 支持暗黑模式。
- 无历史记录或无搜索结果时显示空状态。
- 发布前删除敏感
console.log。
- 加入
sessions权限。 - 使用
chrome.sessions.getRecentlyClosed()获取最近关闭标签页/窗口。 - 区分
session.tab和session.window.tabs。 - 使用
chrome.sessions.restore(sessionId)恢复关闭的标签页或窗口。 - 添加“查看全部记录”按钮。
- 尝试打开
chrome://history/,失败时提示用户使用Ctrl + H。
- 多设备同步标识。
- 大规模懒加载。
- 复杂插画。
- 历史记录删除和批量管理。
- AI 历史分类。
原因:history.search() 返回的数据不稳定提供“来自哪个设备”的字段;而复杂管理功能会让插件从“快速查看器”变成“历史管理器”,第一版没必要。
如果第一版只做历史搜索,使用基础版:
{
"manifest_version": 3,
"name": "History",
"version": "0.1.0",
"description": "A clean popup for searching recent Chrome history.",
"permissions": ["history"],
"action": {
"default_title": "History",
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}如果第一版直接包含“最近关闭”和“打开新标签”,使用增强版:
{
"manifest_version": 3,
"name": "History",
"version": "0.1.0",
"description": "A clean popup for searching recent Chrome history and recently closed tabs.",
"permissions": ["history", "sessions", "tabs"],
"action": {
"default_title": "History",
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}推荐实际开发顺序:先用基础版;等历史搜索稳定后,再改为增强版。
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>History</title>
<link rel="stylesheet" href="popup.css" />
</head>
<body>
<main class="app">
<header class="header">
<div>
<h1>History</h1>
<p id="count-text">加载中...</p>
</div>
</header>
<section class="search-wrap">
<input
id="search-input"
type="search"
placeholder="搜索历史记录..."
autocomplete="off"
/>
</section>
<section id="history-list" class="history-list"></section>
<section id="recent-section" class="recent-section" hidden>
<div class="section-title">最近关闭</div>
<section id="recent-list" class="recent-list"></section>
</section>
<footer class="footer">
<button id="open-full-history" type="button">查看全部记录</button>
</footer>
<section id="empty-state" class="empty-state" hidden>
<div class="empty-icon">⌕</div>
<p>没有找到匹配的历史记录</p>
</section>
</main>
<script src="popup.js"></script>
</body>
</html>:root {
--bg: rgba(255, 255, 255, 0.9);
--panel: rgba(255, 255, 255, 0.72);
--text: #111827;
--muted: #6b7280;
--border: rgba(0, 0, 0, 0.08);
--hover: rgba(0, 0, 0, 0.05);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: rgba(17, 24, 39, 0.94);
--panel: rgba(31, 41, 55, 0.78);
--text: #f9fafb;
--muted: #9ca3af;
--border: rgba(255, 255, 255, 0.1);
--hover: rgba(255, 255, 255, 0.08);
}
}
* {
box-sizing: border-box;
}
body {
width: 380px;
max-height: 550px;
margin: 0;
background: var(--bg);
color: var(--text);
backdrop-filter: blur(10px);
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.app {
min-height: 520px;
padding: 14px;
}
.header {
margin-bottom: 12px;
}
.header h1 {
margin: 0;
font-size: 20px;
}
.header p {
margin: 4px 0 0;
color: var(--muted);
font-size: 12px;
}
.search-wrap {
position: sticky;
top: 0;
z-index: 10;
padding-bottom: 10px;
background: var(--bg);
}
#search-input {
width: 100%;
height: 40px;
padding: 0 12px;
color: var(--text);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 999px;
outline: none;
}
.history-list,
.recent-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 360px;
overflow-y: auto;
}
.history-item,
.recent-item {
display: flex;
align-items: center;
gap: 10px;
min-height: 46px;
padding: 8px 10px;
color: inherit;
background: transparent;
border: 1px solid transparent;
border-radius: 14px;
cursor: pointer;
text-align: left;
}
.history-item:hover,
.recent-item:hover {
background: var(--hover);
border-color: var(--border);
}
.item-main {
min-width: 0;
flex: 1;
}
.item-title,
.item-url,
.item-meta {
display: block;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.item-title {
font-size: 13px;
font-weight: 600;
}
.item-url,
.item-meta {
margin-top: 2px;
color: var(--muted);
font-size: 11px;
}
.section-title {
margin: 12px 0 8px;
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.footer {
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid var(--border);
}
.footer button {
width: 100%;
height: 36px;
color: var(--text);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 999px;
cursor: pointer;
}
.empty-state {
padding: 48px 16px;
color: var(--muted);
text-align: center;
}const DEFAULT_LIMIT = 20;
const DEFAULT_RANGE_DAYS = 30;
const listEl = document.getElementById('history-list');
const recentSection = document.getElementById('recent-section');
const recentListEl = document.getElementById('recent-list');
const searchInput = document.getElementById('search-input');
const emptyState = document.getElementById('empty-state');
const countText = document.getElementById('count-text');
const openFullHistoryBtn = document.getElementById('open-full-history');
async function init() {
bindSearch();
bindFooter();
await loadHistory('');
await loadRecentlyClosedIfAvailable();
}
async function searchHistory(query = '') {
return chrome.history.search({
text: query,
startTime: Date.now() - 1000 * 60 * 60 * 24 * DEFAULT_RANGE_DAYS,
maxResults: DEFAULT_LIMIT
});
}
async function loadHistory(query) {
try {
const items = await searchHistory(query);
renderHistory(items);
} catch (error) {
console.error('Failed to load history:', error);
countText.textContent = '历史记录加载失败';
emptyState.hidden = false;
emptyState.querySelector('p').textContent = '无法读取历史记录,请检查扩展权限。';
}
}
function renderHistory(items) {
listEl.textContent = '';
countText.textContent = `${items.length} 条历史记录`;
emptyState.hidden = items.length > 0;
const fragment = document.createDocumentFragment();
for (const item of items) {
fragment.appendChild(createHistoryItem(item));
}
listEl.appendChild(fragment);
}
function createHistoryItem(item) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'history-item';
button.title = `${item.title || '无标题'}\n${item.url || ''}`;
const main = document.createElement('span');
main.className = 'item-main';
const title = document.createElement('span');
title.className = 'item-title';
title.textContent = item.title || '无标题';
const url = document.createElement('span');
url.className = 'item-url';
url.textContent = simplifyUrl(item.url || '');
const meta = document.createElement('span');
meta.className = 'item-meta';
meta.textContent = formatRelativeTime(item.lastVisitTime);
main.append(title, url, meta);
button.append(main);
button.addEventListener('click', async () => {
if (!item.url) return;
await chrome.tabs.create({ url: item.url });
window.close();
});
return button;
}
function bindSearch() {
let timer = null;
searchInput.addEventListener('input', () => {
clearTimeout(timer);
timer = setTimeout(() => {
loadHistory(searchInput.value.trim());
}, 200);
});
}
async function loadRecentlyClosedIfAvailable() {
if (!chrome.sessions?.getRecentlyClosed) return;
try {
const sessions = await chrome.sessions.getRecentlyClosed({ maxResults: 5 });
if (!sessions.length) return;
recentSection.hidden = false;
recentListEl.textContent = '';
const fragment = document.createDocumentFragment();
for (const session of sessions) {
fragment.appendChild(createRecentItem(session));
}
recentListEl.appendChild(fragment);
} catch (error) {
console.warn('Recently closed unavailable:', error);
}
}
function createRecentItem(session) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'recent-item';
const isTab = Boolean(session.tab);
const sessionId = isTab ? session.tab.sessionId : session.window?.sessionId;
const titleText = isTab
? (session.tab.title || '无标题')
: `窗口 - ${session.window?.tabs?.length || 0} 个标签页`;
const urlText = isTab ? (session.tab.url || '') : '点击恢复整个窗口';
const main = document.createElement('span');
main.className = 'item-main';
const title = document.createElement('span');
title.className = 'item-title';
title.textContent = titleText;
const url = document.createElement('span');
url.className = 'item-url';
url.textContent = simplifyUrl(urlText);
main.append(title, url);
button.append(main);
button.addEventListener('click', async () => {
if (!sessionId) return;
await chrome.sessions.restore(sessionId);
window.close();
});
return button;
}
function bindFooter() {
openFullHistoryBtn?.addEventListener('click', async () => {
try {
await chrome.tabs.create({ url: 'chrome://history/' });
window.close();
} catch {
alert('无法自动打开历史记录页面,请按 Ctrl + H 查看全部历史记录。');
}
});
}
function simplifyUrl(url) {
try {
const parsed = new URL(url);
return parsed.hostname + parsed.pathname.replace(/\/$/, '');
} catch {
return url;
}
}
function formatRelativeTime(timestamp) {
if (!timestamp) return '';
const diff = Date.now() - timestamp;
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
if (diff < minute) return '刚刚';
if (diff < hour) return `${Math.floor(diff / minute)} 分钟前`;
if (diff < day) return `${Math.floor(diff / hour)} 小时前`;
return `${Math.floor(diff / day)} 天前`;
}
init();- 在
chrome://extensions/中能加载history/。 - 点击工具栏图标后能打开 Popup。
- 默认显示最近 20 条历史记录。
- 输入关键词后约 200ms 刷新搜索结果。
- 标题、URL、访问时间能正确显示。
- 长标题和长 URL 不撑破布局。
- 点击历史项可打开对应网页。
- 如果启用
sessions,最近关闭区域能显示 tab / window,并能恢复。 -
chrome://history/无法打开时有 fallback 提示。 - 系统暗黑模式下样式可读。
- 发布前无隐私敏感
console.log。
两个插件虽然功能不同,但建议保持一致的视觉语言:
- Popup 宽度控制在 380px - 400px。
- 最大高度控制在 550px - 560px。
- 使用圆角搜索框。
- 列表项高度保持在 44px - 48px。
- 使用 Flexbox 布局。
- 标题、URL、路径都使用单行省略。
- 使用 CSS 变量管理颜色。
- 支持
prefers-color-scheme: dark。 - hover 使用轻微背景色变化,不要做过重动画。
- 空状态使用简洁文字即可,不建议塞复杂插画。
两个插件要分别加载。
- 打开 Chrome。
- 地址栏输入:
chrome://extensions/
- 开启右上角“开发者模式”。
- 点击“加载已解压的扩展程序”。
- 选择:
chrome_plugins/bookmark/
- 点击扩展图标测试 Popup。
同样进入 chrome://extensions/,点击“加载已解压的扩展程序”,选择:
chrome_plugins/history/
注意:不要选择 chrome_plugins/ 总目录。Chrome 加载扩展时必须选择包含 manifest.json 的具体插件目录。
| 错误 | 常见原因 | 解决方法 |
|---|---|---|
chrome.bookmarks is undefined |
没声明 bookmarks 权限,或选错目录 |
检查 bookmark/manifest.json,重新加载扩展。 |
chrome.history is undefined |
没声明 history 权限,或选错目录 |
检查 history/manifest.json,重新加载扩展。 |
chrome.sessions is undefined |
没声明 sessions 权限 |
只有需要最近关闭功能时才添加该权限。 |
| favicon 不显示 | 没声明 favicon 权限,或 URL 异常 |
加 favicon 权限,并保留默认图标 fallback。 |
| 点击不打开页面 | URL 为空、事件未绑定、权限策略差异 | 检查 Console,必要时加 tabs 权限。 |
| Popup 白屏 | JS 报错中断 | 右键 Popup 页面,点击“检查”,查看 Console。 |
| 修改代码后没生效 | 扩展没有重新加载 | 回到 chrome://extensions/,点击对应扩展的刷新按钮。 |
| 选择总目录无法加载 | 总目录没有 manifest.json |
分别选择 bookmark/ 或 history/。 |
两个插件发布前都需要检查:
-
manifest.json中的权限是否最小化。 - 图标尺寸是否包含 16、48、128。
- 没有发布版敏感
console.log。 - 无外部依赖或外部请求,除非明确说明。
- 支持浅色和暗黑模式。
- 空状态、失败状态、长文本状态都可用。
- README 说明插件用途和权限用途。
- 准备截图。
- 准备隐私说明。
- 版本号符合语义化版本习惯,例如
0.1.0、0.2.0、1.0.0。
chrome_plugins/
├─ bookmark/
└─ history/
原因:bookmark 只读书签树,数据比较稳定,风险小,适合作为第一个 MV3 练手插件。
开发顺序:
manifest.jsonpopup.htmlpopup.js读取书签并渲染- 搜索过滤
popup.css美化- favicon fallback
- 验收
原因:历史记录权限更敏感,且搜索、最近关闭、恢复会话涉及更多边界情况。
开发顺序:
manifest.json先只申请historypopup.htmlpopup.js读取最近 20 条历史记录- 搜索防抖
popup.css美化- 点击打开历史项
- 增加
sessions和最近关闭恢复 - 验收
最后再补:
- 总工程 README。
- 两个插件的截图。
- 权限说明。
- 使用说明。
- 后续版本计划。
请在当前目录创建一个名为 chrome_plugins 的工程。该工程包含两个独立的 Chrome Manifest V3 插件:bookmark 和 history。
目录结构要求:
chrome_plugins/bookmark/ 下包含 manifest.json、popup.html、popup.css、popup.js、icons/icon16.png、icons/icon48.png、icons/icon128.png。
chrome_plugins/history/ 下也包含 manifest.json、popup.html、popup.css、popup.js、icons/icon16.png、icons/icon48.png、icons/icon128.png。
bookmark 插件要求:插件名称为 bookmark,权限只申请 bookmarks 和 favicon。Popup 打开后调用 chrome.bookmarks.getTree() 获取完整书签树,递归遍历所有书签节点,转换为平铺数组,同时保留 title、url、id、dateAdded 和文件夹 path。UI 宽度约 390px,最大高度约 560px;顶部显示 bookmark 标题和结果数量;顶部固定搜索框;列表项左侧显示 favicon,中间显示标题、简化 URL 和文件夹路径;支持暗黑模式;搜索标题、URL 和路径,加入 100-200ms debounce;点击书签后用 chrome.tabs.create({ url }) 打开新标签并关闭 popup;favicon 使用 MV3 的 /_favicon/ 路径,失败时使用默认 SVG 首字母图标。
history 插件要求:插件名称为 History。第一版先申请 history 权限,实现最近 20 条历史记录展示和搜索。Popup 顶部显示 History 标题、搜索框和结果数量;列表项显示标题、简化 URL 和相对访问时间;搜索调用 chrome.history.search(),加入约 200ms debounce;点击历史项用 chrome.tabs.create({ url }) 打开新标签并关闭 popup。第二阶段可加入 sessions 和 tabs 权限,实现 chrome.sessions.getRecentlyClosed({ maxResults: 5 }),区分 tab 和 window,并用 chrome.sessions.restore(sessionId) 恢复最近关闭会话。添加“查看全部记录”按钮,尝试打开 chrome://history/,失败时提示用户按 Ctrl + H。
两个插件都要使用 Manifest V3、无外部依赖、支持暗黑模式、长文本省略、空状态提示、错误处理,并避免申请多余权限。请输出完整可运行代码。
- Chrome Extensions Manifest V3 文档
- Chrome
bookmarksAPI - Chrome
historyAPI - Chrome
sessionsAPI - Chrome
tabsAPI - Chrome Extensions favicon 机制