Chrome 扩展开发实战教程:从零构建书签同步插件 MiniMark

Cosolar 7 阅读 前端

Chrome 扩展开发实战教程:从零构建书签同步插件 MiniMark

基于 Manifest V3,以真实开源项目 MiniMark 为案例,逐行拆解一款支持 GitHub / Gitee 双平台 Gist 同步的书签管理扩展。本教程不是 API 罗列,而是跟着真实代码走一遍完整开发流程。

开源项目: 极简书签同步助手

项目图.png

为什么需要书签同步

Chrome 内置的书签功能把数据锁在本地。换电脑、重装系统、切换浏览器——书签就没了。Chrome 自带的账号同步依赖 Google 账号,在国内网络环境下常常不可用,而且数据完全存在 Google 服务器,用户无法自主控制、无法版本回溯、无法跨浏览器使用。

把书签导出为 HTML 再手动上传是个办法,但操作繁琐且无法自动化。社区里的书签同步扩展要么绑定单一服务,要么代码不透明让人担心 Token 安全。

MiniMark 解决这些问题的方式是:读取 Chrome 全量书签树,序列化为 JSON,推送到用户自己的 GitHub Gist 或 Gitee 代码片段中;同时能从远端拉取数据恢复到本地。整个过程中,用户的 Personal Access Token 只存在扩展的本地存储中,不经过任何第三方服务器。

MiniMark 的最终能力

这是一款已经实现的、可运行的扩展,具备以下能力:

  • 一键将 Chrome 全量书签同步到 GitHub Gist 或 Gitee 代码片段
  • 从远端恢复书签到本地(覆盖替换或合并去重两种模式)
  • 自动定时同步,智能比较两端数量选择同步方向
  • 同步状态对比分析,本地独有、远端独有、两端共有一目了然
  • 书签变动实时角标提醒
  • 近 7 日同步趋势图与完整历史记录
  • 自动清理同名旧片段,仅保留最新一份
  • 中英文双语界面

本教程将带你走进它的每一行代码。

Manifest V3 基础

在动手写书签同步之前,需要先搞清楚 Chrome 扩展到底是什么、怎么运行的。MiniMark 基于 Manifest V3 开发,这是 Chrome 扩展平台的最新架构规范。理解它的核心概念,是看懂后面所有代码的前提。

Chrome 扩展是什么

Chrome 扩展本质上是一组网页技术文件(HTML、CSS、JavaScript)加上一个配置清单。它运行在浏览器内部,可以调用普通网页无法访问的特殊 API(比如读写书签、管理标签页、监听网络请求),从而增强浏览器的功能。

一个扩展至少需要两部分:一个 manifest.json 配置文件告诉浏览器"我是谁、我需要什么权限、我的入口在哪",以及若干功能文件(界面 HTML、逻辑 JavaScript、样式 CSS、图标等)。浏览器读取 manifest 后,按照其中的声明加载对应的文件,扩展就跑起来了。

什么是 Manifest V3

Manifest V3 是 Chrome 扩展平台的第三代架构规范,于 2021 年正式发布,到 2024 年已全面取代 Manifest V2 $TRAE_REF。你可以在 manifest.json 中通过 manifest_version: 3 声明使用这套规范。

Chrome 推动 V3 的目标是加强扩展的隐私保护、安全性和性能。相比 V2,它带来了三个根本性变化,每一个都直接影响你写代码的方式。

核心变化一:后台页面变成 Service Worker

这是 V3 最重要、也最容易让新手困惑的变化。

在 V2 时代,扩展有一个"后台页面"(background page)。它是一个常驻的 HTML 页面,从浏览器启动到关闭一直运行着,扩展的所有后台逻辑都跑在这里。问题是:即使扩展什么都不做,这个页面也占着内存,一装几十个扩展,内存消耗就很可观。

V3 用 Service Worker 取代了后台页面。Service Worker 是一种事件驱动的脚本,它的核心特点是按需启动、空闲即停

  • 当有事件发生时(比如书签被新增、定时器触发、扩展被安装),Chrome 会唤醒 Service Worker 执行代码。
  • 事件处理完毕后,如果没有新的事件,Chrome 会在几秒到几十秒后终止 Service Worker,释放内存。
  • 下次再有事件时,Chrome 重新启动它。

这意味着你不能在 Service Worker 的全局变量中保存状态。一个新手常犯的错误是这样写:

// ❌ 错误示范:V2 时代可以这样写,V3 中行不通
let syncCount = 0;

chrome.bookmarks.onCreated.addListener(() => {
  syncCount++;  // Service Worker 被终止后,这个变量就没了
  console.log('累计变动次数:', syncCount);
});

Service Worker 被终止后重启,syncCount 会重新变成 0,之前累加的数据全丢了。正确的做法是用 chrome.storage 持久化:

// ✅ 正确写法:用 chrome.storage 保存状态
chrome.bookmarks.onCreated.addListener(async () => {
  const { syncCount = 0 } = await chrome.storage.local.get(['syncCount']);
  await chrome.storage.local.set({ syncCount: syncCount + 1 });
});

MiniMark 的 markUnsynced 函数就是这么做的——它把"是否有未同步的修改"这个状态存在 chrome.storage.localhasUnsynced 字段里,而不是用全局变量,这样即使 Service Worker 被终止重启,状态也不会丢失。

Service Worker 还有一个限制:没有 DOM。它不是网页,不能操作 document、不能创建 DOM 元素。所有界面相关的操作都必须放在 Popup 或其他扩展页面中。这就是为什么 MiniMark 把界面逻辑放在 popup.js,把业务逻辑放在 background.js——两者各司其职,通过消息通信协作。

manifest.json 中声明 Service Worker:

{
  "background": {
    "service_worker": "background.js"
  }
}

MiniMark 没有声明 "type": "module",所以 background.js 不能用 import/export,所有代码写在一个文件的作用域内。

核心变化二:禁止远程代码

V2 允许扩展从远程服务器加载并执行 JavaScript 代码(比如从 CDN 加载一个库)。这意味着扩展的实际行为可能随时改变,Chrome 应用商店审核时看到的代码和用户实际运行的代码可能不一致,存在安全风险。

V3 严格禁止远程代码:扩展只能执行打包在扩展内的本地 JavaScript 文件。所有代码必须随扩展一起提交审核,审核通过后不能动态替换。

这个变化解释了 MiniMark 的一个设计选择——它没有使用任何第三方库(如 jQuery、React、图标库),全部用原生 JavaScript 和内联 SVG 实现。这不是开发者不懂得用库,而是 V3 环境下最简洁安全的选择:无需引入远程 CDN,无需打包构建,代码即审核即运行。

核心变化三:声明式网络请求

V2 的 webRequest API 允许扩展拦截和修改网络请求,但这要求所有网络流量都经过扩展处理,影响性能。V3 用 declarativeNetRequest 取代了它——扩展预先声明一组规则,浏览器根据规则处理请求,无需扩展介入每个请求。

MiniMark 不涉及网络请求拦截(它只是用 fetch 主动请求 GitHub/Gitee API),所以这个变化对它没有影响。但如果你开发广告拦截类的扩展,这个变化是核心。

manifest.json:扩展的身份证

manifest.json 是每个扩展的唯一入口,浏览器通过它认识你的扩展。MiniMark 的完整配置如下:

{
  "manifest_version": 3,
  "name": "__MSG_extName__",
  "version": "2026.7.30",
  "default_locale": "en",
  "description": "__MSG_extDesc__",
  "permissions": [
    "bookmarks",
    "storage",
    "alarms"
  ],
  "host_permissions": [
    "https://api.github.com/*",
    "https://gitee.com/*"
  ],
  "action": {
    "default_popup": "popup.html",
    "default_icon": {
      "16": "icons/icon16.png",
      "48": "icons/icon48.png",
      "128": "icons/icon128.png"
    }
  },
  "background": {
    "service_worker": "background.js"
  },
  "icons": {
    "16": "icons/icon16.png",
    "48": "icons/icon48.png",
    "128": "icons/icon128.png"
  }
}

逐个字段拆解:

字段 作用 新手要点
manifest_version 声明架构版本 固定填 3,表示使用 Manifest V3
name 扩展名称 __MSG_xxx__ 是多语言占位符,Chrome 会从 _locales 目录读取对应翻译
version 版本号 每次发布新版本时递增,Chrome 据此判断是否需要更新
default_locale 默认语言 配合 __MSG_xxx__ 使用,浏览器语言没有对应翻译时回退到此语言
description 扩展描述 同样支持多语言占位符
permissions 扩展权限声明 声明需要调用的 Chrome API,用户安装时会看到这些权限提示
host_permissions 跨域请求权限 声明扩展可以向哪些域名发起网络请求
action 工具栏图标配置 定义点击扩展图标时弹出的 Popup 页面和图标资源
background Service Worker 配置 指定后台脚本文件
icons 扩展图标 不同尺寸用于不同场景(标签页、扩展管理页、安装提示)

权限系统:permissions 与 host_permissions

权限是 Manifest V3 安全模型的核心。扩展不能偷偷访问用户的书签或发送网络请求,必须在 manifest 中事先声明,用户安装时会看到完整的权限清单。

Chrome 把权限分成两类:

