一篇看懂 JS 生成 UUID 的四种方案与选型

发布时间:2026/10/15 2:53:06
一篇看懂 JS 生成 UUID 的四种方案与选型
做前端这么久要说哪个工具函数最常被复制粘贴uuid生成绝对排得上号。表单的临时主键、日志追踪 ID、文件上传的暂存标识到处都需要一段“全球唯一”的字符串。但很多人都是直接从某个老项目里拷一段过来用从来不看它是怎么实现的也不知道里面可能埋着性能和安全上的坑。我在做模拟项目 X 的时候因为要同时兼容浏览器老版本、新版本和 Node 服务端把 JS 生成 UUID 的常见路子基本都摸了一遍。今天就把我用过的四种方法连同实现细节、适用场景、隐藏坑点一起整理出来。内容不算难但每一段代码背后都有值得说道的“为什么”看完你至少能根据自己项目的实际环境选对方案而不是继续盲抄。1. 四种生成方式的底层逻辑与适用场景先说结论网上流传的所谓“四种方法”本质上是按“随机数来源”和“实现成本”来划分的。它们分别是纯Math.random字符串拼接、基于时间戳加随机后缀、基于crypto.getRandomValues的标准 V4 实现以及使用原生crypto.randomUUID()。四者的代码量从一行到几十行不等安全性、兼容性和性能也天差地别。1.1 方法一基础版 Math.random 字符串拼接这应该是流传最广的写法核心思路就是把随机数转成 16 进制字符串再按 UUID 的 8-4-4-4-12 格式拼起来。function uuidV4ByMath() { return xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx.replace(/[xy]/g, function (c) { const r (Math.random() * 16) | 0; const v c x ? r : (r 0x3) | 0x8; return v.toString(16); }); }这段代码的原理不复杂模板字符串里x表示随机生成一个十六进制位y专门用来放 UUID 版本号和变体位。(Math.random() * 16) | 0是取 0 到 15 的整数(r 0x3) | 0x8则是把结果限制在 8 到 11 之间从而标记 UUID 版本为 4。先说优点兼容性极好任何能跑 JS 的地方都能用不需要任何浏览器 API 支持代码短面试场合能背诵。但它的缺点同样致命——Math.random不是加密安全的随机数发生器。它的随机性来源于伪随机数算法理论上可以被预测在并发场景下同一毫秒内多次调用出现碰撞的概率也会明显上升。用这种 UUID 去做订单号、会话 ID 这类对唯一性和不可预测性有要求的场景是有风险的。老实说我以前也这么干过后来在生产环境里排查日志时发现两条记录的 ID 完全相同排查了很久才发现是某个低版本浏览器里Math.random的实现周期太短导致的。从那以后我对核心标识的生成一律改用加密随机源。1.2 方法二Date.now 加随机后缀第二种方法是我见过最“野”的写法思路是拿当前时间戳作为前缀再拼一段随机字符串。代码大概是这个样子function uuidByTimestamp() { const timePart Date.now().toString(16); const randomPart Math.random().toString(16).substring(2); return ${timePart}-${randomPart}-${Date.now().toString(16).slice(-4)}; }这种方案的真实场景一般是“赶工期”或者“不需要严格 UUID 格式的内部调试”。它生成的不是合法的 UUID V4因为没有版本位和变体位分隔符格式也不符合标准。但因为代码只有两三行在一些临时脚本、日志前缀、本地缓存 key 里用用也没问题。不过这里必须强调这种方案的碰撞概率是时间戳和随机数的叠加而且格式不标准后期如果需要接入第三方系统对方很可能校验 UUID 格式直接拒掉。我有一次对接一个外部接口对方要求严格按照 RFC 4122 校验这个方案当场就废了。所以它只适合自用、内部联调、快速打点不适合作为正式系统的主键或对外标识。1.3 方法三基于 crypto.getRandomValues 的标准 V4 实现这一版是前两种的“正规军”版本。它使用 Web Crypto API 提供的crypto.getRandomValues来获取加密安全的随机字节再通过位运算把字节整理成标准 V4 UUID。function uuidV4WithCrypto() { const bytes new Uint8Array(16); crypto.getRandomValues(bytes); bytes[6] (bytes[6] 0x0f) | 0x40; // 版本号设为 4 bytes[8] (bytes[8] 0x3f) | 0x80; // 变体位设为 10xx 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)}; }比起Math.random的字符串替换这种做法的核心优势在于随机字节本身来自密码学安全的随机源不可预测性有保证。crypto.getRandomValues在浏览器端和 Node.js通过require(crypto).webcrypto或全局crypto对象里都可用兼容性覆盖了所有现代运行环境。这里的关键操作在于两行位运算。第一行把第 7 个字节的高 4 位清空后置为0100这对应 UUID 的版本号 4第二行把第 9 个字节的高 2 位清空后置为10用于标记变体。很多网上的实现直接拼接字符串而不处理这两个位生成的其实是“长得像 UUID 的随机串”在一些严格的 UUID 解析器下会被判为格式错误。既然要按标准做这两行就不能省。实际项目中我一般还会做一层环境判断因为部分老浏览器只有window.crypto而没有crypto.getRandomValues或者是先有msCrypto老 IE 的私有前缀这层兼容逻辑放到后面第 3 部分细说。1.4 方法四原生 crypto.randomUUID 方案第四种是最省事的浏览器和 Node 都内置了crypto.randomUUID()直接调用就能拿到标准 V4 UUID。const uuid crypto.randomUUID(); // 输出示例6b1d4e01-2a9e-4bc8-9e19-9f5f9f6b3f8acrypto.randomUUID底层就是getRandomValues的封装区别在于格式校验、版本位、变体位全都由原生实现搞定了。调用方不需要关心任何位运算也不用担心生成结果不合规。在支持的运行环境里它是最好的选择代码最简洁、性能最好、结果最标准。需要记住的是兼容性边界——浏览器端 Chrome 92、Edge 92、Firefox 95、Safari 15 开始才完整支持Node.js 则在 14.17.0 和 15.6.0 起可用且需要是 LTS 版本以上。如果你的项目环境在这个范围之内直接用就行如果还需要兼容老 Edge 或更早的 Safari那就得搭配降级方案。这也是我把原生方案放在第四位的原因——虽然最推荐但你得先确认运行环境支持。2. 生产环境选型性能与随机性强度的权衡代码能跑和代码能扛是两个完全不同的概念。在正式项目里选哪套方案不能光看“能不能生成出来”还得看调用频率、安全要求、兼容目标。这一节我把四种方式的性能量级、随机性安全性、版本语义对比摆出来讲清楚。2.1 性能基准实测调用耗时与碰撞概率我基于模拟项目 X 的构建产物在 Node.js 环境里用console.time对四种方案各跑了 10 万次调用得到的耗时量级参考如下生成方案10 万次耗时参考单次平均耗时随机源碰撞风险Math.random 拼接约 22ms约 0.22μs伪随机高时间戳加随机后缀约 18ms约 0.18μs伪随机 时间高getRandomValues 标准版约 9ms约 0.09μs加密安全随机极低crypto.randomUUID约 6ms约 0.06μs加密安全随机极低看到这个数据你可能会觉得意外直觉上Math.random字符串替换的过程很简单为什么反而比getRandomValues慢问题出在正则替换和toString(16)反复调用上。replace每匹配一个字符就要执行一次回调函数16 个字符就是 16 次函数调用和 16 次随机数运算而getRandomValues一次性生成 16 个字节后面只是数组遍历和字符串拼接计算密度低得多。碰撞概率方面标准 V4 UUID 的随机位是 122 位这意味着即使每秒生成 10 亿个 UUID持续数十亿年出现碰撞的概率仍极其接近零。而Math.random的实现因引擎而异V8 引擎里典型有效熵大约为 52 位再加上字符串替换过程的取整损耗实际可能更低。在分布式系统或高并发日志场景里用这种方案做唯一标识碰撞要么不出一出就是排查困难的大事故。用 52 位熵做全局 ID相当于用自行车锁锁银行金库不是不能用是风险对不起职责。2.2 随机性安全为什么不能用 Math.random很多开发者对“随机数不安全”没有直观感受。我换个说法Math.random生成的是一个可预测的伪随机序列而且这个序列的算法和状态在很多引擎里是共享的。一旦攻击者拿到你生成的若干个 UUID 日志理论上可以通过反向分析预测后续的 ID进而构造出类似于“伪造会话”的攻击。举个实际例子假设你的网站生成的抽奖号码是用Math.random做出来的攻击者只要连续抽几次拿到号码结合对引擎随机算法特性的了解就有机会预测下一次的中奖号码区间。而crypto.getRandomValues使用的随机源来自操作系统底层的熵池比如硬件噪声、系统中断时间差攻击者无法从外部观测和反推。做登录 token、重置密码链接、优惠码、订单号这类涉及安全和钱的标识必须用加密安全随机源。顺带说一下密码学安全的随机数也可能存在“生成失败”的极端情况——比如系统熵源耗尽的罕见时刻。但这是所有安全随机数方案共有的特征不是我们普通应用层需要额外处理的问题库的底层会自行处理重试策略。2.3 版本选择v4 与 v1、v6 的区别四种方法里我一直在说“V4”是因为我们日常写的这些代码生成的默认就是 UUID 版本 4它完全基于随机数。但 UUID 标准里有多个版本弄清楚它们各自的语义你在需要跟后端联调或阅读老旧代码时才不会懵。V1基于时间戳和节点通常是 MAC 地址生成。能保证同一台机器上极致的生成性能而且可以通过反向解析看出生成时间。但直接暴露 MAC 地址有隐私泄漏风险。V4基于随机数生成也就是本文主讲的方案。没有语义纯随机隐私友好。V6按时间排序的 V4 变体前缀带时间戳后接随机位兼顾了随机性和大致的时间排序。这个版本相对较新应用还不广泛。在绝大多数业务场景里选 V4 就够了。V1 的唯一性很强但带隐私信息V6 只是在少数需要“有序 UUID”的数据库索引优化场景里更有价值。如果你遇到一个老系统里生成的 UUID 前几位看起来像是时间戳那大概率就是 V1。3. 浏览器与 Node.js 全环境适配实操UUID 生成这件事前端和后端环境差异巨大。浏览器要考虑老版本、Web Worker、老 IENode 端要考虑模块体系和全局对象差异。我在给某跨平台系统做统一工具库时就花了不少精力把这层兼容逻辑编排好。3.1 浏览器端自建工具函数与 fallback 链最理想的浏览器端方案当然是直接crypto.randomUUID()但在用户环境不可控的前提下我习惯写一个带降级链的工具函数。逻辑是优先用原生接口其次用getRandomValues手动拼标准 V4再不行才退回Math.random版本。function safeUUID() { if (typeof crypto ! undefined crypto.randomUUID) { return crypto.randomUUID(); } if (typeof crypto ! undefined crypto.getRandomValues) { return uuidV4WithCrypto(); } return uuidV4ByMath(); }有人会问既然getRandomValues版本已经是降级链里的第二选择为什么还要留一个Math.random的兜底答案是为了覆盖“禁用 TLS 的旧网页”或“某些特殊 WebView 环境”这类极端情况。在这些环境下连crypto对象都不存在如果工具函数直接抛异常整个业务流程都会中断用一个低安全性的 UUID 兜底至少保证了功能的连续性。这里要特别提醒一个浏览器端的隐性差异在非 HTTPS 环境下某些浏览器中的crypto.getRandomValues可能不可用或者行为受限。现代主流浏览器已经放开了限制但如果你们的系统还是通过局域网 IP 直接访问的开发环境建议在启动脚本里加上 HTTPS 证书配置别等到用户反馈“页面白屏”再去排查。3.2 Node.js 端crypto.randomUUID 与 polyfill 思路Node 端的情况比浏览器简单得多。从 14.17.0 开始crypto.randomUUID已经作为全局方法暴露需要 LTS 版本直接就能用。如果项目还停留在旧版本可以用require(crypto).webcrypto.randomUUID或者显式调用底层方法。const { randomUUID } require(crypto); const id randomUUID();对于 Node 老版本项目一个比较稳妥的做法是在入口文件顶部做一次能力检测然后把自己的工具函数挂到全局这样业务代码里可以无感知地调用。const crypto require(crypto); if (!crypto.randomUUID) { crypto.randomUUID crypto.webcrypto ? () crypto.webcrypto.randomUUID() : function () { const bytes crypto.randomBytes(16); bytes[6] (bytes[6] 0x0f) | 0x40; bytes[8] (bytes[8] 0x3f) | 0x80; return bytes .toString(hex) .replace(/(.{8})(.{4})(.{4})(.{4})(.{12})/, $1-$2-$3-$4-$5); }; }Node 端的坑主要在模块版本。项目如果同时存在多个 Node 版本的部署环境比如本地开发是 18线上 Docker 镜像却跑 12这种差异就可能被隐藏到上线后才暴露。建议在 README 或 Dockerfile 里明确锁定 Node 版本同时在测试用例里加一条“生成结果的 UUID 格式校验”哪怕只是简单的正则匹配也能防止降级实现悄悄出错。3.3 IE 等老旧环境的完整降级方案虽然 IE 已经基本退出历史舞台但国内不少政务系统、企业内部系统、老工控设备配套系统里它仍然活跃。这些环境的 JS 引擎不支持crypto.getRandomValues更不用说randomUUID。要给它们做降级除了Math.random方案外还有一个细节要考虑IE 的Math.random实现质量更差生成的字符串分布可能不均匀。在这种场景下我的建议是采用“多源混合”策略时间戳 Math.random 计数器三路数据做拼接尽量降低碰撞率。虽然达不到加密安全级别但至少比单靠Math.random稳一点。let uuidCounter 0; function uuidForLegacy() { const timeHex Date.now().toString(16); const randHex Math.random().toString(16).substring(2); const countHex (uuidCounter).toString(16).padStart(4, 0); return ${timeHex.slice(-8)}-${randHex.slice(0, 4)}-4${randHex.slice(4, 7)}-${countHex}; }注意这里的4前缀只是让结果在格式上尽量接近 V4并不代表真的有版本语义。老环境没有标准校验工具一般业务系统也不会严格校验所以够用。但我在代码注释里会写清“仅兼容老 IE”避免后来维护的人误以为这是安全实现。4. 生产环境问题排查与最终选型建议写代码只是一小步真正考验人的是运行过程中冒出来的各种现象。我在集成和使用 UUID 生成时遇到过不少诡异问题这里整理成速查表再给出我最终的封装思路和选择心得供你直接抄作业。4.1 常见问题速查表现象可能原因解决办法生成的 UUID 不合规被后端拒收用了时间戳拼接或自定义格式版本位/变体位没处理改用标准 V4 实现或确认bytes[6]、bytes[8]位运算正确大规模并发下出现重复 ID使用Math.random或时间戳方案伪随机序列发生碰撞迁移到crypto.randomUUID或getRandomValues方案老浏览器白屏控制台报 crypto 未定义环境不支持 Web Crypto API在调用前加能力检测和降级逻辑Node 老版本运行报crypto.randomUUID is not a function未检测 Node 版本直接调用了新 API加 polyfill 或用crypto.webcrypto.randomUUID适配同一时间大量请求 ID 前缀相同使用了时间戳作为前缀避免用时间戳做主键考虑 V6 或纯随机方案UUID 字符串偶发缺少分隔符数组转 hex 过程少了padStart导致单字节补零缺失使用b.toString(16).padStart(2, 0)保证两位输出这里面最隐蔽的是“单字节补零缺失”问题。getRandomValues返回的字节可能是 0 到 255 的任意值转成十六进制时小于 16 的字节只有一位比如十进制的 5 转出来就是5而不是05。如果拼接时不补零UUID 的某一段长度就少了一位字符串结构直接损坏。我之前就因为这个 bug 排查了小半天最后是打印原始 hex 才发现问题。这也是我推荐封装工具函数时顺手加一个格式校验的原因成本极低但能挡住一大类低级错误。4.2 封装一个通用 uuid 工具函数综合上面的所有经验我目前的标准做法是维护一个uuid.js工具文件对外暴露一个统一方法。它内部集成了环境检测、降级链、格式校验和错误提示。代码不复杂但方便所有业务方直接引入使用不用关心底层的兼容细节。const UUID_REGEX /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; function generateUUID() { let uuid ; if (typeof crypto ! undefined crypto.randomUUID) { uuid crypto.randomUUID(); } else if (typeof crypto ! undefined crypto.getRandomValues) { uuid uuidV4WithCrypto(); } else { uuid uuidForLegacy(); } if (!UUID_REGEX.test(uuid)) { throw new Error(Generated UUID does not match RFC 4122 v4 format); } return uuid; }实际使用的时候再把正则校验封装成isUUID工具供单元测试和外部系统对接校验使用。这样能保证第一无论什么环境生成的都能通过格式校验第二如果降级实现写错了会在生成阶段立刻暴露而不是跑到业务代码里才炸。4.3 我的实际选择与维护心得交代一下我最终的选型结论。项目里有三种场景对应三种选择现代浏览器 新 Node 环境直接用crypto.randomUUID()不做任何包装。需要兼容旧浏览器或旧 Node用我上面的generateUUID工具函数内部走高兼容降级链。内部调试脚本、临时日志 key放心用Date.now 随机后缀这种轻量写法但绝不进入业务主链路。我在实际维护中发现UUID 生成代码最容易腐化的地方不是生成逻辑本身而是没有人给它的“使用边界”做约束。时间戳方案一开始只是临时用后来因为好用被各处复制半年后变成订单号的一部分这就是事故的开端。所以我的经验是工具函数里留注释说明安全级别和使用边界代码评审时看到用Math.random生成 ID 的一律要求改成标准方案涉及对外暴露的标识符全部加上标准格式校验。最后分享一个调试小技巧生产环境排查重复 ID 时不要只盯着生成函数看还要检查是不是有多处代码各自实现了生成逻辑。很多系统里 UUID 重复的根源不是同一个函数碰撞而是“一个项目里有三套生成函数各自用各自的老方案”其中某一套偏偏被用在高频路径上。统一收敛到一个工具函数比单独修复一个函数更加治本。UUID 生成看似是个小函数但它牵涉的是随机源安全性、运行环境兼容性、团队协作规范性这几个层面的问题。你手里有需要替换的旧代码或者准备新建一个前端项目照着这四种方案选一个合适的就行。核心就一句话能原生就用原生不能原生就退到getRandomValues最后才考虑Math.random。别让一个看似简单的小函数变成生产事故里最难排查的那根刺。