📌 一个普遍但经常被忽略的场景:
无论你是构建一个:
- 企业级中后台管理系统
- 移动端 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 };
});
}
🧠 通用应用场景
这不仅适用于中后台,而适用于几乎所有需要“本地编辑状态”的系统:
| 场景 | 举例 |
|---|---|
| 表单草稿 | 用户多步填写,后台随时可能刷新状态 |
| 图形/建模工具 | 本地组件拖拽中,后端状态变更 |
| 游戏设置界面 | 用户尚未点击“确认”,但服务器推送参数刷新 |
| 协作系统 | 当前编辑和团队协作修改并存 |
| 表格/列表 | 新增一行、拖拽排序中,接口更新触发刷新 |
🧩 建议封装成系统级别的“状态合并工具库”
命名建议如:
mergeStateWithRemotesafeMergeByKeypreserveUserInput
并明确三大目标:
- 新增项永不被吞
- 修改字段用户优先
- 可控顺序 + 可配置字段粒度
🛠 实践中的两个典型封装函数
在实际项目中,我们通常会封装成以下两种:
✅ 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 | ❌ 忽略 | ✅ 补丁逻辑 | 切换视图时保留局部编辑字段、纯接口更新 |
✍️ 结语:真正的控制权,在于如何处理“两个世界的数据”
在数据驱动的前端开发中:
- 是接口说了算,还是用户说了算?
- 是每次刷新都重置,还是用户操作优先?
- 是字段级别合并,还是对象级覆盖?
我们常说“用户体验优先”,但真正的优先是:你是否允许用户的输入成为系统状态的主导。在面对远程数据刷新与用户本地修改时,合并策略就是你给出的答案。写好这类“状态同步逻辑”,不是技术炫技,而是对用户行为的尊重。