API 权限(permissions 控制扩展能调用哪些 Chrome 扩展 API。MiniMark 声明了三个:

  • bookmarks:读写浏览器书签,这是书签同步扩展的核心权限。
  • storage:使用 chrome.storage.local 持久化数据,用来存 Token、Gist ID、同步历史等。
  • alarms:使用 chrome.alarms 创建定时任务,实现自动同步。

主机权限(host_permissions 控制扩展能向哪些网址发起跨域请求。普通网页受同源策略限制,不能请求其他域名的接口;扩展通过声明 host_permissions 可以突破这个限制。MiniMark 声明了两个:

  • https://api.github.com/*:访问 GitHub Gist API。
  • https://gitee.com/*:访问 Gitee Gist API。

注意 MiniMark 没有使用 <all_urls>(匹配所有网址)。精确声明具体域名有两个好处:用户安装时能清楚看到"这个扩展只访问 GitHub 和 Gitee",审核风险更低;同时扩展本身也不会有意外的网络请求。

action 与 Popup:扩展的交互入口

action 字段配置浏览器工具栏上的扩展图标。用户点击图标时,Chrome 会弹出 default_popup 指定的 HTML 页面,这就是 Popup。

Popup 是一个普通的网页,有完整的 DOM 环境,可以自由操作 HTML 元素。但它有一个重要特性:Popup 关闭即销毁。用户点击图标打开 Popup,再点击其他地方 Popup 就消失了,里面的 JavaScript 变量和 DOM 状态全部清空,下次打开是一个全新的实例。

这个特性决定了 Popup 不能承载长期运行的任务。比如同步书签需要几秒钟的网络请求,如果同步过程中用户关闭了 Popup,同步就被中断了。MiniMark 的解决方案是:Popup 只负责展示和接收用户操作,真正的同步逻辑放在 Service Worker 中执行。Popup 通过消息(chrome.runtime.sendMessage)告诉 Service Worker"请执行同步",Service Worker 在后台跑完后再把结果写回 chrome.storage,Popup 下次打开时读取存储刷新界面。

这就是 MiniMark 分层架构的根源:Popup 是"嘴和脸"(展示和交互),Service Worker 是"手和脚"(执行和持久化),两者通过消息通信协作。

多语言机制

namedescription 中的 __MSG_extName____MSG_extDesc__ 是多语言占位符。Chrome 会根据浏览器的界面语言,从 _locales/{语言代码}/messages.json 中读取对应的翻译。

MiniMark 内置了两种语言。英文版 _locales/en/messages.json

{
  "extName": {
    "message": "MiniMark",
    "description": "Extension name"
  },
  "extDesc": {
    "message": "Sync bookmarks to GitHub Gist or Gitee for cross-device bookmark management",
    "description": "Extension description"
  }
}

中文版 _locales/zh_CN/messages.json

{
  "extName": {
    "message": "MiniMark",
    "description": "扩展名"
  },
  "extDesc": {
    "message": "同步书签到 GitHub Gist 或 Gitee,跨设备管理您的书签",
    "description": "扩展描述"
  }
}

default_locale 设为 en,表示浏览器语言既不是中文也不是英文时,回退到英文。如果你的浏览器是中文界面,Chrome 会读取 zh_CN 目录的翻译,扩展名称和描述就显示中文。

定时任务:从 setInterval 到 chrome.alarms

书签同步扩展需要定时执行后台任务——每隔一段时间自动同步一次。在 V2 时代,后台页面常驻运行,直接用 setInterval 就能搞定。但到了 V3,这套方法行不通了。

原因还是 Service Worker 的按需启停。setIntervalsetTimeout 依赖于一个持续运行的 JavaScript 环境,而 Service Worker 在空闲时会被 Chrome 终止。一旦终止,所有通过 setInterval 注册的定时器就随之消失,永远不会触发。即使 Service Worker 后来被其他事件重新唤醒,定时器也不会自动恢复——你注册的回调已经不在了。

V3 提供的替代方案是 chrome.alarms API。它的底层由浏览器管理,不依赖 Service Worker 是否存活:你创建一个 alarm,到时间后 Chrome 会主动唤醒 Service Worker 并触发 onAlarm 事件。基本用法两步:

// 第一步:创建定时任务(最小间隔 1 分钟)
chrome.alarms.create('autoSync', { periodInMinutes: 30 });

// 第二步:监听触发
chrome.alarms.onAlarm.addListener((alarm) => {
  if (alarm.name === 'autoSync') {
    performSync();  // Service Worker 此时已被唤醒,可以执行同步
  }
});

这里有一个新手容易忽略的限制:periodInMinutes 最小值为 1 分钟。在开发者模式下你可以设置更小的值用于调试,但扩展发布到商店后,Chrome 会把小于 1 分钟的间隔强制对齐到 1 分钟。这是为了防止扩展过于频繁地唤醒 Service Worker,白白消耗用户电量和系统资源。

对比一下两种方式的差异:

对比项 setInterval chrome.alarms
运行依赖 需要持续运行的 JS 环境 由浏览器底层管理,不依赖 SW 存活
Service Worker 终止后 定时器消失,不再触发 到时间仍会唤醒 SW 并触发
最小间隔 无限制(可低至几毫秒) 1 分钟(发布后强制对齐)
精度 高(毫秒级) 低(分钟级,可能有几十秒偏差)
适用场景 Popup 内的界面动画、倒计时 后台定时任务、周期性同步

MiniMark 的自动同步正是基于 chrome.alarms。用户在 Popup 打开定时同步开关后,Popup 发消息给 Service Worker,Service Worker 调用 chrome.alarms.create 注册定时任务;每次 alarm 触发,Service Worker 被唤醒执行智能同步,完成后又回到休眠状态。整个过程不需要 Service Worker 一直运行,内存占用极低。具体的同步决策逻辑——如何比较本地与远端数量、选择同步方向——我们会在后面的"智能自动同步"章节详细拆解。

V3 对 MiniMark 的影响

回顾这一节,Manifest V3 的三个核心变化直接塑造了 MiniMark 的代码结构:

Service Worker 的按需启停特性,迫使 MiniMark 把所有状态(Token、Gist ID、同步历史、配置)都存在 chrome.storage.local 中,而不是用全局变量。每次需要数据时都从存储读取,同步完成后写回存储。这是 V3 扩展的基本编程范式。

Service Worker 没有 DOM 的限制,迫使 MiniMark 把界面逻辑(Popup)和业务逻辑(Service Worker)彻底分离,通过消息通信连接。这反而带来了更好的架构——代码职责清晰,Popup 关闭不影响后台同步。

禁止远程代码的限制,让 MiniMark 选择了零依赖的纯原生 JavaScript 实现。没有构建步骤,没有打包工具,改完代码刷新扩展即可生效,开发和调试都极其简单。

理解了这些基础概念,接下来就可以深入具体 API 了。

Chrome Bookmarks API 核心

要操作浏览器书签,扩展需要先在 manifest.json 中声明 bookmarks 权限。这个权限告诉浏览器"我的扩展需要读写书签",用户安装时会看到这条权限提示。声明方式很简单,一行即可:

{
  "permissions": ["bookmarks"]
}

有了权限之后,扩展就可以通过 chrome.bookmarks 命名空间访问书签数据了。这个 API 是 Chrome 提供的官方接口,所有方法都返回 Promise(也支持回调函数,但 Promise 写法更现代、更易读,MiniMark 全程使用 async/await)$TRAE_REF

在深入具体方法之前,需要先理解一个核心概念:Chrome 的书签不是一张扁平的列表,而是一棵树。

为什么是树形结构

打开浏览器的书签栏,你会看到书签和文件夹混在一起。文件夹里面可以放书签,也可以嵌套子文件夹,子文件夹里又能继续放书签——这和电脑上的文件系统是一模一样的组织方式。Chrome 内部用树形数据结构来表示这种层级关系。

树的基本单位是节点(node)。每个节点要么是一个书签(指向某个网页 URL),要么是一个文件夹(包含子节点)。整棵书签树只有一个根,从根往下逐层展开。理解这一点非常重要,因为后面所有的读取、写入、遍历操作,本质都是在操作这棵树。

BookmarkTreeNode 详解

树中的每个节点都是一个 BookmarkTreeNode 对象。把它想象成一张信息卡片,上面记录了这个书签或文件夹的全部信息。核心属性如下:

属性 类型 说明
id string 节点的唯一身份证号,由 Chrome 自动分配,同一浏览器配置(Profile)内不会重复,重启浏览器后仍然有效
parentId string 父节点的 id,用来定位"我挂在谁的下面"。根节点没有这个属性
index number 在同级兄弟中的排列位置,从 0 开始计数,改变它就能调整书签的显示顺序
title string 显示名称。书签的标题就是你在书签栏看到的名字,文件夹同理
url string 书签指向的网址。这是区分书签和文件夹的关键——文件夹没有 url,只有 children
dateAdded number 创建时间,格式是毫秒时间戳(如 1722300000000),可以用 new Date(dateAdded) 转为可读日期
dateGroupModified number 文件夹专属属性,记录文件夹内部内容最后一次变动的时间
children array 子节点数组,只有文件夹才有这个属性。书签(叶子节点)没有 children

新手最容易混淆的一个点:书签和文件夹在数据结构上是同一种对象(BookmarkTreeNode),区别仅在于有没有 urlchildren。有 url 的是书签,有 children 的是文件夹。一个节点不可能同时有 urlchildren——它要么是一个网页链接,要么是一个装东西的容器。

另一个容易忽略的细节是 id。它是字符串类型(不是数字),格式类似 "1""137"。这个 id 是 Chrome 在本地分配的,不同设备、不同浏览器配置之间不一致,所以同步书签时不能依赖 id 来匹配——这也是为什么 MiniMark 在对比本地与远端书签时用 URL 而非 id 做去重。

书签树的层级结构

整棵书签树以根节点为起点,它的 id 固定为 "0"。根节点是虚拟的,用户看不到它,也不能在里面直接放书签。根节点下面挂着几个特殊的子文件夹,它们就是你在浏览器中实际看到的书签区域:

根节点 (id: "0")  ← 用户不可见的虚拟根
├── 书签栏          ← 显示在浏览器顶部工具栏下方的书签条
│   ├── 文件夹A
│   │   ├── 书签1(叶子节点,有 url)
│   │   └── 书签2
│   └── 书签3
├── 其他书签        ← 不显示在工具栏,但在书签管理器中可见
│   └── 书签4
└── 移动设备书签    ← 桌面版通常为空,给手机端 Chrome 用的

书签栏是最常用的区域,用户添加书签时默认就放在这里。其他书签是一个"隐藏"的存储区,用户可以通过书签管理器访问。移动设备书签在桌面版浏览器中通常是空的,它存在的意义是当你用手机 Chrome 登录同一账号时,手机上的书签会出现在这里。

MiniMark 在恢复书签时需要处理一个跨语言的细节:中文版 Chrome 里书签栏的标题是"书签栏",英文版则是"Bookmarks bar"。恢复时不能假设标题固定,而要先按标题匹配、匹配不到再按位置回退。这个逻辑我们在后面的恢复章节会详细讲。

读取操作

chrome.bookmarks 提供了多种读取方法,适用于不同场景。MiniMark 主要使用 getTree() 获取全量数据,但了解其他方法有助于理解 API 的全貌。

获取整棵树

最常用的读取方式是 getTree(),它返回完整的书签树。MiniMark 在 background.js 中用它获取全量书签:

const bookmarks = await chrome.bookmarks.getTree();
const bookmarkCount = countBookmarks(bookmarks[0]);

这里有一个新手容易踩的坑:getTree() 返回的是一个数组,但里面实际只有一个元素(根节点)。这是 Chrome API 的设计约定——返回数组是为了接口统一,方便未来扩展,但目前你只需要取 [0] 就能拿到根节点。

MiniMark 的 countBookmarks 函数递归遍历这棵树,统计有多少个书签(即有 url 属性的节点):

function countBookmarks(node) {
  let count = 0;
  if (node.url) {
    count = 1;  // 这个节点是书签,计数 +1
  }
  if (node.children) {
    // 这个节点是文件夹,继续往下找
    for (const child of node.children) {
      count += countBookmarks(child);
    }
  }
  return count;
}

这段代码的思路是:遇到书签就加 1,遇到文件夹就钻进去继续数。递归是处理树形数据最自然的方式。这个函数在 MiniMark 中被反复使用:上传同步时统计本地书签数、智能同步时比较本地与远端数量、Popup 界面显示本地书签数。

获取子节点

如果只需要某个文件夹下的直接子项(不递归),用 getChildren()

// 获取书签栏下的所有直接子项(不包含子文件夹里的内容)
const children = await chrome.bookmarks.getChildren('1');
// children 是一个数组,每个元素是一个 BookmarkTreeNode

MiniMark 在合并书签时用到这个方法——它需要知道本地某个文件夹下已经有哪些书签,才能判断远端的书签是否需要新增:

let existing = [];
try { existing = await chrome.bookmarks.getChildren(parentId); } catch (e) { return; }

按 ID 获取节点

已经知道节点 id,想获取它的详细信息,用 get()

// 获取单个节点
const [node] = await chrome.bookmarks.get('123');

// 同时获取多个节点
const nodes = await chrome.bookmarks.get(['123', '456']);

get() 也返回数组,和 getTree() 一样的设计哲学。

搜索书签

search() 可以按关键词或条件查找书签,适合做书签搜索功能:

// 字符串搜索:在标题和 URL 中匹配关键词
const results = await chrome.bookmarks.search('github');

// 对象搜索:精确匹配(所有条件同时满足)
const exact = await chrome.bookmarks.search({
  title: 'GitHub',
  url: 'https://github.com'
});

字符串搜索是模糊匹配(分词后任意命中即可),对象搜索是精确匹配(完全相等)。MiniMark 没有使用 search(),因为它的同步逻辑需要全量数据而非搜索结果,但这个方法在做书签管理器时很有用。

写入操作

chrome.bookmarks 提供了五个写操作:create(创建)、update(更新)、move(移动)、remove(删除单个)、removeTree(递归删除)。MiniMark 在恢复书签时大量使用 createremoveTree

创建书签或文件夹

create() 接收一个对象参数,指定要创建的节点信息。关键在于 url 字段的有无决定了创建的是书签还是文件夹:

// 创建书签(传入 url)
await chrome.bookmarks.create({
  parentId: parentId,    // 放在哪个文件夹下
  title: child.title,    // 显示名称
  url: child.url         // 有 url → 书签
});

// 创建文件夹(不传 url)
const folder = await chrome.bookmarks.create({
  parentId: parentId,
  title: child.title     // 没有 url → 文件夹
});

create() 返回创建后的节点对象(包含 Chrome 分配的 id),MiniMark 在递归恢复时需要用到这个返回值——创建文件夹后拿到它的 id,才能继续往这个文件夹里塞子书签:

const folder = await chrome.bookmarks.create({
  parentId: parentId,
  title: child.title
});
if (folder && folder.id) {
  await restoreBookmarkTree(child, folder.id);  // 用新文件夹的 id 继续递归
}

如果不指定 parentId,书签会默认放到"其他书签"文件夹下。index 字段可以控制插入位置,不指定则追加到末尾。

更新书签

update() 只能修改 titleurl,不能改位置(改位置用 move()):

// 修改标题
await chrome.bookmarks.update('123', { title: '新标题' });

// 修改 URL
await chrome.bookmarks.update('123', { url: 'https://new-url.com' });

MiniMark 没有使用 update(),因为它的同步策略是整体替换而非逐条修改——恢复时先删后建,而非更新已有项。

移动书签

move() 可以改变书签的所属文件夹或排列顺序:

// 移到另一个文件夹
await chrome.bookmarks.move('123', { parentId: '456' });

// 调整顺序(移到当前文件夹的第一个位置)
await chrome.bookmarks.move('123', { index: 0 });

删除操作

删除有两个方法,新手一定要区分清楚:

  • remove(id):只能删除单个书签或空文件夹。如果文件夹里有内容,调用会报错。
  • removeTree(id):递归删除文件夹及其所有子内容,不管文件夹是否为空。

这个区别非常重要。MiniMark 在清空本地书签时用到了这个特性:

// 清空本地所有书签(保留"书签栏""其他书签"等根文件夹骨架)
async function clearLocalBookmarks() {
  const tree = await chrome.bookmarks.getTree();
  const root = tree[0];
  if (!root || !root.children) return;
  for (const folder of root.children) {
    if (!folder.children) continue;
    for (const child of folder.children) {
      try {
        if (child.children) {
          // 有 children → 是文件夹 → 用 removeTree 递归删除
          await chrome.bookmarks.removeTree(child.id);
        } else {
          // 没有 children → 是书签 → 用 remove 直接删除
          await chrome.bookmarks.remove(child.id);
        }
      } catch (e) {
        console.warn('删除书签失败:', e);
      }
    }
  }
}

注意这段代码的遍历层次:它遍历的是 root.children(书签栏、其他书签等顶层文件夹),删除的是每个文件夹下的 child。这样保留了"书签栏""其他书签"这些根文件夹骨架本身,只清空了里面的内容。如果直接对顶层文件夹用 removeTree,会把书签栏本身都删掉,导致浏览器书签功能异常。

每个删除操作都包在 try-catch 中,单个删除失败不会中断整个清空流程——这是防御性编程,避免某条书签因权限或格式问题导致整体卡住。

事件监听

前面讲的读取和写入都是"主动操作"——你的代码明确地去读或写书签。但很多场景下,扩展需要被动地感知书签变化:用户在书签栏新增了一个书签、删掉了一个文件夹、把书签拖到了另一个位置。Chrome 提供了事件监听机制来处理这些场景。

事件监听的工作原理是:你用 addListener 注册一个回调函数,当对应的书签操作发生时,Chrome 会自动调用这个函数,并把相关信息作为参数传进来。你的扩展不需要轮询检查书签是否变化,Chrome 会在变化发生的瞬间通知你。

MiniMark 监听了四个事件来实现"实时未同步提醒":

chrome.bookmarks.onCreated.addListener(() => { markUnsynced().catch(e=>console.warn(e)); });
chrome.bookmarks.onRemoved.addListener(() => { markUnsynced().catch(e=>console.warn(e)); });
chrome.bookmarks.onChanged.addListener(() => { markUnsynced().catch(e=>console.warn(e)); });
chrome.bookmarks.onMoved.addListener(() => { markUnsynced().catch(e=>console.warn(e)); });

这四个事件分别对应书签的四种操作:

  • onCreated:用户或程序新建了书签或文件夹。回调会收到新建节点的 id 和完整节点对象。
  • onRemoved:书签或文件夹被删除。回调会收到被删除节点的 id 和一个 removeInfo 对象,里面记录了被删节点的父节点 id、原位置和完整快照。
  • onChanged:书签的标题或 URL 被修改。回调会收到节点 id 和一个 changeInfo 对象,包含修改后的 titleurl
  • onMoved:书签被移动到另一个文件夹,或在同一文件夹内改变了排列顺序。回调会收到节点 id 和一个 moveInfo 对象,包含新旧父节点 id 和新旧位置索引。

MiniMark 的监听回调没有使用这些参数,因为它不需要知道具体哪个书签变了、怎么变的——只需要知道"有变化发生",然后标记为未同步即可。无论是新增、删除、修改还是移动,都会触发同一个 markUnsynced 函数。这个函数会比较当前书签数量与上次同步时记录的数量,判断是否有变化,然后在扩展图标上显示红色角标提醒用户"有未同步的修改"。我们会在后面的章节详细拆解它。

有一个性能相关的细节值得新手注意:当用户通过浏览器"导入书签"功能批量导入大量书签时,会连续触发大量 onCreated 事件。如果你的监听回调逻辑很重(比如每次都同步到远端),会导致性能问题。Chrome 提供了 onImportBeganonImportEnded 两个事件来标记导入的开始和结束,性能敏感的扩展应该在导入期间暂停处理 onCreated,等导入结束后再批量处理。MiniMark 的 markUnsynced 逻辑很轻量(只比较数量),所以没有做这个优化。

MiniMark 项目架构

整体架构

MiniMark 采用经典的 Manifest V3 分层架构,将书签操作、远程同步、用户界面三者解耦:

┌──────────────────────────────────────────────────────────┐
│                        用户界面层                          │
│              popup.html / popup.js / popup.css            │
│    状态卡 · 统计卡 · 快速操作 · 趋势图 · 历史记录 · 设置     │
└────────────────────────┬─────────────────────────────────┘
                         │ chrome.runtime.sendMessage
                         ▼
┌──────────────────────────────────────────────────────────┐
│                    Service Worker                         │
│                      background.js                        │
│                                                          │
│  · Token 验证        · Gist 增删改查                      │
│  · 书签树序列化      · 书签树恢复                          │
│  · 智能同步决策      · 状态对比分析                        │
│  · 定时任务调度      · 旧片段清理                          │
│  · 角标状态管理      · 书签变动监听                        │
│                                                          │
│  ┌─────────────────────────────────────────────────────┐ │
│  │              chrome.storage.local                    │ │
│  │  Token · Gist ID · 同步历史 · 配置 · 同步状态         │ │
│  └─────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
                         │ fetch
                         ▼
┌──────────────────────────────────────────────────────────┐
│              GitHub Gist API  /  Gitee Gist API           │
│            api.github.com/gists   gitee.com/api/v5/gists   │
└──────────────────────────────────────────────────────────┘

Service Worker 作为核心层,承载所有业务逻辑。Popup 只负责展示和交互,通过消息向 Service Worker 发送指令。这样做的原因是 Popup 关闭即销毁,无法承载长期任务,而 Service Worker 可以被 alarms 唤醒执行定时同步。

项目目录结构

MiniMark 的实际目录非常精简,没有模块化拆分,所有后台逻辑集中在 ../background.js 一个文件中:

MiniMark/
├── manifest.json          # 扩展清单(Manifest V3)
├── background.js          # Service Worker,全部后台逻辑
├── popup.html             # 弹窗界面结构
├── popup.css              # 弹窗样式
├── popup.js               # 弹窗交互逻辑
├── _locales/              # 多语言资源
│   ├── en/messages.json
│   └── zh_CN/messages.json
└── icons/                 # 图标资源
    ├── icon16.png
    ├── icon48.png
    ├── icon128.png
    ├── logo.ico
    ├── github.svg
    └── gitee.svg

这种"单文件 Service Worker"的设计适合中小型扩展。好处是无需处理 ES Module 导入导出,加载调试简单;代价是文件较长,需要良好的注释分区。MiniMark 用 // ===================== 分隔块来组织代码。

manifest.json 配置解析

{
  "manifest_version": 3,
  "name": "__MSG_extName__",
  "version": "2026.7.30",
  "default_locale": "en",
  "description": "__MSG_extDesc__",
  "permissions": [
    "bookmarks",
    "storage",
    "alarms"
  ],
  "host_permissions": [
    "https://api.github.com/*",
    "https://gitee.com/*"
  ],
  "action": {
    "default_popup": "popup.html",
    "default_icon": {
      "16": "icons/icon16.png",
      "48": "icons/icon48.png",
      "128": "icons/icon128.png"
    }
  },
  "background": {
    "service_worker": "background.js"
  },
  "icons": {
    "16": "icons/icon16.png",
    "48": "icons/icon48.png",
    "128": "icons/icon128.png"
  }
}

几个关键设计决策值得注意:

namedescription 使用 __MSG_xxx__ 占位符,配合 default_locale 实现多语言。Chrome 会根据浏览器语言从 _locales 目录读取对应翻译。MiniMark 内置 enzh_CN 两套文案。

权限声明极简:bookmarks 是核心,storage 用于持久化 Token 和配置,alarms 用于定时同步。没有申请 notificationsidentity 等额外权限,减少审核风险和用户顾虑。

host_permissions 精确声明了两个 API 域名,而非用 <all_urls>。这样用户安装时能清楚看到扩展只访问这两个域名,审核风险也低。注意 Gitee 声明的是 https://gitee.com/* 而非 https://gitee.com/api/v5/*,这是因为 Gitee API 的认证 token 拼在 URL query 中,实际请求路径覆盖整个域名。

Service Worker 没有声明 "type": "module",这意味着 ../background.js 不能使用 import/export,所有代码写在一个文件的作用域内。这是 MiniMark 选择单文件架构的直接原因。

书签树的序列化与恢复

要把书签同步到远程,第一步是把 Chrome 的书签树转换为可传输的格式。MiniMark 选择直接序列化 chrome.bookmarks.getTree() 的原始返回值,因为 JSON 保留了完整的树形结构和元数据。

上传时的序列化

MiniMark 的 performSync 函数负责上传同步,序列化部分非常直接:

async function performSync(platform) {
  // ...获取配置与 Token...

  // 获取所有书签
  const bookmarks = await chrome.bookmarks.getTree();
  const bookmarkCount = countBookmarks(bookmarks[0]);

  // 直接使用原始书签树(保留标题中的 emoji,Gitee Gist 内容本身支持 emoji)
  const cleanedBookmarks = bookmarks;

  const bookmarkData = {
    version: '1.0',
    timestamp: Date.now(),
    count: bookmarkCount,
    bookmarks: cleanedBookmarks
  };

  // 检查数据大小
  const dataSize = JSON.stringify(bookmarkData).length;
  const dataSizeMB = (dataSize / 1024 / 1024).toFixed(2);
  console.log(`书签数据大小: ${dataSizeMB} MB (${dataSize} 字节)`);

  // Gitee 限制单个文件最大 1MB
  if (platform === 'gitee' && dataSize > 1024 * 1024) {
    throw new Error(`书签数据过大 (${dataSizeMB} MB),Gitee 限制单个文件最大 1MB。`);
  }

  // 根据平台执行同步
  let result;
  if (platform === 'github') {
    result = await syncToGithub(token, gistId, bookmarkData);
  } else if (platform === 'gitee') {
    result = await syncToGitee(token, gistId, bookmarkData);
  }

  // ...保存同步状态...
}

序列化后的数据结构是:

{
  "version": "1.0",
  "timestamp": 1722300000000,
  "count": 152,
  "bookmarks": [
    {
      "id": "0",
      "title": "",
      "children": [
        { "id": "1", "title": "书签栏", "children": [...] },
        { "id": "2", "title": "其他书签", "children": [...] }
      ]
    }
  ]
}

注意 MiniMark 保留了原始的 id 字段。虽然 id 是本地分配的、跨设备不一致,但保留它不增加多少体积,且在恢复时可以参考原始结构。注释中特别说明"保留标题中的 emoji"——这一点在 Gitee 同步时会成为一个问题,后面会讲。

数据大小检查是 Gitee 特有的。Gitee 限制单个 Gist 文件最大 1MB,如果书签数据超过这个限制就直接报错中断,避免发送注定失败的请求。

下载时的书签树恢复

从远端恢复书签是序列化的逆过程。MiniMark 的 restoreFromRemote 函数处理两种模式:覆盖模式和合并模式。我们先看覆盖模式的恢复逻辑:

async function restoreFromRemote(platform) {
  // ...获取配置、Token、Gist ID...

  // 获取远程书签数据
  let bookmarkData;
  if (platform === 'github') {
    bookmarkData = await fetchFromGithub(token, gistId);
  } else if (platform === 'gitee') {
    bookmarkData = await fetchFromGitee(token, gistId);
  }

  // 合并模式:不清空本地,直接把远端合并进本地后返回
  const syncMode = (await chrome.storage.local.get(['syncMode'])).syncMode;
  if (syncMode === 'merge') {
    if (bookmarkData && bookmarkData.bookmarks) {
      await mergeRemoteIntoLocal(bookmarkData.bookmarks);
      // ...保存状态、记录历史...
      return { count: mergedCount };
    }
  }

  // 覆盖模式:删除本地所有书签(保留根文件夹骨架)
  const tree = await chrome.bookmarks.getTree();
  const root = tree[0];
  if (root.children) {
    for (const folder of root.children) {
      if (folder.children) {
        for (const child of folder.children) {
          try {
            if (child.children) {
              await chrome.bookmarks.removeTree(child.id);
            } else {
              await chrome.bookmarks.remove(child.id);
            }
          } catch (e) {
            console.warn('删除书签失败:', e);
          }
        }
      }
    }
  }

  // 恢复书签到对应的文件夹
  if (bookmarkData.bookmarks && bookmarkData.bookmarks[0]) {
    const currentTree = await chrome.bookmarks.getTree();
    const currentRoot = currentTree[0];

    if (currentRoot.children && bookmarkData.bookmarks[0].children) {
      const remoteFolders = bookmarkData.bookmarks[0].children;
      for (let i = 0; i < remoteFolders.length; i++) {
        const remoteFolder = remoteFolders[i];
        // 优先按标题匹配本地文件夹;跨语言时回退到固定位置
        let localFolder = currentRoot.children.find(f => f.title === remoteFolder.title);
        if (!localFolder && currentRoot.children[i]) {
          localFolder = currentRoot.children[i];
        }
        if (localFolder && remoteFolder.children) {
          await restoreBookmarkTree(remoteFolder, localFolder.id);
        }
      }
    }
  }
  // ...保存恢复状态...
}

恢复时有一个跨语言/跨浏览器的细节:远端数据里的顶层文件夹标题可能是"书签栏"(中文 Chrome),但本地浏览器如果装的是英文版,标题是"Bookmarks bar"。MiniMark 的处理策略是先按标题匹配,匹配不到就按固定位置回退(children[0] 是书签栏,children[1] 是其他书签)。这保证了跨语言恢复不会把书签放错位置。

递归恢复的核心函数 restoreBookmarkTree

async function restoreBookmarkTree(node, parentId) {
  if (!node.children) {
    return;
  }

  for (const child of node.children) {
    try {
      if (child.url) {
        // 创建书签
        await chrome.bookmarks.create({
          parentId: parentId,
          title: child.title,
          url: child.url
        });
      } else if (child.children) {
        // 创建文件夹,然后递归
        const folder = await chrome.bookmarks.create({
          parentId: parentId,
          title: child.title
        });
        if (folder && folder.id) {
          await restoreBookmarkTree(child, folder.id);
        }
      }
    } catch (e) {
      console.warn('恢复书签项失败:', e, child);
    }
  }
}

每个创建操作都包在 try-catch 中,单个书签创建失败不会中断整个恢复过程。这是健壮性设计——万一某条书签 URL 格式异常,跳过它继续恢复其余书签。

Gist 同步方案选型

为什么用 Gist 而非仓库文件

很多书签同步教程选择 GitHub Contents API 操作仓库中的文件,MiniMark 选择的是 Gist API。两种方案各有优劣:

维度 Gist API Contents API(仓库文件)
需要 Fork/创建仓库 否,Gist 是开箱即用的轻量存储 是,用户需先有仓库
单文件大小限制 GitHub 无硬性限制;Gitee 1MB GitHub 100MB(仓库文件)
API 复杂度 低,只有 create/get/update/delete 中,需要管理 SHA 乐观锁
版本历史 Gist 自带版本历史 需要 Git commit 历史
适合场景 单文件配置/数据备份 多文件项目

书签同步只需要一个 JSON 文件,Gist 是更轻量的选择。用户无需创建仓库,只要有一个 GitHub/Gitee 账号和 Personal Access Token 就能用。MiniMark 的同步对象就是一个名为 bookmarks.json 的 Gist 文件。

Gist 的增删改查

MiniMark 对 Gist 的操作覆盖了完整生命周期:

  • 创建:首次同步时 POST 一个新 Gist
  • 读取:恢复或分析时 GET Gist 内容
  • 更新:后续同步时 PATCH 已有 Gist
  • 删除:清理旧片段时 DELETE 多余 Gist
  • 列表:查找已存在的同名 Gist,避免重复创建

接下来我们逐个拆解 GitHub 和 Gitee 的实现。

GitHub Gist 同步实现

GitHub Gist API 是同步的核心。MiniMark 的 syncToGithub 函数处理创建和更新两种情况 $TRAE_REF

认证方式

GitHub 使用 Bearer Token 认证,放在请求头中:

headers: {
  'Authorization': `Bearer ${token}`,
  'Accept': 'application/vnd.github.v3+json'
}

创建与更新 Gist

syncToGithub 通过判断是否有 gistId 来决定是 POST(创建)还是 PATCH(更新):

async function syncToGithub(token, gistId, bookmarkData) {
  const url = gistId
    ? `https://api.github.com/gists/${gistId}`
    : 'https://api.github.com/gists';

  const method = gistId ? 'PATCH' : 'POST';

  const body = {
    description: 'MiniBookmark',
    public: false,  // 私有 Gist,仅本人可见
    files: {
      'bookmarks.json': {
        content: JSON.stringify(bookmarkData, null, 2)
      }
    }
  };

  const response = await fetch(url, {
    method: method,
    headers: {
      'Authorization': `Bearer ${token}`,
      'Accept': 'application/vnd.github.v3+json',
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(body)
  });

  if (!response.ok) {
    const errorText = await response.text();
    let errorMsg;
    try {
      const error = JSON.parse(errorText);
      errorMsg = error.message || error.error || `GitHub API 错误: ${response.status}`;
    } catch (e) {
      errorMsg = `GitHub API 错误: ${response.status} - ${errorText}`;
    }
    throw new Error(errorMsg);
  }

  const result = await response.json();
  return {
    gistId: result.id,
    url: result.html_url,
    updatedAt: result.updated_at
  };
}

几个关键点:

public: false 确保创建的是私有 Gist。这是隐私设计的重要一环——书签数据只有用户自己能看到。

Gist 的文件内容通过 files 对象传递,key 是文件名 bookmarks.json,value 是 { content: ... }。更新时同样用这个结构,PATCH 会替换文件内容。

错误处理做了两层:先尝试解析 JSON 错误信息(GitHub API 返回 { "message": "..." }),解析失败则返回原始文本。这样无论是认证失败(401)、速率限制(403)还是服务器错误(500),用户都能看到有意义的错误提示。

读取 Gist

恢复书签和分析同步状态时需要读取 Gist 内容:

async function fetchFromGithub(token, gistId) {
  const response = await fetch(`https://api.github.com/gists/${gistId}`, {
    headers: {
      'Authorization': `Bearer ${token}`,
      'Accept': 'application/vnd.github.v3+json'
    }
  });

  if (!response.ok) {
    throw new Error(`GitHub API 错误: ${response.status}`);
  }

  const result = await response.json();
  const content = result.files['bookmarks.json'].content;
  return JSON.parse(content);
}

Gist API 返回的 JSON 中,files 是一个以文件名为 key 的对象,每个文件有 content 字段保存原始字符串内容。MiniMark 直接 JSON.parse 还原书签数据。

删除 Gist

清理旧片段时需要删除多余的 Gist:

async function deleteGithubGist(token, gistId) {
  const response = await fetch(`https://api.github.com/gists/${gistId}`, {
    method: 'DELETE',
    headers: {
      'Authorization': `Bearer ${token}`,
      'Accept': 'application/vnd.github.v3+json'
    }
  });
  // 成功返回 204;404 表示已被删除,可忽略
  if (!response.ok && response.status !== 404) {
    const t = await response.text().catch(() => '');
    throw new Error(`GitHub 删除 Gist 失败: ${response.status} ${t.slice(0, 120)}`);
  }
}

删除成功返回 204 No Content,如果 Gist 已被删除则返回 404——这种情况视为成功(幂等操作),不报错。

Gitee Gist 同步实现

Gitee 的 Gist API 与 GitHub 类似,但有几个重要差异。MiniMark 的 syncToGitee 函数处理了这些差异 $TRAE_REF

认证方式

Gitee 使用 query 参数传递 token,而非请求头:

const auth = `?access_token=${encodeURIComponent(token)}`;
const url = `https://gitee.com/api/v5/gists/${id}${auth}`;

Emoji 处理:Gitee 的 4 字节字符陷阱

这是 Gitee 同步中最隐蔽的问题。Gitee 的数据库使用 utf8(3 字节)编码,无法存储 4 字节 UTF-8 字符——也就是大部分 emoji(如 😀🎉🚀)。直接写入会报 Mysql2::Error: Incorrect string value

MiniMark 在上传前用正则剥离这些字符:

function sanitizeForGitee(text) {
  if (typeof text !== 'string') return text;
  // 移除 U+10000 及以上的补充平面字符(即 4 字节 UTF-8)
  return text.replace(/[\u{10000}-\u{10FFFF}]/gu, '');
}

async function syncToGitee(token, gistId, bookmarkData) {
  // 剥离 4 字节 UTF-8 字符,避免 Gitee 数据库写入失败
  const safeContent = sanitizeForGitee(JSON.stringify(bookmarkData));
  // ...后续用 safeContent 替代原始内容...
}

正则 /[\u{10000}-\u{10FFFF}]/gu 匹配所有补充平面字符(Unicode 码点大于等于 U+10000),u 标志启用 Unicode 模式。这意味着 Gitee 同步后书签标题里的 emoji 会消失,但同步能成功。GitHub 则完整保留 emoji。

多候选更新策略

Gitee 同步有一个 GitHub 不需要的复杂逻辑:多候选更新。原因是 Gitee 的 Gist ID 可能因为各种原因失效(如用户在网页端删除了 Gist),但本地存储的 gistId 还在。MiniMark 不会直接新建,而是先尝试更新已记录的 ID,失败后再查询列表找最新的同名 Gist 继续 UPDATE,只有都不行才新建:

async function syncToGitee(token, gistId, bookmarkData) {
  const safeContent = sanitizeForGitee(JSON.stringify(bookmarkData));
  const auth = `?access_token=${encodeURIComponent(token)}`;
  const headers = { 'Content-Type': 'application/json' };
  const patchBody = JSON.stringify({
    files: { 'bookmarks.json': { content: safeContent, filename: 'bookmarks.json' } }
  });

  const patchGist = async (id) => {
    const r = await fetch(`https://gitee.com/api/v5/gists/${id}${auth}`, {
      method: 'PATCH', headers, body: patchBody
    });
    if (r.ok) {
      const res = await r.json();
      return res;
    }
    return null;  // 失败返回 null,尝试下一个候选
  };

  // 需要尝试更新的目标:记录的 gistId 优先;失效则查询列表取最新一份
  const targets = [];
  if (gistId) targets.push(gistId);
  try {
    const list = await fetchGiteeGists(token);
    const candidates = (list || [])
      .filter(g => g.files && g.files['bookmarks.json'])
      .sort((a, b) =>
        new Date(b.updated_at || b.created_at || 0).getTime() -
        new Date(a.updated_at || a.created_at || 0).getTime()
      );
    if (candidates[0]) targets.push(candidates[0].id);
  } catch (e) {
    console.warn('获取 Gitee 片段列表失败:', e);
  }

  // 去重后依次尝试 PATCH 更新
  const tried = new Set();
  for (const id of targets) {
    if (!id || tried.has(id)) continue;
    tried.add(id);
    try {
      const res = await patchGist(id);
      if (res) {
        return { gistId: res.id, url: res.html_url, updatedAt: res.updated_at };
      }
    } catch (e) {
      console.warn('Gitee 更新异常(尝试下一候选):', e);
    }
  }

  // 没有任何可更新的现有片段:新建一份
  const createResponse = await fetch(`https://gitee.com/api/v5/gists${auth}`, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      files: { 'bookmarks.json': { content: safeContent } },
      description: 'MiniBookmark',
      public: false
    })
  });
  // ...错误处理与返回...
}

