排查一个 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 = 'synced';
}
}
这样操作的好处是,服务器始终拥有数据库主键生成的最终解释权,前端 id 只是一个过渡标识,不用被塞进数据库里当成唯一键。所以当你纠结“前端到底生成 uuid 还是短 code”之前,先搞清楚这条 id 的最终归属,再决定用哪套算法,会省掉后面一堆脏活。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 四种前端 id 生成方案,从代码到取舍一次讲清
2.1 时间戳:最直观,但别单一使用
Date.now() 是前端新人最容易上手的 id:短、可读、基本能反映先后顺序。但问题也很明显——同一毫秒内连续调用,返回值完全一样。前端单线程不代表不会在同一毫秒连续操作两次,按钮双击、批量新增、循环创建都是重灾区。
把时间戳 toString(36) 转成短字符串并不会提高唯一性,它只是把数字换成字母。如果你在循环里生成 1000 个 id,其中可能有一大批共用了同一个毫秒时间戳。
时间戳更合理的定位是“id 的一部分”,不是整个 id。比如给时间戳拼上随机串,或者拼上页面内计数器,都能解决时间粒度不够的问题。如果只是需要展示顺序,完全可以把 createdAt 单独存一个字段,id 仍然用随机方案。
2.2 Math.random 的惯用写法:适合临时场景
网上最常见的“前端生成 id”答案多半长这样:
js复制Math.random().toString(36).slice(2, 10);
它把 Math.random() 生成的 0 到 1 之间的小数转成 36 进制字符串,再截掉开头的 0.。优点是代码短、不需要引入任何库,缺点也有三个:
Math.random()不是密码学安全随机数,不适合用在邀请码、口令这类需要防伪造的场景。- 生成的是小数转字符串,长度可能不稳定;如果在小数点后遇到 0,截取出来还可能整体变短。
- 它最多只能表达
2^53量级的随机值,对于大量生成来说不是理论上的“不可撞”,只是概率高低问题。
放在列表 key 这种只需要几十、几百个不重复的临时场景,它够用。但如果你告诉我这条记录要进 localStorage,要跨会话使用,我会建议直接往下看基于 crypto.getRandomValues 的方案。 这里没有说 Math.random 一定会撞,而是它把随机性的“天花板”定得太低了。
2.3 crypto.randomUUID:现代标准答案
如果你不需要短码,也没有传递语义,只是想生成一个标准的唯一标识,crypto.randomUUID() 是今天最省心的选择:
js复制const uid = crypto.randomUUID();
// 形如:a6d3f4bb-4e67-4a36-9f12-9b1a2c3d4e5f
它按 UUID v4 规范生成,其中包含 122 位随机数。格式是固定的 8-4-4-4-12,非常适合直接作为记录主键、请求对账 key 等。
它的生成由浏览器底层调用系统熵源,不靠 Math.random 这种伪随机算法,所以安全性、均匀性都更可控。在 HTTPS 或 localhost 环境下基本是标配能力;老的 WebView、某些小程序内置浏览器可能不支持,需要一节里的兼容函数兜底。
2.4 自研短码型 ID:可控长度,可控可读性
UUID 太长,放在 URL 或者界面展示不太友好。短码型 id 适合给前端本地数据做 key,也适合生成类似订单号、协作邀请码这种“需要一眼看出不是普通序号”的标识。
短码的核心就一句话:从自定义字符集里按随机均匀的方式抽出 N 个字符。 常见的字符集是 10 个数字 + 26 个小写字母 + 26 个大写字母,共 62 个字符。少一些字符虽然长度要变长,但可以减少混淆,比如去掉 0/O/1/l/I,做人工输入时更友好。
如果只要求它“够用”,可以用 crypto.getRandomValues 取随机字节再模上字符总数。不过要注意,当字符总数不是 256 的约数时,简单的取模会产生轻微概率偏差。后面我会给出消除偏差的实现。
先看四类方案横向比较:
| 方案 | 典型长度 | 是否可排序 | 安全性 | 推荐使用范围 |
|---|---|---|---|---|
Date.now() |
13 位数字 | 可按时间 | 不涉及 | 绝不能单独作为唯一 key |
Math.random().toString(36) |
8 位左右 | 否 | 弱 | 临时列表 key |
crypto.randomUUID() |
36 字符 | 否 | 强 | 本地持久化主键、同步对账 |
| 自定义短码 | 可控 | 看实现 | 中到强 | URL、邀请码、前端友好的记录 key |
3. 一个可以直接用的 generateId 小工具
3.1 randomUUID 兼容与“非标准环境”兜底
浏览器环境里,优先用原生方法。我把一个常见的工具函数整理成这样:
js复制function generateUUID() {
// 1. 原生优先
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
// 2. 如果没有 randomUUID,但有 getRandomValues,就手工按 UUID v4 拼
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
// 设置版本号为 4
bytes[6] = (bytes[6] & 0x0f) | 0x40;
// 设置变体为 10xx
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
// 3. 极老环境兜底,只能临时用,不适合高并发场景
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
这里第 2 步很重要:不是把 16 个随机字节直接转成字符串就完事。UUID v4 要求第 7 个字节的高四位固定成 0100(版本号 4),第 9 个字节的高两位固定成 10(变体)。照这个格式拼出来的才是合法 UUID。如果漏掉位设置,你生成的只是“长得像 UUID 的随机串”,某些严格校验字符串格式的服务端会直接拒收。
还需要说明,第三步的 Math.random 兜底只能保证“页面不崩”,不能保证长期唯一,更不能用于任何对安全性有要求的场景。生产环境里如果检测到浏览器不支持 crypto.randomUUID,尽量早点打日志或接一个 polyfill,不要默默降级让用户带着低强度 id 用上几个月。
3.2 避免短 ID 的模偏差并支持前缀
短码实现时,最常见的是下面这种写法:
js复制function shortId(length = 12) {
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
const bytes = new Uint8Array(length);
crypto.getRandomValues(bytes);
let result = '';
for (let i = 0; i < length; i++) {
result += alphabet[bytes[i] % alphabet.length];
}
return result;
}
代码能跑,但 bytes[i] % alphabet.length 有一个隐蔽的均匀性问题。alphabet.length 是 62,不是 256 的整数倍,所以 0 到 255 的字节在取模后,前几个余数出现的概率比后面几个略高。对业务 id 来说影响微乎其微,但如果你的 id 要被用作防猜测的标识,我建议还是用拒绝采样:
js复制const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
// 256 对 62 取模后是 8,这里只接受 0~247,丢弃 248~255
const MAX_ACCEPT = 256 - (256 % ALPHABET.length);
function secureShortId(length = 12, prefix = '') {
const bytes = new Uint8Array(length);
const randomIndexes = new Uint8Array(length);
const tmp = new Uint8Array(1);
for (let i = 0; i < length; i++) {
// 拒绝采样,保证每个字符出现概率尽量均匀
do {
crypto.getRandomValues(tmp);
} while (tmp[0] >= MAX_ACCEPT);
randomIndexes[i] = tmp[0] % ALPHABET.length;
}
let result = '';
for (let i = 0; i < length; i++) {
result += ALPHABET[randomIndexes[i]];
}
return prefix ? `${prefix}_${result}` : result;
}
实际业务里你还会遇到一个前缀需求。比如生成动画任务 id、草稿记录 id、上传文件分组 id,我希望日志里一眼能猜到是哪类:
js复制const uploadTaskId = secureShortId(16, 'upload');
// upload_1a2b3c4d5e6f7g8h
const todoId = secureShortId(12, 'todo');
// todo_2b3c4d5e6f7g
加前缀不会提高随机性,但会大幅提高可读性。排查问题时,看到 upload_ 开头立刻知道是上传任务,看到 todo_ 知道是本地待办。这个习惯我强推,代价只是多了几个字符。
如果你还需要按创建时间粗略排序,可以在前端生成时再拼一段可排序前缀:
js复制function sortableId(prefix = '') {
const timePart = Date.now().toString(36).padStart(8, '0');
const randomPart = secureShortId(8);
return prefix ? `${prefix}_${timePart}${randomPart}` : `${timePart}${randomPart}`;
}
注意时间前缀要补足位数后再拼接,否则字符串排序会出问题:10 进制的 99 排 100 前面,36 进制同理。更好的做法仍然是单独记录 createdAt 字段,排序完全交给它,id 只负责唯一。
4. 完整示例:按 id 删除 localStorage 里的记录
4.1 数据应该存成“一个 key 一个数组”,还是“每个 id 一个 key”
网上搜“根据 id 删除 localStorage 数据”的开发者,多半已经踩到了另一个坑:数据不知道按什么结构存的,删除时自然也无从下手。localStorage 只能存字符串,没有原生索引,所以你必须自己决定结构。
两种主流的存法:
- 一种是一个 key 下面存一整段 JSON 数组,所有记录都放里面。
- 另一种是每条记录单独占一个 key,比如
draft:xxxxx、todo:xxxxx。
一个数组一个 key 的好处:整体读写方便,排序、迁移、批量操作都集中在同一段代码里。缺点是每次增删改都要把整个数组读出来、改完、写回去;记录达到几千条以上后,解析和序列化成本会明显增加。
每个 id 一个 key 的好处:删除时直接 removeItem(key),修改时只读写一条,不用碰其他记录。缺点是“列出所有记录”时要用 Object.keys(localStorage) 过滤前缀,并且不同 key 之间的顺序无法天然维护。
如果是前端草稿、待办这类中小型数据,我更推荐数组方案。它好维护、不容易出现 key 前缀冲突,写出来的增删改逻辑也更好懂。
4.2 用“生成 id → 存数组 → 按 id 增删改”跑通整个链路
下面是一个可以直接改造成项目代码的本地待办示例。先封装读写:
js复制const STORAGE_KEY = 'todos';
function readTodos() {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
try {
const data = JSON.parse(raw);
return Array.isArray(data) ? data : [];
} catch (e) {
console.warn('本地数据解析失败,已返回空列表', e);
return [];
}
}
function writeTodos(list) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
}
新增时,id 要在记录对象创建那一刻生成:
js复制function addTodo(text) {
const todo = {
id: secureShortId(12, 'todo'),
text,
done: false,
createdAt: Date.now(),
};
const list = readTodos();
list.unshift(todo);
writeTodos(list);
return todo;
}
按 id 删除是核心需求,不能靠数组下标,否则删除 A 后,原本后面的元素全部“前移”,下一次想删 B 就会删错:
js复制function deleteTodoById(targetId) {
const list = readTodos();
const next = list.filter(todo => todo.id !== targetId);
writeTodos(next);
}
按 id 更新状态同样简单:
js复制function toggleTodoById(targetId) {
const list = readTodos();
const next = list.map(todo => {
if (todo.id !== targetId) return todo;
return { ...todo, done: !todo.done };
});
writeTodos(next);
}
这几段代码看起来很基础,但实际项目里最容易出问题的就是:id 在新增时没有生成、删除时用 index、更新时直接改原数组后忘记写回 localStorage。这三件事分别对应“没有主键”“主键不对”“没持久化”三种独立问题,建议一次写到位。
如果数据真的拆成了 todo_xxx 这种单条 key,按 id 删除会变成:
js复制function deleteTodoBySeparateKey(targetId) {
localStorage.removeItem(`todo_${targetId}`);
}
这时 id 本身不能包含特殊字符,否则 key 前缀过滤容易出现误伤。这也是我前面推荐短码场景用 ${prefix}_${random} 的原因:只留一个下划线,解析 key 时按下划线切分不会出太大问题。
最后提醒一句:localStorage 不适合放敏感数据。同源下任何脚本都能读取,只要页面被注入了 XSS 脚本,token、手机号、身份证这些放在 localStorage 里就相当于裸奔。本地草稿、界面偏好这类可以放;登录凭证应该交给后端用更安全的 Cookie 策略处理,而不是前端拿着一个 uuid 当地址簿。
5. 复盘几个我在真实页面里遇到的“id 事故”
5.1 渲染函数里现场生成 key,输入框疯狂失焦
先说一个我见得最多的错误写法,在 React 或 Vue 组件内部现场生成 key:
jsx复制{
list.map((item) => (
<
