多笔记间快速切换自动保存竞态条件问题及解决方案

0 阅读3分钟

概述

在笔记类应用中,自动保存功能很重要了不用说,产品经理都喜欢设计这个功能。我们很容易想到要用防抖节流来优化性能,然而,当用户快速切换编辑对象时,异步请求的竞态条件往往会导致数据丢失或不一致问题。接下来我将采用具体场景来描述问题

问题场景分析

用户操作

  1. 用户修改笔记A:内容从 'aaa' 修改为 'aaa111',触发自动保存请求 P_A
  2. 快速切换到笔记B:此时 P_A 请求仍在 pending 状态
  3. 快速切回笔记A:重新获取笔记详情,由于 P_A 未完成,获取到的仍是旧数据 'aaa'
  4. 用户继续编辑:基于 'aaa' 输入 '222',页面显示 'aaa222'
  5. P_A 请求完成:后端保存了 'aaa111'

问题核心:此时客户端显示 'aaa222',但服务端已保存 'aaa111',两个修改都基于 'aaa' 但互不兼容。

传统方案的局限性

传统的防抖自动保存方案主要存在以下问题:

  • 数据覆盖风险:服务端返回数据直接覆盖本地状态
  • 用户输入丢失:快速切换时未提交的修改可能丢失
  • 状态不一致:客户端与服务端数据不同步

解决方案设计

核心策略

我们采用本地缓存 + 切换前强制保存 + 智能数据合并的组合策略:

  1. 立即本地缓存:每次用户输入立即缓存到本地
  2. 切换前强制保存:笔记切换前自动保存当前修改
  3. 智能数据加载:加载时优先使用本地缓存数据
  4. 保存后清理:API 成功后清除对应缓存

架构设计

graph TB
    A[用户输入] --> B[本地状态更新]
    B --> C[本地缓存更新]
    C --> D[防抖保存触发]
    
    E[笔记切换] --> F{检查是否有未保存修改}
    F -->|是| G[强制保存]
    F -->|否| H[直接切换]
    G --> H
    
    H --> I[加载新笔记]
    I --> J{检查本地缓存}
    J -->|有缓存| K[合并缓存数据]
    J -->|无缓存| L[使用服务端数据]
    K --> M[更新UI]
    L --> M
    
    D --> N[API调用]
    N -->|成功| O[清除本地缓存]
    N -->|失败| P[保留本地缓存]

实现方案

1. Store 层改造

在 Zustand Store 中新增本地编辑缓存管理:

// 新增状态
interface NotesStoreState {
  // ... 现有状态
  
  // 本地编辑缓存,防止快速切换时丢失数据
  localEditCache: Record<number, { 
    title?: string; 
    content?: string; 
    timestamp: number 
  }>;
  
  // 缓存管理方法
  updateLocalEditCache: (noteId: number, updates: { title?: string; content?: string }) => void;
  clearLocalEditCache: (noteId: number) => void;
  getLocalEditCache: (noteId: number) => { title?: string; content?: string; timestamp: number } | null;
}

2. 智能数据加载

改造 fetchAndSetActiveNoteDetail 方法,优先使用本地缓存:

fetchAndSetActiveNoteDetail: async (noteId: number) => {
  set({ isDetailLoading: true });
  
  try {
    const response = await getNoteDetailService(String(noteId));
    
    if (!isResponseSuccess(response)) {
      throw new Error(response.error || '获取笔记详情失败');
    }

    let storeNoteDetail = transformApiNoteToStoreNote(response.data) as NoteDetail;
    
    // 检查是否有本地未提交的编辑缓存
    const { localEditCache } = get();
    const localEdit = localEditCache[noteId];
    
    if (localEdit) {
      // 如果有本地编辑缓存,优先使用本地数据
      storeNoteDetail = {
        ...storeNoteDetail,
        title: localEdit.title ?? storeNoteDetail.title,
        content: localEdit.content ?? storeNoteDetail.content,
      };
      console.log(`笔记 ${noteId}: 应用本地编辑缓存`);
    }
    
    set({ activeNoteDetail: storeNoteDetail, isDetailLoading: false });
  } catch (error) {
    console.error('获取笔记详情失败:', noteId, error);
    set({ isDetailLoading: false, activeNoteDetail: null });
  }
}

3. 改进的保存 Hook

修改 useUnifiedNoteSave Hook,增加立即缓存功能:

const save = useCallback((updates: { title?: string; content?: string }) => {
  pendingChangesRef.current = { ...pendingChangesRef.current, ...updates };
  
  // 立即更新本地编辑缓存,确保快速切换时不丢失数据
  if (note?.id) {
    updateLocalEditCache(note.id, pendingChangesRef.current);
    setCurrentNoteDirty(true);
  }
  
  debouncedSave();
}, [debouncedSave, note?.id, updateLocalEditCache, setCurrentNoteDirty]);

4. 切换前强制保存

创建 useNoteSwitch Hook 管理笔记切换:

export const useNoteSwitch = () => {
  const { setActiveNote, isCurrentNoteDirty } = useNotesStore();
  const flushFunctionRef = useRef<FlushFunction | null>(null);

  // 注册 flush 函数
  const registerFlush = useCallback((flush: FlushFunction) => {
    flushFunctionRef.current = flush;
  }, []);

  // 切换笔记的方法
  const switchToNote = useCallback(async (noteId: number | null, options?: { shouldScroll?: boolean }) => {
    // 如果当前笔记有未保存的修改,先强制保存
    if (isCurrentNoteDirty && flushFunctionRef.current) {
      console.log('切换笔记前强制保存当前笔记');
      try {
        await flushFunctionRef.current();
      } catch (error) {
        console.error('强制保存失败:', error);
        // 即使保存失败,也继续切换笔记,避免阻塞用户操作
      }
    }
    
    // 执行笔记切换
    setActiveNote(noteId, options);
  }, [setActiveNote, isCurrentNoteDirty]);

  return { registerFlush, switchToNote };
};

