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 };
      }
    });
  }
}

关键设计决策

  1. 操作级同步:只同步变更的操作(创建/更新/删除),而非全量数据,大幅减少 API 调用
  2. 离线队列:离线时的操作记录在本地队列中,恢复网络后按顺序提交
  3. 冲突处理:基于 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 条时,列表渲染会出现卡顿。采用了以下优化措施:

  1. 虚拟滚动:只渲染可视区域内的任务项,DOM 节点数量保持在 50 个以内
  2. 防抖搜索:搜索输入使用 300ms 防抖,避免每次按键都触发全量过滤
  3. 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 插件的过程中,几个重要的经验教训:

  1. 同步是最难的部分:双向同步的边界情况非常多(并发修改、网络中断、Token 过期),需要充分测试各种异常场景
  2. 离线优先不是说说而已:一旦承诺离线可用,每个功能都要考虑离线场景下的行为
  3. Manifest V3 的 Service Worker 有坑:Service Worker 会被浏览器休眠,长时间后台任务需要特殊处理
  4. 用户体验决定留存:功能再强大,如果操作不流畅,用户也不会留下来

相关链接