别让“自动刷新”毁掉用户心血:聊聊数据丢失那些坑

242 阅读5分钟

📌 一个普遍但经常被忽略的场景:

无论你是构建一个:

  • 企业级中后台管理系统
  • 移动端 App 的购物车界面
  • 可视化建模/低代码平台
  • 数据驱动型游戏 UI
  • 多步操作表单 / 向导流程

只要你的系统涉及到:

✅ 用户在客户端编辑了某些数据
✅ 同时又会从服务端拉取这类数据的“最新版本”
✅ 并希望两者融合,用户输入不丢失

你就极有可能中招:

🔥 用户新增的数据或未提交的修改被“拉新操作”悄悄吞掉了!

一个常见场景是编辑富文本说明,如:

  • 后台运营填写商品详情、活动说明、用户协议等;
  • 用户在编辑区中写了一大段内容;
  • 此时他们点击了“查看另一个商品”或刷新按钮;
  • 系统重新拉了数据并替换表单值;
  • 用户输入的内容没有本地缓存,也没有弹窗提示。

最终结果就是:

❌ 富文本内容瞬间被“远程数据”覆盖,用户失去所有编辑进度。

这类问题最容易在以下情境中高频出现:

触发行为描述
点击切换记录切换当前编辑对象但无未保存提示
页面被 keep-alive 销毁又挂载再次拉新数据时,误清空用户操作中内容
后台定时刷新数据出于“保证时效”而忽略了用户的未提交状态
富文本控件未绑定本地缓存状态没有绑定 v-model,或没做本地备份机制

❗️这个问题的本质不是“谁覆盖谁”,而是“我们到底以谁为主?”

大多数系统的错误合并逻辑是这样的:

const newData = apiData.map(item => {
  const cacheItem = cacheList.find(c => c.id === item.id);
  return cacheItem ? { ...item, ...cacheItem } : item;
});

这段代码的暗含逻辑是:

以接口返回的数据为“主表”,用户输入只是“补丁”。

但问题是:

  • 用户新增的数据根本没有 ID,所以压根不会出现在接口返回列表中。
  • 于是 map() 时直接被忽略 → 用户看着刚填的东西不见了。

✅ 站在抽象层面来看,这是「双数据源一致性同步」的问题

两个状态源:

  • 服务端下发的 “远程权威数据”
  • 客户端本地缓存的 “用户当前意图”

我们需要做到的是:

  • 字段更新 → 按字段级别 merge,优先保留用户值
  • 新增数据 → 完全保留用户本地结构
  • 顺序可控 → merge 后的列表仍满足展示顺序、状态约定

✅ 正确的思维模式是:“以用户状态为主、拉新为辅助”

工程上可以抽象为:

function mergeUserStateWithRemote<T>(
  userList: T[],
  serverList: T[],
  key: keyof T,
  fieldsToPreserve: (keyof T)[]
): T[] {
  const serverMap = new Map(serverList.map(item => [item[key], item]));

  return userList.map(userItem => {
    const serverItem = serverMap.get(userItem[key]);
    const base = serverItem ? { ...serverItem } : {};
    fieldsToPreserve.forEach(field => {
      if (userItem[field] !== undefined) {
        base[field] = userItem[field];
      }
    });
    return { ...base, ...userItem };
  });
}

🧠 通用应用场景

这不仅适用于中后台,而适用于几乎所有需要“本地编辑状态”的系统:

场景举例
表单草稿用户多步填写,后台随时可能刷新状态
图形/建模工具本地组件拖拽中,后端状态变更
游戏设置界面用户尚未点击“确认”,但服务器推送参数刷新
协作系统当前编辑和团队协作修改并存
表格/列表新增一行、拖拽排序中,接口更新触发刷新

🧩 建议封装成系统级别的“状态合并工具库”

命名建议如:

  • mergeStateWithRemote
  • safeMergeByKey
  • preserveUserInput

并明确三大目标:

  1. 新增项永不被吞
  2. 修改字段用户优先
  3. 可控顺序 + 可配置字段粒度

🛠 实践中的两个典型封装函数

在实际项目中,我们通常会封装成以下两种:

mergeCurrencyListWithAppend:保留用户输入 + 合并远程数据(新增项优先)

适合用户可新增项 + 字段需用户优先 + 接口拉全量覆盖的情况:

/**
 * 通用合并函数(保留新增 + 字段覆盖)
 * @param {Array} apiList 接口返回的最新数据
 * @param {Array} userList 页面中已有数据(用户输入)
 * @param {Array} fields 需要保留的字段列表
 * @param {String} key 唯一标识字段名
 * @returns {Array} 合并后的完整数据
 */
function mergeCurrencyListWithAppend(apiList, userList, fields = [], key = 'id') {
  const resultMap = new Map();

  // 1. 接口数据优先放入 map
  for (const apiItem of apiList) {
    resultMap.set(apiItem[key], { ...apiItem });
  }

  // 2. 用户数据合并字段或新增项
  for (const userItem of userList) {
    const id = userItem[key];
    if (resultMap.has(id)) {
      const merged = resultMap.get(id);
      for (const f of fields) {
        if (userItem[f] !== undefined) {
          merged[f] = userItem[f]; // 用户值优先
        }
      }
      resultMap.set(id, merged);
    } else {
      // 完全新增项
      resultMap.set(id, { ...userItem });
    }
  }

  return Array.from(resultMap.values());
}

mergeCachedFields:用于字段级“打补丁”逻辑,保留部分用户编辑

适合用户不能新增项,仅修改已存在项的某些字段场景:

/**
 * 通用缓存合并函数
 * @param {Array} newList 接口返回的新数据
 * @param {Array} cachedList 页面已存在的数据(缓存)
 * @param {Array} fields 需要保留的字段名
 * @param {String} key 唯一标识字段名,默认 'id'
 * @returns {Array} 合并结果(仅字段补丁)
 */
function mergeCachedFields(newList, cachedList, fields = [], key = 'id') {
  if (!Array.isArray(newList) || !Array.isArray(cachedList)) return newList;

  const cacheMap = new Map();
  cachedList.forEach(item => {
    if (item[key] !== undefined && item[key] !== null) {
      cacheMap.set(item[key], item);
    }
  });

  return newList.map(item => {
    const cached = cacheMap.get(item[key]);
    if (!cached) return item;

    const merged = { ...item };
    fields.forEach(f => {
      if (cached[f] !== undefined) {
        merged[f] = cached[f];
      }
    });
    return merged;
  });
}

这两个函数的定位如下:

函数名用户新增项字段保留应用典型
mergeCurrencyListWithAppend✅ 保留✅ 用户优先表格新增行、行内编辑、配置面板
mergeCachedFields❌ 忽略✅ 补丁逻辑切换视图时保留局部编辑字段、纯接口更新

✍️ 结语:真正的控制权,在于如何处理“两个世界的数据”

在数据驱动的前端开发中:

  • 是接口说了算,还是用户说了算?
  • 是每次刷新都重置,还是用户操作优先?
  • 是字段级别合并,还是对象级覆盖?

我们常说“用户体验优先”,但真正的优先是:你是否允许用户的输入成为系统状态的主导。在面对远程数据刷新与用户本地修改时,合并策略就是你给出的答案。写好这类“状态同步逻辑”,不是技术炫技,而是对用户行为的尊重。