5. UI 组件集成

在编辑器组件中注册 flush 函数并使用新的切换方法:

// 文本笔记编辑器
export default function TypeTextNote() {
  const { activeNoteDetail } = useNotesStore();
  const note = activeNoteDetail;

  // 统一保存Hook
  const { save, flush } = useUnifiedNoteSave({ note });
  
  // 笔记切换Hook
  const { registerFlush } = useNoteSwitch();

  // 注册 flush 函数到切换钩子
  useEffect(() => {
    registerFlush(flush);
  }, [flush, registerFlush]);

  // ... 其他组件逻辑
}

// 笔记列表项
const handleClick = async () => {
  console.log('点击笔记项:', noteId, title)
  
  // 使用 switchToNote 切换笔记,自动处理保存
  await switchToNote(noteId)
  setEditorLayout(true, null)
}

流程图

完整的自动保存流程

sequenceDiagram
    participant User as 用户
    participant UI as UI组件
    participant Hook as useUnifiedNoteSave
    participant Cache as 本地缓存
    participant Store as Zustand Store
    participant API as 后端API

    User->>UI: 输入内容
    UI->>Hook: save({ content: "新内容" })
    Hook->>Cache: 立即缓存
    Hook->>Store: updateLocalEditCache
    Hook->>Hook: 触发防抖保存

    Note over Hook: 2秒后防抖结束

    Hook->>Store: updateNoteDetails
    Store->>API: 发送保存请求
    
    User->>UI: 快速切换笔记
    UI->>Hook: registerFlush
    Hook->>Store: 强制保存当前修改
    UI->>Store: switchToNote(newNoteId)
    
    Store->>API: 获取新笔记详情
    API-->>Store: 返回笔记数据
    Store->>Cache: 检查本地缓存
    
    alt 有本地缓存
        Store->>Store: 合并缓存数据
    else 无本地缓存
        Store->>Store: 使用服务端数据
    end
    
    Store->>UI: 更新界面
    
    API-->>Store: 保存请求成功
    Store->>Cache: 清除对应缓存

竞态条件处理流程

image.png

核心代码示例

本地缓存管理

// Store中的缓存管理方法实现
const notesStore = {
  // 状态
  localEditCache: {},
  
  // 更新本地编辑缓存
  updateLocalEditCache: (noteId: number, updates: { title?: string; content?: string }) => {
    set(state => ({
      localEditCache: {
        ...state.localEditCache,
        [noteId]: {
          ...state.localEditCache[noteId],
          ...updates,
          timestamp: Date.now(),
        }
      }
    }));
  },

  // 清除特定笔记的本地编辑缓存
  clearLocalEditCache: (noteId: number) => {
    set(state => {
      const newCache = { ...state.localEditCache };
      delete newCache[noteId];
      return { localEditCache: newCache };
    });
  },

  // 保存成功后清除缓存
  updateNoteDetails: async (noteId: number, updates: { title?: string; content?: string }) => {
    set({ isSaving: true });
    
    try {
      // ... API调用逻辑
      const response = await updateNoteWithChanges(noteId, currentDetail, updates, hasChanges);
      
      // 更新状态
      set(state => ({
        notes: state.notes.map(note => 
          note.id === noteId ? { ...note, ...updatedStoreNote } : note
        ),
        activeNoteDetail: state.activeNoteId === noteId 
          ? { ...state.activeNoteDetail, ...updatedStoreNoteDetail } 
          : state.activeNoteDetail,
        isSaving: false,
        isCurrentNoteDirty: false,
      }));

      // 保存成功后清除本地编辑缓存
      get().clearLocalEditCache(noteId);
      return updatedStoreNote;
    } catch (error) {
      console.error(`更新笔记 ${noteId} 详情失败:`, error);
      set({ isSaving: false });
      return null;
    }
  }
};

方案优势

1. 用户体验

  • 无感知保存:用户输入时立即响应,无需等待
  • 数据不丢失:快速操作时本地缓存确保数据安全
  • 操作流畅:切换时自动处理保存,无需用户干预

2. 技术优势

  • 简单可靠:相比复杂的时间戳冲突检测,实现更简洁
  • 向后兼容:不破坏现有API调用逻辑
  • 易于维护:清晰的职责分离,便于调试和扩展

3. 健壮性

  • 容错处理:保存失败时保留本地数据,支持重试
  • 状态一致:通过缓存确保UI状态与用户期望一致
  • 并发安全:有效处理快速操作的竞态条件

总结

这种解决方案通过引入本地编辑缓存、切换前强制保存和智能数据合并等策略,有效解决了笔记应用中的自动保存竞态条件问题。

关键要点:

  • 立即缓存确保数据不丢失
  • 智能合并优先使用用户最新修改
  • 强制保存避免切换时的数据丢失
  • 清理机制维护缓存状态一致性

这套方案我已经在实际项目中验证过啦,确实能够有效处理用户快速操作场景,能提供稳定可靠的自动保存体验。供小伙伴们参考一二~