这个设计避免了"反复新建产生重复 Gist"的问题。想象一下,如果每次本地记录的 gistId 失效就直接新建,用户同步十次就会产生十个 Gist,既浪费存储又难管理。多候选策略确保优先复用已有片段。

GitHub 与 Gitee API 差异对照

维度 GitHub Gist API Gitee Gist API
认证 Header Authorization: Bearer token Query ?access_token=token
Emoji 支持 完整支持 不支持 4 字节字符,需剥离
文件大小限制 无硬性限制 单文件 1MB
创建端点 POST /gists POST /api/v5/gists
更新端点 PATCH /gists/{id} PATCH /api/v5/gists/{id}
删除端点 DELETE /gists/{id} DELETE /api/v5/gists/{id}
列表端点 GET /gists GET /api/v5/gists
私有 Gist public: false public: false

两个平台的 API 形态高度相似,差异集中在认证方式、字符编码限制和文件大小限制上。MiniMark 通过为每个平台写独立的同步函数来处理这些差异,而非抽象成统一接口——对于两个平台来说,抽象的收益低于代码复杂度增加的成本。

复用已有 Gist:避免重复创建

一个容易被忽略但很重要的细节:当用户清除浏览器数据或换设备后,本地存储的 gistId 丢失了,但远端的 Gist 还在。如果直接新建,会产生重复片段。

