
Last updated on
MS-ToDo 浏览器插件开发实践:从微软待办同步到思维导图的技术实现
MS-ToDo 浏览器插件开发实践:从微软待办同步到思维导图的技术实现
MS-ToDo 是我开发的一款 Chrome 浏览器扩展,它将微软 To Do(Microsoft To Do)的任务管理能力与思维导图的可视化结构结合在一起,让用户可以在浏览器中高效管理日常任务。
这款插件目前已服务 110+ 活跃用户,在开发过程中我积累了不少 Chrome Extension 开发、OAuth 认证、离线存储和数据结构设计方面的经验。本文将从技术角度分享几个核心功能的实现方案。
一、项目背景与技术选型
1.1 为什么开发这款插件?
微软 To Do 本身是一款优秀的任务管理工具,但存在几个痛点:
- 浏览器中需要打开新标签页访问,打断当前工作流
- 任务列表是线性的,缺乏结构化的层级视图
- 无法在浏览器中快速添加任务(需要完整打开应用)
MS-ToDo 的目标是:在浏览器中提供一个触手可及的任务面板,同时用思维导图的方式呈现任务结构。
1.2 技术栈选择
| 技术 | 选择 | 理由 |
|---|---|---|
| 扩展规范 | Manifest V3 | Chrome 最新标准,Service Worker 替代 Background Page |
| 前端框架 | 原生 JS + Web Components | 轻量级,减少包体积 |
| 数据存储 | IndexedDB + chrome.storage | 离线优先,支持大量任务数据 |
| API 对接 | Microsoft Graph API | 微软官方 API,支持完整的 To Do 操作 |
| 认证方式 | OAuth 2.0 (PKCE) | 安全的第三方认证流程 |
| 思维导图 | 自研 Markdown 解析引擎 | 轻量、可控、无外部依赖 |
二、微软待办 API 双向同步
2.1 Microsoft Graph API 接入
微软 To Do 的数据通过 Microsoft Graph API 进行管理。核心接口包括:
GET /me/todo/lists # 获取所有任务列表
GET /me/todo/lists/{id}/tasks # 获取列表下的所有任务
POST /me/todo/lists/{id}/tasks # 创建新任务
PATCH /me/todo/lists/{id}/tasks/{tid} # 更新任务
DELETE /me/todo/lists/{id}/tasks/{tid} # 删除任务
2.2 OAuth 2.0 PKCE 认证流程
由于浏览器扩展无法安全存储 Client Secret,我们采用 PKCE(Proof Key for Code Exchange)流程:
// 1. 生成 PKCE 验证码
async function generatePKCE() {
const verifier = generateRandomString(64);
const challenge = await sha256(verifier);
return { verifier, challenge };
}
// 2. 构建授权 URL
function buildAuthUrl(codeChallenge) {
const params = new URLSearchParams({
client_id: CLIENT_ID,
response_type: 'code',
redirect_uri: chrome.identity.getRedirectURL(),
scope: 'Tasks.ReadWrite offline_access',
code_challenge: codeChallenge,
code_challenge_method: 'S256'
});
return `https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?${params}`;
}
// 3. 通过 chrome.identity 获取授权码
chrome.identity.launchWebAuthFlow(
{ url: authUrl, interactive: true },
async (redirectUrl) => {
const code = new URL(redirectUrl).searchParams.get('code');
const tokens = await exchangeCodeForTokens(code, verifier);
await saveTokens(tokens);
}
);
2.3 双向同步策略
同步是这款插件最核心的功能,也是最复杂的部分。核心设计思路是基于时间戳的增量同步:
// 同步引擎核心逻辑
class SyncEngine {
constructor() {
this.lastSyncTime = null;
this.conflictResolution = 'timestamp'; // 基于时间戳解决冲突
}
async sync() {
// 1. 获取本地变更(delta)
const localChanges = await this.getLocalChanges(this.lastSyncTime);
// 2. 获取远端变更
const remoteChanges = await this.getRemoteChanges(this.lastSyncTime);
// 3. 合并变更(冲突解决)
const merged = this.resolveConflicts(localChanges, remoteChanges);
// 4. 应用合并结果
await this.applyChanges(merged);
// 5. 更新同步时间戳
this.lastSyncTime = new Date();
await this.saveLastSyncTime();
}
// 冲突解决:比较 lastModifiedDateTime
resolveConflicts(local, remote) {
const conflicts = this.findConflicts(local, remote);
return conflicts.map(item => {
// 时间戳较新的变更胜出
if (item.local.lastModified > item.remote.lastModified) {
return { action: 'push', data: item.local };
} else {
return { action: 'pull', data: item.remote };
}
});
}
}
关键设计决策:
- 操作级同步:只同步变更的操作(创建/更新/删除),而非全量数据,大幅减少 API 调用
- 离线队列:离线时的操作记录在本地队列中,恢复网络后按顺序提交
- 冲突处理:基于
lastModifiedDateTime时间戳,较新的修改覆盖较旧的
三、思维导图解析引擎
3.1 Markdown 到思维导图的映射
MS-ToDo 支持用 Markdown 语法描述任务结构,然后自动解析为思维导图。映射规则如下:
| Markdown 语法 | 思维导图节点 | 说明 |
|---|---|---|
# 标题 |
根节点 | 整个思维导图的主题 |
## 二级标题 |
一级分支 | 主要分类 |
- 列表项 |
子节点 | 具体任务 |
- 缩进列表 |
孙节点 | 子任务 |
普通文本 |
备注/描述 | 附加信息 |
3.2 解析器实现
// Markdown → 思维导图树结构
class MindMapParser {
parse(markdown) {
const lines = markdown.split('\n');
const root = { type: 'root', children: [], text: '' };
const stack = [{ node: root, level: -1 }];
for (const line of lines) {
const parsed = this.parseLine(line);
if (!parsed) continue;
const { level, type, text } = parsed;
const node = { type, text, children: [], collapsed: false };
// 找到合适的父节点
while (stack.length > 1 && stack[stack.length - 1].level >= level) {
stack.pop();
}
stack[stack.length - 1].node.children.push(node);
stack.push({ node, level });
}
return root;
}
parseLine(line) {
// 标题
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
return { level: headingMatch[1].length, type: 'heading', text: headingMatch[2] };
}
// 列表项
const listMatch = line.match(/^(\s*)-\s+(.+)$/);
if (listMatch) {
const indent = listMatch[1].length;
return { level: indent / 2 + 1, type: 'list', text: listMatch[2] };
}
// 普通文本
if (line.trim()) {
return { level: 99, type: 'text', text: line.trim() };
}
return null;
}
}
3.3 渲染与交互
思维导图使用 SVG 渲染,支持以下交互:
- 点击节点展开/折叠子节点
- 拖拽节点调整层级和顺序
- 双击节点进入编辑模式
- 右键菜单快速添加子任务
SVG 渲染的核心是树形布局算法,计算每个节点的坐标位置:
// 简化的树形布局算法
function layoutTree(node, x, y, config) {
node.x = x;
node.y = y;
if (node.collapsed || !node.children.length) return;
const childX = x + config.nodeWidth + config.hGap;
let childY = y;
// 计算子树总高度
const totalHeight = node.children.reduce((sum, child) => {
return sum + getSubtreeHeight(child, config);
}, 0) + (node.children.length - 1) * config.vGap;
// 居中分布子节点
childY = y - totalHeight / 2;
for (const child of node.children) {
const height = getSubtreeHeight(child, config);
layoutTree(child, childX, childY + height / 2, config);
childY += height + config.vGap;
}
}
四、离线优先存储方案
4.1 存储架构
插件采用离线优先策略,所有数据首先存储在本地,同步时与远端交互:
┌─────────────────────────────────────────────┐
│ 存储架构 │
├─────────────────────────────────────────────┤
│ │
│ chrome.storage.local ← 用户设置、Token │
│ IndexedDB ← 任务数据(大量) │
│ chrome.storage.session ← 运行时状态 │
│ │
│ ↕ 同步引擎 │
│ │
│ Microsoft Graph API ← 远端数据 │
│ │
└─────────────────────────────────────────────┘
为什么选择 IndexedDB 而非 chrome.storage?
chrome.storage.local有 5MB 的容量限制( Manifest V3 中可提升到 10MB)- 任务数据(含历史记录)可能超过此限制
- IndexedDB 支持索引和高效查询,适合大量结构化数据
4.2 离线操作队列
当网络不可用时,用户操作会被记录到离线队列中:
class OfflineQueue {
async enqueue(operation) {
const entry = {
id: crypto.randomUUID(),
type: operation.type, // 'create' | 'update' | 'delete'
data: operation.data,
timestamp: Date.now(),
retryCount: 0
};
await this.saveToIndexedDB(entry);
}
async processQueue() {
const queue = await this.getAll();
for (const item of queue) {
try {
await this.executeOperation(item);
await this.removeFromQueue(item.id);
} catch (error) {
if (error.status === 401) {
// Token 过期,需要刷新
await this.refreshToken();
}
item.retryCount++;
if (item.retryCount > 3) {
await this.markAsFailed(item.id);
}
}
}
}
}
五、隐私锁功能
5.1 实现方案
隐私锁允许用户将敏感任务列表加锁隐藏,需要输入密码才能查看:
// 密码加密存储
async function lockList(listId, password) {
const salt = crypto.getRandomValues(new Uint8Array(16));
const key = await deriveKey(password, salt);
// 加密列表数据
const data = JSON.stringify(await getTasks(listId));
const encrypted = await encrypt(key, data);
// 存储加密数据和盐值
await chrome.storage.local.set({
[`locked_${listId}`]: {
encrypted: arrayBufferToBase64(encrypted),
salt: arrayBufferToBase64(salt),
lockedAt: Date.now()
}
});
// 删除明文数据
await deleteTasksFromIndexedDB(listId);
}
// 使用 PBKDF2 派生密钥
async function deriveKey(password, salt) {
const encoder = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
'raw', encoder.encode(password), 'PBKDF2', false, ['deriveKey']
);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations: 100000, hash: 'SHA-256' },
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
六、性能优化
6.1 大量任务的渲染优化
当任务数量超过 500 条时,列表渲染会出现卡顿。采用了以下优化措施:
- 虚拟滚动:只渲染可视区域内的任务项,DOM 节点数量保持在 50 个以内
- 防抖搜索:搜索输入使用 300ms 防抖,避免每次按键都触发全量过滤
- Web Worker:将思维导图的布局计算移到 Worker 线程,避免阻塞主线程
6.2 内存管理
// 定期清理过期的 Session 数据
chrome.alarms.create('cleanup', { periodInMinutes: 30 });
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name === 'cleanup') {
await clearExpiredSessions();
await compactIndexedDB();
}
});
七、总结与经验
开发 MS-ToDo 插件的过程中,几个重要的经验教训:
- 同步是最难的部分:双向同步的边界情况非常多(并发修改、网络中断、Token 过期),需要充分测试各种异常场景
- 离线优先不是说说而已:一旦承诺离线可用,每个功能都要考虑离线场景下的行为
- Manifest V3 的 Service Worker 有坑:Service Worker 会被浏览器休眠,长时间后台任务需要特殊处理
- 用户体验决定留存:功能再强大,如果操作不流畅,用户也不会留下来
相关链接:
- MS-ToDo 插件下载:Chrome 应用商店搜索 “MS-ToDo”
- Microsoft Graph API 文档:https://learn.microsoft.com/en-us/graph/api/
- Chrome 扩展开发文档:https://developer.chrome.com/docs/extensions/