排查一个 bug:用户连续新增两条明细,第二条把第一条“覆盖”了。代码一看,行 id 用的是 new Date().getTime(),同一毫秒连续生成了两次,两条记录 id 完全相同,列表渲染时 key 冲突,界面直接错乱。同事当时很委屈:“前端生成 id,我看了很多文章,大家都这么写啊。”
其实前端生成 id 这件事,难点真不在怎么写,而在“生成的 id 给谁用、活多久、要不要跟后端对齐”。同样是十几行代码,用在列表 key 上可以怎么简单怎么来,用于本地存储记录就必须持久稳定,一旦要参与接口对齐,甚至该用哪套算法、什么格式都要重新想。这篇文章我就把前端生成 id 的完整思路、可落地的工具函数、常见的坑一次讲清楚,适合正在做 React/Vue 项目,或者想系统梳理这个小知识点的前端开发者。
1. 先判断这个 id 要给谁用,再做方案选型
1.1 只活在当前页面的列表 key
前端最常见的 id 需求,其实是列表渲染时给虚拟节点当 key。React 的 diff、Vue 的 vdom patch 都需要一套稳定的 key 来区分兄弟节点。这个阶段对 id 的唯一性要求很低,低到什么程度?只要“当前这一屏数据里不重复,并且一次渲染周期内没变化”就够了。
很多老项目图省事直接用数组下标当 key,静态列表其实没问题。可一旦列表支持删除、排序,或者每一行内部有输入框、复选框这类带状态的组件,下标 key 就会出现非常隐蔽的 bug。比如删掉第一行之后,原来第二行的输入框状态可能被“继承”给第一行,因为组件复用了,key 变了但 index 没被识别出来。
我建议的写法仍是给每条数据一个独立 id,但这个 id 不需要持久化,也不需要高强度随机。它更像一个“临时工号”,只要能撑过浏览器当前 tab 的生命周期,甚至撑过这一轮组件状态就够了。生成时机应该放在数据创建那一刻,而不是渲染函数里;如果每次 render 都现场去 new 一个 id,等于组件每次渲染都会拿到新 key,轻则触发重挂载,重则输入框失焦、图片闪烁。
1.2 要存进 localStorage 的本地主键
等数据要落进 localStorage、IndexedDB 或者做离线草稿时,id 的性质就变了。它不再是“临时工号”,而是“本机数据库主键”。之后你要做更新、删除、查重,都得靠它找到那条记录。
这种情况下有一个原则:id 必须在创建记录时就确定,并且随整条记录一起保存,刷新页面后读回来,原样还给数据本身。常见的错误是页面加载后又重新 crypto.randomUUID() 一把,把旧数据里的 id 全部覆盖掉,那后续按 id 删除就永远删不干净——因为旧数据已经是你“找不到”的对象了。
如果你打算让前端在本地维护一个数组,比如待办清单、草稿箱,那么 id 最合适的位置是数组元素的自带字段:
js复制const todo = {
id: createId('todo'),
text: '写完这篇前端生成 id 的总结',
done: false,
createdAt: Date.now(),
};
id 跟着对象走,而不是单独维护一份字段目录,尤其在删除、批量操作时最不容易出差错。
1.3 最终要交给后端对齐的临时 id
还有一种场景经常被忽略:前端先生成了一条记录,用户离线操作或做了乐观更新,需要“等网络恢复后再同步给后端”。这时候前端拿出的 id 通常不是后端数据库主键,只是一个客户端临时标识,用于两边对账。
常见的做法是请求体里同时带 clientId,后端处理完后返回 serverId 和原来的 clientId,前端用 clientId 找到本地对应记录,再把它替换成服务端真正的主键:
js复制// 请求前先把本地记录标记为 pending
const localTodo = {
id: createId('pending'),
text: '同步数据',
status: 'pending',
};
// 请求返回后对账
function onSyncSuccess(res) {
const { clientId, serverId } = res;
const localItem = todos.find(t => t.id === clientId);
if (localItem) {
localItem.id = serverId;
localItem.status = '