MiniMark 的 performSync 在没有本地 gistId 时,先调用 findExistingGist 查找已存在的同名 Gist:

async function findExistingGist(platform) {
  const config = await chrome.storage.local.get(['githubToken', 'giteeToken']);
  const token = platform === 'github' ? config.githubToken : config.giteeToken;

  let gistList;
  if (platform === 'github') {
    gistList = await fetchGithubGists(token);
  } else if (platform === 'gitee') {
    gistList = await fetchGiteeGists(token);
  }

  // 查找名为 bookmarks.json 的 Gist,按更新时间倒序取最新
  const bookmarkCandidates = (gistList || []).filter(gist => {
    return gist.files && gist.files['bookmarks.json'];
  });
  bookmarkCandidates.sort((a, b) => {
    const ta = new Date(a.updated_at || a.created_at || 0).getTime();
    const tb = new Date(b.updated_at || b.created_at || 0).getTime();
    return tb - ta;
  });
  const bookmarkGist = bookmarkCandidates[0] || null;

  if (bookmarkGist) {
    // 保存 gistId 到本地,下次直接用
    const storageData = {};
    if (platform === 'github') {
      storageData.githubGistId = bookmarkGist.id;
    } else {
      storageData.giteeGistId = bookmarkGist.id;
    }
    await chrome.storage.local.set(storageData);
    return { gistId: bookmarkGist.id, count: bookmarkData.count || 0 };
  } else {
    return { gistId: null, count: 0 };
  }
}

performSync 中的调用逻辑:

if (!gistId) {
  try {
    const existing = await findExistingGist(platform);
    if (existing && existing.gistId) {
      gistId = existing.gistId;
      console.log(`${platform}: 复用已存在的 Gist ${gistId},不再新建`);
    }
  } catch (e) {
    console.warn(`查找已存在 ${platform} Gist 失败,将继续创建新 Gist:`, e);
  }
}

查找失败不会中断同步流程,而是降级为创建新 Gist。这是防御性编程的体现——网络抖动或 API 临时不可用不应该让用户无法同步。

双向同步与合并策略

书签同步不是简单的"上传"或"下载",而是一个需要处理冲突的分布式数据同步问题。MiniMark 提供两种同步模式:覆盖模式和合并模式。

覆盖模式

覆盖模式以当前设备书签为准,整体替换另一端。上传时本地替换远端,下载时远端替换本地。下载恢复时会先清空本地书签栏内容再写入远端数据。

这种模式适合单设备主导的场景——你主要在一台电脑上管理书签,其他设备只是读取。它的逻辑简单,强一致,但多设备同时修改时会丢失数据。

合并模式

合并模式按 URL 去重,保留两端各自新增的书签。上传时先把远端书签合并进本地(不删除本地已有项),再整体上传;下载时同样以合并方式写入本地。这种模式适合多设备并行使用的场景。

合并的核心是 mergeIntoLocal 函数,它递归地把远端节点合并进本地文件夹:

async function mergeIntoLocal(remoteNodes, parentId) {
  if (!Array.isArray(remoteNodes) || !parentId) return;
  // 获取本地该文件夹下已有的子节点
  let existing = [];
  try { existing = await chrome.bookmarks.getChildren(parentId); } catch (e) { return; }

  // 按 URL(书签)或标题(文件夹)建立去重索引
  const existingByKey = new Map();
  for (const n of existing) {
    const key = n.url ? ('u:' + n.url) : ('f:' + (n.title || ''));
    if (!existingByKey.has(key)) existingByKey.set(key, n);
  }

  for (const r of remoteNodes) {
    const key = r.url ? ('u:' + r.url) : ('f:' + (r.title || ''));
    const found = existingByKey.get(key);
    if (found) {
      // 文件夹则继续向下合并;书签(叶子)已存在则跳过
      if (!r.url && r.children && found.url === undefined) {
        await mergeIntoLocal(r.children, found.id);
      }
      continue;
    }
    // 本地不存在,创建
    try {
      if (r.url) {
        await chrome.bookmarks.create({ parentId, title: r.title || '', url: r.url });
      } else {
        const folder = await chrome.bookmarks.create({ parentId, title: r.title || '文件夹' });
        if (r.children) await mergeIntoLocal(r.children, folder.id);
      }
    } catch (e) {
      console.warn('合并创建书签失败:', e);
    }
  }
}

去重逻辑值得细看:书签按 URL 去重(u: + url),文件夹按标题去重(f: + title)。如果远端的某个书签 URL 在本地已存在,跳过不创建;如果远端的某个文件夹标题在本地已存在,不新建文件夹,而是递归地把远端文件夹里的内容合并进本地同名文件夹。

mergeRemoteIntoLocal 是合并的入口,它按顶层文件夹标题匹配,把远端的"书签栏""其他书签"分别合并进本地对应文件夹:

async function mergeRemoteIntoLocal(remoteBookmarks) {
  if (!remoteBookmarks || !remoteBookmarks[0]) return;
  const localTree = await chrome.bookmarks.getTree();
  const localRoot = localTree[0];
  for (const remoteTop of (remoteBookmarks[0].children || [])) {
    // 按标题匹配本地顶层文件夹
    let localTop = (localRoot.children || []).find(c => !c.url && c.title === remoteTop.title);
    if (!localTop) {
      // 本地没有同名文件夹,创建一个
      try {
        localTop = await chrome.bookmarks.create({ parentId: localRoot.id, title: remoteTop.title || '文件夹' });
      } catch (e) { console.warn('创建顶层文件夹失败:', e); continue; }
    }
    await mergeIntoLocal(remoteTop.children || [], localTop.id);
  }
}

合并模式的设计哲学是"只增不删"——永远不会删除本地已有的书签,只把远端有而本地没有的补进来。这最大限度避免了数据丢失,代价是可能产生重复(如果同一书签 URL 不同则不去重)。对于绝大多数用户场景,这个权衡是合理的。

上传时的合并时机

覆盖模式下,上传直接把本地书签推到远端。合并模式下,上传前会先把远端合并进本地,再上传:

// performSync 中的合并逻辑
const syncMode = (await chrome.storage.local.get(['syncMode'])).syncMode;
if (syncMode === 'merge') {
  try {
    const remote = await fetchRemoteBookmarkData(platform);
    if (remote && remote.bookmarks) {
      await mergeRemoteIntoLocal(remote.bookmarks);
      console.log('合并模式:已把远端书签合并进本地');
    }
  } catch (e) {
    console.warn('合并远端到本地失败(继续以本地为准上传):', e);
  }
}

合并失败不会中断上传,而是降级为"以本地为准上传"。这保证了即使远端数据暂时不可用,用户至少能把本地数据备份上去。

智能自动同步

手动同步需要用户主动操作,但很多人会忘记。MiniMark 的定时同步功能通过 chrome.alarms API 实现后台自动同步。

定时任务设置

function setupAutoSync(intervalMinutes) {
  chrome.alarms.create('autoSync', {
    periodInMinutes: intervalMinutes
  });
  console.log(`自动同步已设置,间隔: ${intervalMinutes} 分钟`);
}

用户可以在设置页选择间隔(1/3/6/12/24 小时),切换时通过消息通知 Service Worker 重新创建 alarm:

// background.js 消息处理
} else if (request.action === 'updateAutoSync') {
  if (request.enabled) {
    chrome.storage.local.get(['syncInterval'], (result) => {
      setupAutoSync(result.syncInterval || 1440);
    });
  } else {
    chrome.alarms.clear('autoSync');
  }
  sendResponse({ success: true });
}

alarm 触发时执行 performAutoSync

chrome.alarms.onAlarm.addListener((alarm) => {
  if (alarm.name === 'autoSync') {
    performAutoSync().catch(error => {
      console.error('自动同步失败:', error);
    });
  }
});

智能方向选择

自动同步不是无脑上传,而是会比较本地与远端书签数量,选择更优方向。这是 MiniMark 的一个亮点设计:

async function performAutoSync() {
  const config = await chrome.storage.local.get([
    'githubToken', 'giteeToken', 'githubGistId', 'giteeGistId', 'syncMode'
  ]);

  const bookmarks = await chrome.bookmarks.getTree();
  const localCount = countBookmarks(bookmarks[0]);

  // 对每个已配置的平台执行智能同步
  const platforms = [];
  if (config.githubToken) platforms.push('github');
  if (config.giteeToken) platforms.push('gitee');

  for (const platform of platforms) {
    try {
      const gistId = platform === 'github' ? config.githubGistId : config.giteeGistId;
      const syncMode = config.syncMode;

      // 合并模式:直接走 performSync(内部双向合并)
      if (syncMode === 'merge') {
        await performSync(platform);
        continue;
      }

      if (!gistId) {
        // 没有远程备份,直接上传
        await performSync(platform);
        continue;
      }

      // 覆盖模式:比较数量选择方向
      const remoteCount = await getRemoteBookmarkCount(platform);

      if (remoteCount > localCount) {
        // 远端更多,从远端下载,避免覆盖更多数据
        await restoreFromRemote(platform);
      } else {
        // 本地更多或相等,上传到远端
        await performSync(platform);
      }
    } catch (error) {
      console.error(`${platform} 自动同步失败:`, error);
      // 继续处理下一个平台
    }
  }
}

智能决策的逻辑很直觉:哪边书签多就以哪边为准。如果远端有 200 个书签而本地只有 50 个(比如用户在另一台电脑上加了书签但这台还没同步),自动同步会从远端下载而不是用本地的 50 个覆盖远端的 200 个。这避免了"同步一次丢一半书签"的灾难。

合并模式则不需要比较数量,直接执行 performSync——它内部会先把远端合并进本地再上传,天然是双向的。

每个平台的同步是独立的,一个平台失败不影响另一个。这是健壮性设计——GitHub API 限流不应该阻止 Gitee 同步。

同步状态对比分析

用户在同步前往往想知道"本地和远端到底差了多少"。MiniMark 的 analyzeSyncStatus 函数实现了这个功能,它是同步功能中逻辑最精巧的部分之一。

扁平化书签树

对比的第一步是把树形结构扁平化为列表,这样可以用 URL 集合做差集运算:

function flattenBookmarks(treeNode, folderPath = '', out = []) {
  const children = Array.isArray(treeNode) ? treeNode : (treeNode && treeNode.children) || [];
  for (const node of children) {
    const title = node.title || '';
    const path = folderPath ? `${folderPath} / ${title}` : title;
    if (node.url) {
      out.push({ title, url: node.url, folderPath: path });
    }
    if (node.children) {
      flattenBookmarks(node.children, path, out);
    }
  }
  return out;
}

flattenBookmarks 递归遍历整棵树,把每个有 url 的节点提取为 { title, url, folderPath }folderPath 记录了书签所在的完整路径(如"书签栏 / 开发工具 / 前端"),方便在对比报告中显示位置。

差集计算

async function analyzeSyncStatus(platform) {
  const localTree = await chrome.bookmarks.getTree();
  const localFlat = flattenBookmarks(localTree);
  let remoteFlat = [];
  let remoteError = null;
  try {
    const remoteData = await fetchRemoteBookmarkData(platform);
    if (remoteData && remoteData.bookmarks) {
      remoteFlat = flattenBookmarks(remoteData.bookmarks);
    }
  } catch (e) {
    remoteError = e.message || String(e);
  }

  // 用 URL 集合做差集
  const localUrls = new Set(localFlat.filter(b => b.url).map(b => b.url));
  const remoteUrls = new Set(remoteFlat.filter(b => b.url).map(b => b.url));

  const localOnlyItems = localFlat.filter(b => b.url && !remoteUrls.has(b.url));
  const remoteOnlyItems = remoteFlat.filter(b => b.url && !localUrls.has(b.url));
  const bothCount = localFlat.filter(b => b.url && remoteUrls.has(b.url)).length;

  const SAMPLE = 50;
  return {
    platform,
    localCount: localFlat.length,
    remoteCount: remoteFlat.length,
    localOnlyCount: localOnlyItems.length,
    remoteOnlyCount: remoteOnlyItems.length,
    bothCount,
    remoteError,
    localOnlyItems: localOnlyItems.slice(0, SAMPLE),
    remoteOnlyItems: remoteOnlyItems.slice(0, SAMPLE),
    localOnlyTotal: localOnlyItems.length,
    remoteOnlyTotal: remoteOnlyItems.length,
    hasMoreLocal: localOnlyItems.length > SAMPLE,
    hasMoreRemote: remoteOnlyItems.length > SAMPLE,
  };
}

核心是三个集合运算:

  • 本地独有 = 本地 URL 集合 - 远端 URL 集合
  • 远端独有 = 远端 URL 集合 - 本地 URL 集合
  • 两端共有 = 本地 URL 集合 ∩ 远端 URL 集合

差异项数量 = 本地独有 + 远端独有。如果两者都为 0,说明本地与远端完全一致。

返回值中 localOnlyItemsremoteOnlyItems 只取前 50 条样本(SAMPLE = 50),避免数据量太大导致 Popup 渲染卡顿。同时返回 hasMoreLocal/hasMoreRemote 标记,让 UI 显示"仅展示前 50 条,共 N 条"。

remoteError 字段保存远端获取失败的错误信息。如果远端不可用(如 Token 过期),分析结果中会标注"远端不可用",而不是直接报错中断。

自动标记已同步

分析还有一个副作用:如果发现本地与远端完全一致,会自动清除"未同步"标记:

// checkStatus 函数中(popup.js)
const identical = !a.remoteError && a.localOnlyCount === 0 && a.remoteOnlyCount === 0;
if (identical) {
  const cur = await chrome.storage.local.get(['hasUnsynced']);
  if (cur.hasUnsynced) {
    await chrome.storage.local.set({ hasUnsynced: false });
    await renderAll();
    toast('本地与远端一致,已自动标记为已同步', 'success');
  }
}

这处理了一个边界情况:用户在另一台设备上同步后,本地的"未同步"角标还在,但实际上数据已经一致了。检查状态时自动修正这个误报。

Token 存储与验证

存储方案

MiniMark 的 Token 直接存储在 chrome.storage.local 中,没有做额外加密。这是一个有意的权衡:

// 验证成功后保存 Token
await chrome.storage.local.set({ [tokenKey]: token });

// 保存用户信息
await chrome.storage.local.set({ [userKey]: res.user });

chrome.storage.local 的数据存储在浏览器的本地 Profile 目录中,不与云同步(不同于 chrome.storage.sync),其他扩展无法访问。对于浏览器扩展场景,这个安全级别是足够的——如果攻击者能访问你的浏览器 Profile 目录,Token 安全已经不是主要问题了。

更重要的安全措施在传输层:所有请求直连 GitHub/Gitee 官方 API,Gist 创建为私有,扩展本身不架设任何中转服务。

Token 验证

用户在设置页粘贴 Token 后,MiniMark 会先验证其有效性并获取用户信息:

async function verifyGithubToken(token) {
  const r = await fetch('https://api.github.com/user', {
    headers: { 'Authorization': 'Bearer ' + token, 'Accept': 'application/vnd.github.v3+json' }
  });
  if (!r.ok) {
    const t = await r.text().catch(() => '');
    throw new Error('GitHub 验证失败: ' + r.status + (t ? ' ' + t.slice(0, 120) : ''));
  }
  const u = await r.json();
  return {
    name: u.name || u.login || 'GitHub 用户',
    login: u.login || '',
    avatarUrl: u.avatar_url || ''
  };
}

验证通过后返回用户名和头像 URL,Popup 中会显示当前登录用户。验证失败则清除 Token 并提示:

// popup.js 中的验证流程
const res = await chrome.runtime.sendMessage({ action: 'verifyToken', platform });
if (res && res.success && res.user) {
  await chrome.storage.local.set({ [userKey]: res.user });
  toast(name + ' Token 验证成功', 'success');
} else {
  // 验证失败,清除 Token
  await chrome.storage.local.remove([tokenKey, userKey]);
  toast(name + ' Token 验证失败: ' + ((res && res.error) || '请检查 Token 权限'), 'error');
}

验证失败时清除 Token 是安全设计——避免无效 Token 残留,下次同步时再报错。用户需要重新粘贴正确的 Token。

实时未同步提醒

MiniMark 的一个细节体验:当本地书签发生变化时,扩展图标上会立即出现红色角标,提醒用户"有未同步的修改"。

监听书签变化

chrome.bookmarks.onCreated.addListener(() => { markUnsynced().catch(e=>console.warn(e)); });
chrome.bookmarks.onRemoved.addListener(() => { markUnsynced().catch(e=>console.warn(e)); });
chrome.bookmarks.onChanged.addListener(() => { markUnsynced().catch(e=>console.warn(e)); });
chrome.bookmarks.onMoved.addListener(() => { markUnsynced().catch(e=>console.warn(e)); });

四个事件都指向同一个处理函数 markUnsynced

标记未同步

markUnsynced 通过比较书签数量来判断是否有变化:

async function markUnsynced() {
  try {
    const res = await chrome.storage.local.get(['lastSyncCount']);
    const tree = await chrome.bookmarks.getTree();
    const current = countBookmarks(tree[0]);
    const last = res.lastSyncCount !== undefined ? res.lastSyncCount : null;
    // 如果没有 lastSyncCount(首次)且 current>0,视为未同步
    const hasUnsynced = (last === null && current > 0) || (last !== null && current !== last);
    await chrome.storage.local.set({ hasUnsynced });
    await updateActionBadge();
  } catch (e) {
    console.warn('标记未同步失败:', e);
  }
}

逻辑分两种情况:

  • 首次安装lastSyncCount 不存在):如果本地已有书签(current > 0),标记为未同步——因为还没有备份过。
  • 已有同步记录:比较当前书签数与上次同步时记录的数量,不一致就标记为未同步。

同步成功后,performSync 会更新 lastSyncCount 并清除标记:

await chrome.storage.local.set({
  lastSyncCount: bookmarkCount,
  hasUnsynced: false
});
await updateActionBadge();

角标管理

async function updateActionBadge() {
  try {
    const res = await chrome.storage.local.get(['hasUnsynced', 'githubToken', 'giteeToken']);
    const has = !!res.hasUnsynced;
    const loggedIn = !!(res.githubToken || res.giteeToken);

    // 未登录任何平台则不显示角标
    if (!loggedIn) {
      chrome.action.setBadgeText({ text: '' });
      return;
    }

    if (has) {
      chrome.action.setBadgeText({ text: '●' });
      chrome.action.setBadgeBackgroundColor({ color: '#FF3B30' });
    } else {
      chrome.action.setBadgeText({ text: '' });
    }
  } catch (e) {
    console.warn('更新角标失败:', e);
  }
}

角标逻辑有一个条件:只有用户登录了至少一个平台才显示角标。如果用户还没配置 Token,显示"未同步"角标没有意义——反正也没法同步。

角标文本用 (圆点)而非文字,这是因为扩展图标本身很小,文字角标会拥挤。红色 #FF3B30 是 iOS 系统的警告红,视觉上醒目但不刺眼。

响应存储变化

角标还需要在 Token 变更或同步状态变更时自动刷新。MiniMark 监听 chrome.storage.onChanged

chrome.storage.onChanged.addListener((changes, areaName) => {
  if (areaName !== 'local') return;
  const keys = Object.keys(changes);
  const watched = ['hasUnsynced', 'githubToken', 'giteeToken', 'lastSyncCount'];
  if (keys.some(k => watched.includes(k))) {
    updateActionBadge().catch(e => console.warn('storage change 更新角标失败:', e));
  }
});

只监听 local 存储区域,只关心四个关键字段的变化。这样无论是用户在设置页退出登录(Token 被清除)、后台同步成功(hasUnsynced 变为 false),还是书签变动触发了 markUnsynced,角标都会自动更新。

旧片段自动清理

多次同步可能产生多个同名 Gist(比如本地 gistId 失效后新建,或者用户手动清除了本地存储)。MiniMark 会在启动时自动清理这些旧片段,只保留最新一份。

清理逻辑

async function cleanupOldGists(platform) {
  const config = await chrome.storage.local.get([
    'githubToken', 'giteeToken', 'githubGistId', 'giteeGistId'
  ]);
  const token = platform === 'github' ? config.githubToken : config.giteeToken;
  if (!token) return;

  const list = platform === 'github'
    ? await fetchGithubGists(token)
    : await fetchGiteeGists(token);

  // 找出所有包含 bookmarks.json 的 Gist
  const candidates = (list || []).filter(g => g.files && g.files['bookmarks.json']);
  if (candidates.length <= 1) return; // 不足两份无需清理

  // 按更新时间倒序,最新的排在最前
  candidates.sort((a, b) => {
    const ta = new Date(a.updated_at || a.created_at || 0).getTime();
    const tb = new Date(b.updated_at || b.created_at || 0).getTime();
    return tb - ta;
  });

  const keep = candidates[0];        // 保留最新
  const remove = candidates.slice(1); // 删除其余
  let deleted = 0;

  for (const g of remove) {
    try {
      if (platform === 'github') await deleteGithubGist(token, g.id);
      else await deleteGiteeGist(token, g.id);
      deleted++;
    } catch (e) {
      console.warn(`[${platform}] 删除旧片段 ${g.id} 失败:`, e);
    }
  }

  // 若本地记录的 gistId 不是保留的最新一份,则更新
  const storedId = platform === 'github' ? config.githubGistId : config.giteeGistId;
  if (storedId !== keep.id) {
    const set = platform === 'github'
      ? { githubGistId: keep.id }
      : { giteeGistId: keep.id };
    await chrome.storage.local.set(set);
  }
}

清理策略是:按更新时间排序,保留最新的一份,删除其余。同时更新本地记录的 gistId 为保留的那份——因为可能本地记录的是旧 ID,而最新的那份是之前某次同步新建的。

启动时触发

chrome.runtime.onStartup.addListener(() => {
  updateActionBadge().catch(e=>console.warn(e));
  cleanupOnStartup().catch(e=>console.warn(e));
});

async function cleanupOnStartup() {
  const config = await chrome.storage.local.get([
    'autoCleanupOldGists', 'githubToken', 'giteeToken'
  ]);
  if (config.autoCleanupOldGists === false) return; // 默认开启,可手动关闭
  const platforms = [];
  if (config.githubToken) platforms.push('github');
  if (config.giteeToken) platforms.push('gitee');
  for (const p of platforms) {
    await cleanupOldGists(p);
  }
}

清理在浏览器启动时触发,对每个已配置的平台分别执行。用户可以在设置页关闭"启动时清理同名旧片段"(autoCleanupOldGists),默认开启。

MiniMark 的 Popup 是一个 420×600 像素的弹窗,采用卡片化设计。整个界面通过视图切换实现多页面(主页、设置页、历史页),而非多 HTML 文件。

视图切换机制

Popup HTML 中有三个 <main> 区域,通过 hidden 属性控制显示:

<main class="app-main" id="mainScroll"> ... 主页内容 ... </main>
<main class="app-main settings-view" id="settingsView" hidden> ... 设置内容 ... </main>
<main class="app-main settings-view" id="historyView" hidden> ... 历史内容 ... </main>

切换视图的 JavaScript:

function showSettingsView() {
  loadConfig();
  $('mainScroll').hidden = true;
  $('settingsView').hidden = false;
  const header = document.querySelector('.app-header');
  if (header) header.style.display = 'none';
}
function hideSettingsView() {
  $('settingsView').hidden = true;
  $('mainScroll').hidden = false;
  const header = document.querySelector('.app-header');
  if (header) header.style.display = '';
}

进入设置页时隐藏顶部 Header,返回主页时恢复。这种单 HTML 多视图的设计省去了页面跳转,切换更流畅。

状态卡渲染

状态卡是主页的核心,展示同步状态、进度、自动同步开关和同步对象:

async function renderStatus() {
  const { hasUnsynced, syncHistory = [], autoSync = false, githubToken, giteeToken, syncPlatform = 'github' } = 
    await chrome.storage.local.get(['hasUnsynced', 'syncHistory', 'autoSync', 'githubToken', 'giteeToken', 'syncPlatform']);
  const last = syncHistory.find((h) => h.status === 'success');

  $('statusTitleLg').textContent = hasUnsynced ? '有未同步的修改' : '已是最新';
  $('statusDescLg').textContent = hasUnsynced ? '本地书签与远端不一致' : '本地与远端已保持同步';
  $('statusMetaLg').textContent = '最后同步: ' + (last ? formatTimeShort(last.time) : '--');

  // 进度条:未同步显示 40%,已同步显示 100%
  if (hasUnsynced) {
    fill.style.width = '40%';
    pct.textContent = '40%';
  } else {
    fill.style.width = '100%';
    pct.textContent = '100%';
  }

  // 自动同步开关
  badge.textContent = autoSync ? '自动同步' : '自动同步已关闭';

  // 同步对象
  const platformName = syncPlatform === 'gitee' ? 'Gitee' : 'GitHub';
  healthText.textContent = platformName;
}

状态卡根据 hasUnsynced 切换两种视觉状态:绿色"已是最新"或橙色"有未同步的修改",进度条相应显示 100% 或 40%。

7 日趋势图

趋势图是一个纯 CSS 柱状图,用 grid 布局七根柱子,高度由同步次数决定:

async function renderTrend() {
  const { syncHistory = [] } = await chrome.storage.local.get(['syncHistory']);
  const dayLabels = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
  const now = new Date();
  const dow = (now.getDay() + 6) % 7; // 周一=0
  const startOfWeek = new Date(now);
  startOfWeek.setDate(now.getDate() - dow);
  startOfWeek.setHours(0, 0, 0, 0);

  const days = [];
  for (let i = 0; i < 7; i++) {
    const d = new Date(startOfWeek);
    d.setDate(startOfWeek.getDate() + i);
    const dayStart = d.getTime();
    const dayEnd = dayStart + 86400000;
    const count = syncHistory.filter((h) => h.time >= dayStart && h.time < dayEnd).length;
    days.push({ date: d, label: dayLabels[i], count, isToday: i === dow });
  }

  // 计算较上周变化百分比
  const thisWeekCount = days.reduce((s, d) => s + d.count, 0);
  const lastWeekCount = syncHistory.filter((h) => 
    h.time >= lastWeekStart && h.time < weekStart
  ).length;
  let pct = 0;
  if (lastWeekCount > 0) pct = Math.round(((thisWeekCount - lastWeekCount) / lastWeekCount) * 100);
  else if (thisWeekCount > 0) pct = 100;

  // 渲染柱子
  const max = Math.max(1, ...days.map((d) => d.count));
  chart.innerHTML = days.map((d, i) => {
    const h = Math.max(4, Math.round((d.count / max) * 80));
    const today = d.isToday ? ' today' : '';
    return '<div class="trend-col' + today + '">' +
      '<div class="trend-bar" style="height:' + h + 'px">' + num + '</div>' +
      '<span class="trend-day">' + d.label + '</span>' +
      '</div>';
  }).join('');
}

趋势图计算本周每天的同步次数,柱子高度按比例缩放(最大 80px,最小 4px)。今日柱子会高亮显示。同时计算与上周的同比变化百分比,正数显示绿色↑,负数显示红色↓。

消息通信机制

Popup 和 Service Worker 之间通过 chrome.runtime.sendMessage / chrome.runtime.onMessage 通信。这是 Manifest V3 中 UI 与后台交互的标准方式。

Popup 中的每个操作都通过消息触发后台执行:

// 上传同步
const res = await chrome.runtime.sendMessage({ action: 'uploadToRemote', platform: platform.toLowerCase() });

// 下载恢复
const res = await chrome.runtime.sendMessage({ action: 'downloadFromRemote', platform: platform.toLowerCase() });

// 验证 Token
const res = await chrome.runtime.sendMessage({ action: 'verifyToken', platform });

// 分析同步状态
const res = await chrome.runtime.sendMessage({ action: 'analyzeSyncStatus', platform });

// 更新自动同步设置
await chrome.runtime.sendMessage({ action: 'updateAutoSync', enabled: next });

// 清理旧片段
const res = await chrome.runtime.sendMessage({ action: 'cleanupOldGists', platform: 'all' });

Service Worker 处理消息

../background.js 的消息监听器是一个大的 if-else 链,根据 action 分发到不同处理函数:

chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
  if (request.action === 'syncNow' || request.action === 'uploadToRemote') {
    performSync(request.platform)
      .then(result => sendResponse({ success: true, gistId: result.gistId, count: result.count }))
      .catch(error => sendResponse({ success: false, error: error.message }));
    return true; // 保持消息通道开启(异步响应)
  } else if (request.action === 'downloadFromRemote') {
    restoreFromRemote(request.platform)
      .then(result => sendResponse({ success: true, count: result.count }))
      .catch(error => sendResponse({ success: false, error: error.message }));
    return true;
  }
  // ...其他 action...
});

return true 是关键——它告诉 Chrome 这个监听器会异步响应(通过 Promise 链调用 sendResponse)。如果没有 return true,消息通道会在监听器返回后立即关闭,Popup 端的 await sendMessage 会收到 undefined

消息类型一览

action 方向 说明
uploadToRemote Popup → BG 上传书签到远端
downloadFromRemote Popup → BG 从远端恢复书签
verifyToken Popup → BG 验证 Token 并获取用户信息
analyzeSyncStatus Popup → BG 分析本地与远端差异
updateAutoSync Popup → BG 开启/关闭定时同步
cleanupOldGists Popup → BG 清理旧片段
clearLocalBookmarks Popup → BG 清空本地书签
clearAllBookmarks Popup → BG 清空并重置全部数据
platformSyncUpdate BG → Popup 后台同步完成后通知 Popup 刷新

platformSyncUpdate 是反向通知——后台自动同步完成后,主动通知 Popup(如果打开的话)刷新界面。Popup 端通过 chrome.storage.onChanged 监听存储变化来触发刷新,两种机制互为补充。

调试与测试

调试各组件

MiniMark 的调试分为两个层面:Service Worker 调试和 Popup 调试。

Service Worker 调试:在 chrome://extensions/ 页面,找到 MiniMark 扩展卡片,点击"Service Worker"链接(或"详情" → “检查视图”),会打开一个 DevTools 窗口。所有 console.logconsole.error 输出在这里查看。同步过程的日志(如"书签数据大小"、“Gist 更新成功”)都输出在这里。

Popup 调试:点击扩展图标打开 Popup,在 Popup 上右键选择"检查",会打开 Popup 的 DevTools。这里可以调试 DOM 操作、事件绑定和 Popup 中的 JavaScript 逻辑。

存储查看:在 Popup 的 DevTools 中,进入 Application → Storage → Local Storage,可以查看 chrome.storage.local 的所有数据,包括 Token、Gist ID、同步历史、配置等。

同步逻辑的边界情况

调试同步功能时,需要关注几个边界情况:

首次同步:本地没有 gistId,需要先查找已有 Gist 或新建。调试时可以清除 githubGistId 后触发同步,观察是否正确复用了远端已有的 Gist。

Gist 失效:远端 Gist 被手动删除,本地 gistId 还在。GitHub 会返回 404,Gitee 的多候选策略会尝试从列表中找新的。调试时可以在 GitHub/Gitee 网页端删除 Gist,然后触发同步。

Gitee Emoji:书签标题包含 emoji,同步到 Gitee 后 emoji 消失但同步成功。调试时可以创建一个标题带 emoji 的书签,分别同步到 GitHub 和 Gitee 对比结果。

数据超限:Gitee 1MB 限制。调试时如果书签数据不够大,可以临时修改 performSync 中的阈值测试报错路径。

合并模式去重:在两台设备上分别添加不同书签,合并模式同步后应该两边的书签都保留。调试时可以在同一台设备上:先上传,然后本地删除几个书签再添加几个新书签,用合并模式下载,观察删除的书签是否恢复、新增的书签是否保留。

发布注意事项

隐私政策

Chrome 应用商店要求声明数据使用方式的扩展提供隐私政策。MiniMark 收集的数据包括:

  • 用户的 GitHub/Gitee Personal Access Token(仅存本地)
  • 用户的书签数据(同步到用户私有 Gist)
  • 同步历史记录(仅存本地)

隐私政策需要明确说明这些数据不经过第三方服务器,Token 不上传,Gist 为私有。MiniMark 的 host_permissions 只声明了两个 API 域名,审核时会核对隐私政策与实际网络请求的一致性。

权限说明

提交审核时需要为每个权限提供使用理由:

  • bookmarks:读取和恢复浏览器书签,核心功能
  • storage:存储 Token、配置和同步历史
  • alarms:定时自动同步
  • https://api.github.com/*:与 GitHub Gist API 通信
  • https://gitee.com/*:与 Gitee Gist API 通信

远程代码合规

Manifest V3 严格限制远程代码执行。MiniMark 没有使用 eval、动态 import 或任何远程脚本,所有代码都是扩展包内的本地文件,符合审核要求。没有引入任何外部 CDN 资源或第三方库。

仓库可见性建议

如果开源 MiniMark,建议将仓库设为公开以便社区审查代码安全性。Token 安全是用户最关心的问题,公开代码可以让用户验证"Token 确实只存本地"。

常见问题

Q:同步时报"Gitee 数据过大"怎么办?

Gitee 限制单个 Gist 文件最大 1MB。performSync 在上传前检查数据大小,超过 1MB 直接报错。解决方案是改用 GitHub(无此限制)或精简书签数量。

Q:Gitee 同步后书签标题里的 emoji 消失了?

Gitee 数据库使用 utf8 编码,不支持 4 字节 UTF-8 字符(大部分 emoji)。sanitizeForGitee 函数在上传前剥离这些字符以保证同步成功。GitHub 完整保留 emoji。

Q:换了一台电脑,如何恢复书签?

新设备安装扩展并配置同一平台的 Token。如果没有本地 gistIdperformSync 会先调用 findExistingGist 查找远端已有的同名 Gist 并复用。然后点击"远端 → 本地"下载恢复。覆盖模式下会先清空本地再写入远端数据。

Q:自动同步会覆盖我新加的书签吗?

智能自动同步比较两端数量,选择数据更多的一端为准。如果远端有 200 个书签而本地只有 50 个,会从远端下载而非用本地覆盖远端。多设备并行使用建议切换为合并模式,按 URL 去重保留两端新增。

Q:为什么扩展图标上有时有红点?

红点表示本地书签有变动但尚未同步。markUnsynced 监听书签增删改事件,数量与上次同步记录不一致时显示红点。同步成功后红点消失。如果尚未配置任何平台 Token,不显示红点。

Q:同步产生了多个 Gist 怎么办?

浏览器启动时 cleanupOnStartup 会自动清理同名旧片段,只保留最新一份。也可以在设置页点击"立即清理旧片段"手动触发。

Q:Token 会泄露吗?

Token 仅存储在 chrome.storage.local,所有请求直连 GitHub/Gitee 官方 API,扩展不经过任何第三方服务。Gist 默认创建为私有,仅本人可见。退出登录时 Token 会被清除。

架构总结

MiniMark 用不到 1300 行 JavaScript(../background.js)和 900 行(../popup.js)实现了一个功能完整的书签同步扩展。回顾它的架构决策:

单文件 Service Worker。所有后台逻辑集中在 ../background.js,没有 ES Module 拆分。对于这个体量的扩展,单文件的可读性和调试便利性高于模块化。代码用注释分隔块组织,结构清晰。

Gist 而非仓库文件。Gist 是开箱即用的轻量存储,用户无需创建仓库。代价是 Gitee 有 1MB 限制和 emoji 问题,MiniMark 通过数据大小检查和字符剥离来应对。

覆盖与合并双模式。覆盖模式简单强一致,适合单设备主导;合并模式只增不删,适合多设备并行。智能自动同步在覆盖模式下比较数量选方向,合并模式下直接双向合并。

防御性编程贯穿始终。每个 API 调用都包在 try-catch 中,单个操作失败不中断整体流程。Gist ID 失效时多候选更新,查找失败时降级新建,合并失败时降级覆盖上传。

消息通信解耦 UI 与逻辑。Popup 只负责展示和交互,所有业务逻辑在 Service Worker 中执行。return true 保持异步消息通道,chrome.storage.onChanged 实现数据变化的自动刷新。

隐私优先的安全设计。Token 仅存本地,Gist 创建为私有,请求直连官方 API,最小权限声明。代码开源可审计。

这些决策共同构成了一款简洁、实用、可信赖的书签同步扩展。如果你正在学习 Chrome 扩展开发,MiniMark 是一个值得逐行阅读的真实案例——它不大,但覆盖了 Manifest V3 的核心知识点:Service Worker、消息通信、Bookmarks API、Alarms API、Storage API、跨域请求、多语言、角标管理。