跳到主要内容
极客日志极客日志面向AI+效率的开发者社区
首页博客我的书AI学习GitHub 精选镜像AI 生图工具UI配色美学关于
搜索内容 / 工具 / 仓库 / 镜像...⌘K搜索
注册
博客列表
JavaScriptNode.jsReact Native大前端算法

JS 生成 UUID 的常见方案与生产环境避坑指南

JavaScript 生成 UUID 是前端开发中的常见需求,涉及离线存储、埋点上报等场景。本文对比了 Math.random、Date.now、浏览器指纹及 npm 包等多种实现方式,重点分析了原生 crypto.randomUUID API 的优势与兼容性限制。结合生产环境真实翻车案例,阐述了伪随机数导致的 ID 冲突风险,并提供了草稿箱同步、WebSocket 防重发等实战代码方案。建议优先使用加密级随机源,注意降级处理,避免自行发明非标准格式。

咸鱼开飞机发布于 2026/4/8更新于 2026/9/981 浏览
JS 生成 UUID 的常见方案与生产环境避坑指南

JS 生成 UUID 的常见方案与生产环境避坑指南

说实话,UUID 这玩意儿听起来挺'后端味儿'的,感觉应该是 Java 老哥在 Spring Boot 里搞定的事儿。但架不住现在前后端分离之后,后端越来越懒了——'这个 ID 你前端生成一下呗','离线存储自己搞个临时 ID 嘛'。

既然逃不掉,那咱就好好唠唠。今天这篇不整虚头八脑的,手把手教你从'土法炼钢'到'正规军'怎么搞 UUID,顺便看看那些年在生产环境踩过的坑。

为啥前端突然要搞这破玩意儿?

先还原个真实场景:需求评审会上,产品经理说要做个'离线编辑功能',用户没网的时候也能写东西,有网了自动同步。后端小哥听完表示:'这个简单,前端你本地先存着,等联网了发给我。'

你小心翼翼地问:'那主键 ID 呢?'

后端翘着二郎腿:'你先生成一个呗,uuid 就行。'

那一刻你的内心是崩溃的。啥?我?生成主键?但这场景现在太常见了。除了离线数据同步,还有埋点上报、本地缓存 Key、表单自动保存、WebSocket 消息去重等等。别觉得 UUID 是后端专属,现在前端玩得可花了。但问题也来了:JavaScript 不像 Java 有个 java.util.UUID 直接 randomUUID() 就完事了,JS 生态怎么说呢,百花齐放(群魔乱舞)吧。

先整明白 UUID 到底是个啥

UUID 全称 Universally Unique Identifier,通用唯一识别码。标准格式是 8-4-4-4-12 的 32 个十六进制数字,比如 550e8400-e29b-41d4-a716-446655440000。

版本有好几个,咱们前端常用的是:

  • v4:纯随机生成,最常用,看起来像 xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx。
  • v1:基于时间戳+MAC 地址,但前端拿不到 MAC 地址,所以基本是时间戳 + 随机数凑合版。

其实还有 v3、v5(基于命名空间和哈希),但前端基本用不上。你就记住:前端说 UUID,九成九是指 v4 那种随机的。

土法炼钢第一式:Math.random() 真的靠谱吗?

先说最傻白甜的写法,估计刚学 JS 的人都写过:

function generateUUID() {
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
    var r = Math.random() * 16 | 0;
    var v = c === 'x' ? r : (r & 0x3 | 0x8);
    return v.toString(16);
  });
}
console.log(generateUUID()); // 比如:f47ac10b-58cc-4372-a567-0e02b2c3d479

这代码看着挺高级是吧?正则替换,位运算,十六进制转换,乍一看以为是大佬写的。但我要给你泼盆冷水了:这玩意儿在生产环境用,分分钟教你做人。

Math.random() 的随机性其实挺垃的,它是伪随机,基于种子生成的。在 V8 引擎里,不同浏览器实现还不一样。最离谱的是某些低端安卓机,Math.random() 的随机范围居然有偏置,生成的数扎堆,结果就是撞 ID。

我曾经在日志系统里看到,一天之内居然有几百个重复的 UUID,查了半天发现都是某个国产安卓机型干的好事。那场面,堪称车祸现场。

而且 Math.random() 还有个致命问题:它不是加密的。如果你拿这个 UUID 当会话 ID 或者安全令牌,黑客能给你预测出来,直接社会工程学攻击走起。

但你说完全不能用吗?倒也不是。如果只是做个前端临时缓存 key,丢了就丢了那种,用用也无妨。但记住,别用它做数据持久化的主键,真的会出事的。

土法炼钢第二式:Date.now() 加料版

既然纯随机不靠谱,那咱加点时间戳总行了吧?时间总是唯一的嘛(理论上)。

function makeId() {
  let id = '';
  // 时间戳部分,13 位
  const timestamp = Date.now().toString(36);
  // 转成 36 进制,缩短长度
  // 随机数部分,补够长度
  const randomPart = Math.random().toString(36).substring(2, 8);
  id = `${timestamp}-${randomPart}-${Math.random().toString(36).substring(2, 8)}`;
  return id;
}
console.log(makeId()); // 类似:lxx1h2z3-abc123-def456

这代码是我早些年写的,当时觉得贼聪明——时间戳保证大致顺序,随机数保证唯一性,36 进制还能缩短字符串长度,完美!

结果上线第一天就出事了。用户狂点提交按钮,网络卡的时候点五次,后台瞬间收到五条数据,ID 居然一模一样。为啥?因为 Date.now() 是毫秒级的,用户手速再快也比不上机器循环快啊。

后来我学乖了,加了个自增计数器:

let counter = 0;
function betterId() {
  const timestamp = Date.now().toString(36);
  const random = Math.random().toString(36).substring(2, 5);
  // 加了个原子计数器,每次调用都 +1
  const count = (counter++).toString(36).padStart(4, '0');
  return `${timestamp}-${random}-${count}`;
}

这下倒是不会重复了,但这代码看着就…挺丑的,而且还是不安全,还是那个问题,可以被预测。只适合临时用用,正式环境别这么搞。

土法炼钢第三式:浏览器指纹大杂烩

后来我又想了个骚操作,既然随机数不靠谱,那我把能拿到的设备信息都混进去总行了吧?指纹唯一性应该还可以。

function fingerprintUUID() {
  // 收集各种浏览器信息
  const screenInfo = `${screen.width}x${screen.height}x${screen.colorDepth}`;
  const userAgent = navigator.userAgent;
  const language = navigator.language;
  const platform = navigator.platform;
  const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
  
  // 混在一起做个简单的 hash(这里简化处理,实际可以用更复杂的 hash 算法)
  const seed = `${screenInfo}-${userAgent}-${language}-${platform}-${timezone}-${Date.now()}-${Math.random()}`;
  
  // 简单 hash 函数
  let hash = 0;
  for (let i = 0; i < seed.length; i++) {
    const char = seed.charCodeAt(i);
    hash = ((hash << 5) - hash) + char;
    hash = hash & hash; // 转 32 位整数
  }
  
  // 转成十六进制,再格式化成 UUID 格式
  const hex = Math.abs(hash).toString(16).padStart(8, '0');
  const randomPart = Math.random().toString(16).substring(2, 10);
  return `${hex.substring(0, 8)}-${hex.substring(0, 4)}-4${hex.substring(1, 4)}-${randomPart.substring(0, 4)}-${randomPart.substring(4, 12)}${Date.now().toString(16).substring(0, 4)}`;
}
console.log(fingerprintUUID());

这代码看着唬人,但其实问题更大。首先,fingerprint 不是唯一的,同型号手机,一样的屏幕分辨率,一样的浏览器,生成的指纹就一样。其次,现在浏览器都在搞隐私保护,navigator.userAgent 快要变成固定值了,platform 也快要藏起来了。

所以这条路基本也行不通,属于自娱自乐型。

正规军来了:uuid npm 包到底香不香?

土法炼钢搞了半天,发现都有坑,那咋办?上正规军呗。

npm 上有个 uuid 包,周下载量上亿,属于业界标准了。用法简单得令人发指:

# 先装上:npm install uuid
import { v4 as uuidv4 } from 'uuid';

// 生成一个 v4 UUID
const id = uuidv4();
console.log(id); // 比如:9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d

这包的好处是啥呢?它考虑了各种环境:

  • Node.js:用 crypto 模块生成真随机数
  • 浏览器:优先用 crypto.getRandomValues,降级才用 Math.random
  • React Native:也有对应实现

而且它还支持 v1、v3、v4、v5,虽然咱们基本只用 v4,但它有啊,显得专业。

我在生产环境用了三四年,几百万数据量,没出过重复问题。当然,理论上还是有碰撞概率的,但那个概率比你中彩票还低,可以忽略不计。

但有个坑要注意:版本问题。uuid 包第 9 版是大版本,如果你项目里还在用第 8 版,升级的时候注意看 changelog,有些 API 变了。别问我怎么知道的,问就是曾经升级后线上挂了半小时。

还有个性能问题,如果你要一次性生成几万个 UUID,uuid 包可能会有点慢,因为它内部有些校验逻辑。这时候你可以考虑用 crypto.randomUUID(),这是浏览器原生的,更快。

浏览器原生 API:crypto.randomUUID() 真香预警

这是现代浏览器(Chrome 92+、Firefox 95+、Safari 15.4+)带来的福音,原生支持,不用装包:

// 直接调用,简单粗暴
const id = crypto.randomUUID();
console.log(id); // f47ac10b-58cc-4372-a567-0e02b2c3d479

性能测试下来,比 uuid npm 包快大概 30%-50%,毕竟是原生 C++ 实现的。而且代码量少,不用引入依赖,bundle 体积都小了。

但!是!兼容性是个大问题。你要是还要支持 IE11(虽然微软都放弃它了,但有些国企项目就是绕不开),或者某些老旧的安卓 WebView,这 API 直接报 undefined 给你看。

所以稳妥的写法是加个 polyfill:

function generateUUID() {
  // 优先用原生的
  if (typeof crypto !== 'undefined' && crypto.randomUUID) {
    return crypto.randomUUID();
  }
  // 降级方案,用 uuid 库,或者自己实现一个 v4
  // 这里为了演示,手写一个简陋版,生产环境建议还是引入 uuid 包
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
    const r = (typeof crypto !== 'undefined' && crypto.getRandomValues)
      ? crypto.getRandomValues(new Uint8Array(1))[0] % 16
      : Math.random() * 16 | 0;
    const v = c === 'x' ? r : (r & 0x3 | 0x8);
    return v.toString(16);
  });
}
// 测试一下
console.log(generateUUID());

看到没?我还加了个 crypto.getRandomValues 的降级,这是浏览器提供的加密级随机数 API,比 Math.random() 安全多了,至少能保证随机性。

生产环境翻车实录:那些我以为的唯一其实并不唯一

好了,前面都是技术方案,现在进入吐槽大会环节,说说我在生产环境踩过的坑。

翻车事件一:安卓低端机大屠杀

2021 年,我们做个活动页,要在前端生成订单 ID,然后传给后端。我当时图省事,直接用了 Math.random() 版 UUID。结果活动上线当天,客服炸了,说有很多用户投诉'我明明只买了一次,怎么扣了三次钱?'

查日志发现,三个不同的用户,生成了同一个 UUID,后端以为是重复提交,直接拒绝了。再细查,全是某品牌千元安卓机,CPU 还是联发科三年前的入门款。那机器的 Math.random() 实现有问题,种子更新频率慢,短时间内生成的随机数高度相似。

解决方案:连夜改成 crypto.getRandomValues,并且加了个服务端兜底,如果前端没传 ID 或者 ID 格式不对,后端自己生成一个。

翻车事件二:并发地狱之 for 循环惨案

有个需求是批量导入,前端要一次生成 100 条数据的临时 ID。开发小哥写了个 for 循环:

const list = [];
for (let i = 0; i < 100; i++) {
  list.push({ id: Math.random().toString(36).substring(2), data: xxx });
}

本地测试没问题,测试环境没问题,上线后用户导入 1000 条数据的时候,发现有 30% 的 ID 重复了。因为 Math.random() 在短时间内的种子可能没变,而且 toString(36).substring(2) 截取得太短,碰撞概率剧增。

翻车事件三:mock 数据摧毁生产数据库

这是最惨的一次。测试环境的 mock 脚本里,为了数据好看,用了固定种子的 UUID 生成器,比如:

// mock 脚本
let seed = 12345;
function mockUUID() {
  seed++;
  return `fake-uuid-${seed}`;
}

结果某天测试同学不小心把测试配置指到了生产环境(别问为什么测试能连生产,问就是历史遗留问题),然后 mock 脚本跑了十万条数据进生产库,全是以 fake-uuid- 开头的 ID。更惨的是,这些数据后来同步到大数据平台,导致一整天的报表数据全脏了得回滚。

从那以后,我们定了个规矩:任何环境都不能用非标准 UUID 格式,mock 数据必须用正规的 uuid 库生成,哪怕只是测试。

实战代码大放送:这些场景你肯定用得上

光说不练假把式,给你几个我项目中真实在用的代码片段。

场景一:用户草稿箱本地存储

用户写长文的时候,每 30 秒自动保存一次草稿,关闭页面再回来还能恢复。

class DraftManager {
  constructor() {
    this.STORAGE_KEY = 'user_drafts';
    // 每个草稿给一个唯一 ID,页面加载时如果没 ID 就新建一个
    this.currentDraftId = sessionStorage.getItem('current_draft_id') || this.createNewDraft();
  }

  createNewDraft() {
    const id = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
    sessionStorage.setItem('current_draft_id', id);
    return id;
  }

  saveDraft(content) {
    const drafts = this.getAllDrafts();
    drafts[this.currentDraftId] = {
      content,
      updateTime: Date.now(),
      // 加个子 ID,用于版本控制,万一用户开了两个标签页呢
      versionId: crypto.randomUUID ? crypto.randomUUID() : Date.now()
    };
    localStorage.setItem(this.STORAGE_KEY, JSON.stringify(drafts));
  }

  getAllDrafts() {
    try {
      return JSON.parse(localStorage.getItem(this.STORAGE_KEY)) || {};
    } catch {
      return {};
    }
  }

  // 清理 7 天前的草稿
  cleanOldDrafts() {
    const drafts = this.getAllDrafts();
    const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
    Object.keys(drafts).forEach(id => {
      if (drafts[id].updateTime < sevenDaysAgo) {
        delete drafts[id];
      }
    });
    localStorage.setItem(this.STORAGE_KEY, JSON.stringify(drafts));
  }
}

// 使用
const draftManager = new DraftManager();
document.getElementById('editor').addEventListener('input', (e) => {
  draftManager.saveDraft(e.target.value);
});

看到没?这里我用了双 ID 策略,外层的 currentDraftId 标识一篇草稿,内层的 versionId 标识每次保存的版本。这样即使用户疯狂 Ctrl+S,我们也能知道哪次保存是最新的。

场景二:PWA 离线数据同步

这是真正的业务场景,用户可能在地铁里没网的时候提交表单,有网了再同步。

class OfflineSyncManager {
  constructor() {
    this.DB_NAME = 'offline_data';
    this.STORE_NAME = 'pending_requests';
    this.db = null;
    this.initDB();
  }

  async initDB() {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.DB_NAME, 1);
      request.onerror = () => reject(request.error);
      request.onsuccess = () => {
        this.db = request.result;
        resolve();
      };
      request.onupgradeneeded = (event) => {
        const db = event.target.result;
        // 用 UUID 作为主键
        db.createObjectStore(this.STORE_NAME, { keyPath: 'localId' });
      };
    });
  }

  // 添加离线任务
  async addTask(apiEndpoint, payload) {
    const localId = crypto.randomUUID();
    // 生成临时主键
    const task = {
      localId, // 本地唯一标识
      apiEndpoint,
      payload,
      createdAt: Date.now(),
      retryCount: 0,
      status: 'pending' // pending, failed, success
    };
    const transaction = this.db.transaction([this.STORE_NAME], 'readwrite');
    const store = transaction.objectStore(this.STORE_NAME);
    await store.add(task);
    console.log(`任务已离线保存,本地 ID:${localId}`);
    return localId;
  }

  // 同步数据
  async sync() {
    if (!navigator.onLine) return;
    const transaction = this.db.transaction([this.STORE_NAME], 'readonly');
    const store = transaction.objectStore(this.STORE_NAME);
    const request = store.getAll();
    request.onsuccess = async () => {
      const tasks = request.result.filter(t => t.status === 'pending');
      for (const task of tasks) {
        try {
          // 发送请求时带上 localId,后端返回时会带上,这样前端可以对应上
          const response = await fetch(task.apiEndpoint, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ ...task.payload, clientId: task.localId }) // 告诉后端这是哪条数据
          });
          if (response.ok) {
            // 成功后从本地删除,或者标记为成功
            await this.markAsSuccess(task.localId);
            console.log(`任务 ${task.localId} 同步成功`);
          }
        } catch (error) {
          await this.markAsFailed(task.localId);
          console.error(`任务 ${task.localId} 同步失败`, error);
        }
      }
    };
  }

  // 监听网络恢复
  startListening() {
    window.addEventListener('online', () => {
      console.log('网络恢复,开始同步...');
      this.sync();
    });
  }
}

// 使用示例
const syncManager = new OfflineSyncManager();

// 用户点击提交
document.getElementById('submit').addEventListener('click', async () => {
  const data = {
    name: document.getElementById('name').value,
    content: document.getElementById('content').value
  };
  if (navigator.onLine) {
    // 有网直接发
    await fetch('/api/submit', {
      method: 'POST',
      body: JSON.stringify(data)
    });
  } else {
    // 没网存本地
    const localId = await syncManager.addTask('/api/submit', data);
    alert('当前无网络,数据已保存,联网后自动同步。本地 ID:' + localId);
  }
});

这个方案的关键在于 localId,它在离线阶段就是数据的唯一身份证。等联网后,这个 ID 会传给后端,后端入库时可以把这个作为业务 ID,也可以自己再生成一个数据库自增 ID,但会把 localId 存到单独的字段做映射。这样前后端就能对得上号,不会出现'这数据我明明发了,后端说没收到'的扯皮情况。

场景三:WebSocket 消息防重发

网络抖动的时候,WebSocket 可能以为消息没发出去,实际已经发出去了,然后重发一次,导致后端处理了两次。

class ReliableWebSocket {
  constructor(url) {
    this.url = url;
    this.ws = null;
    this.messageQueue = []; // 待发送队列
    this.pendingMessages = new Map(); // 已发送但未确认的消息
    this.reconnectAttempts = 0;
  }

  connect() {
    this.ws = new WebSocket(this.url);
    this.ws.onopen = () => {
      console.log('WebSocket 连接成功');
      this.reconnectAttempts = 0;
      this.flushQueue(); // 把之前没发出去的发了
    };
    this.ws.onmessage = (event) => {
      const data = JSON.parse(event.data);
      // 处理 ACK 确认
      if (data.type === 'ACK') {
        // 服务端收到消息了,从 pending 里删掉
        this.pendingMessages.delete(data.messageId);
        console.log(`消息 ${data.messageId} 已确认送达`);
      } else {
        // 处理普通消息
        this.handleMessage(data);
      }
    };
    this.ws.onclose = () => {
      console.log('连接断开,准备重连...');
      setTimeout(() => this.reconnect(), 1000 * Math.pow(2, this.reconnectAttempts));
    };
  }

  // 发送消息,带唯一 ID 和重试机制
  send(payload) {
    const messageId = crypto.randomUUID();
    // 每条消息一个唯一 ID
    const message = {
      id: messageId,
      timestamp: Date.now(),
      payload,
      // 重试次数,防重发用
      retryCount: 0
    };
    if (this.ws.readyState === WebSocket.OPEN) {
      this.doSend(message);
    } else {
      // 没连接先存队列
      this.messageQueue.push(message);
    }
    return messageId; // 返回 ID,方便业务层监听
  }

  doSend(message) {
    this.ws.send(JSON.stringify(message));
    // 记录到 pending,等 ACK
    this.pendingMessages.set(message.id, { ...message, sendTime: Date.now() });
    // 3 秒没收到 ACK 就重试
    setTimeout(() => this.checkAck(message.id), 3000);
  }

  checkAck(messageId) {
    if (this.pendingMessages.has(messageId)) {
      const msg = this.pendingMessages.get(messageId);
      if (msg.retryCount < 3) {
        console.log(`消息 ${messageId} 未确认,第${msg.retryCount + 1}次重试`);
        msg.retryCount++;
        this.doSend(msg);
      } else {
        console.error(`消息 ${messageId} 发送失败,放弃重试`);
        this.pendingMessages.delete(messageId);
      }
    }
  }

  flushQueue() {
    while (this.messageQueue.length > 0) {
      const msg = this.messageQueue.shift();
      this.doSend(msg);
    }
  }

  reconnect() {
    this.reconnectAttempts++;
    console.log(`第${this.reconnectAttempts}次重连...`);
    this.connect();
    // 把 pending 的消息标记为需要重发
    this.pendingMessages.forEach((msg, id) => {
      msg.retryCount = 0; // 重置重试次数
      this.messageQueue.push(msg);
    });
    this.pendingMessages.clear();
  }
}

// 使用
const ws = new ReliableWebSocket('wss://example.com/ws');
ws.connect();

// 发送消息
const msgId = ws.send({ text: '你好啊' });
console.log('发送消息 ID:', msgId);

这个方案的核心就是消息 ID。服务端收到消息后,先查这个 ID 有没有处理过,处理过就直接返回 ACK,没处理过就处理然后存起来。这样就算客户端重发了,服务端也不会重复处理业务逻辑。

调试技巧:怎么验证你的 UUID 真的唯一?

写完代码总得测试吧?但 UUID 理论上会重复,只是概率极低,怎么验证你的生成器靠谱呢?

暴力测试法:跑 100 万次

function testUniqueness(generator, count = 1000000) {
  const set = new Set();
  const startTime = performance.now();
  let duplicates = 0;
  for (let i = 0; i < count; i++) {
    const id = generator();
    if (set.has(id)) {
      duplicates++;
      console.log(`发现重复!第${i}次生成了已存在的 ID: ${id}`);
    } else {
      set.add(id);
    }
    // 每 10 万次报告一次进度
    if (i % 100000 === 0 && i > 0) {
      console.log(`已生成${i}个 UUID,目前无重复...`);
    }
  }
  const endTime = performance.now();
  console.log(`测试完成!生成${count}个 UUID,发现${duplicates}个重复`);
  console.log(`耗时:${(endTime - startTime).toFixed(2)}ms`);
  console.log(`平均每个 UUID 生成时间:${((endTime - startTime) / count).toFixed(4)}ms`);
  return duplicates === 0;
}

// 测试 Math.random 版
testUniqueness(() => {
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
    var r = Math.random() * 16 | 0;
    var v = c === 'x' ? r : (r & 0x3 | 0x8);
    return v.toString(16);
  });
}, 100000);

// 测试 crypto.randomUUID
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
  testUniqueness(() => crypto.randomUUID(), 100000);
}

跑这个脚本的时候,建议把浏览器标签页放后台,因为 100 万次循环会卡界面。或者用 Web Worker。

实时监控法:用 WeakMap 做内存去重

如果你不确定线上环境有没有重复,可以埋个点:

class UUIDMonitor {
  constructor() {
    this.uuidSet = new Set();
    this.duplicates = [];
    this.maxSize = 10000; // 只保留最近 1 万个,防止内存爆炸
  }

  check(uuid) {
    if (this.uuidSet.has(uuid)) {
      this.duplicates.push({
        uuid,
        time: Date.now(),
        stack: new Error().stack // 记录调用栈
      });
      // 上报到监控平台
      this.report(uuid);
      return false;
    }
    // 满了就清一半,LRU 策略简单版
    if (this.uuidSet.size >= this.maxSize) {
      const iter = this.uuidSet.values();
      for (let i = 0; i < this.maxSize / 2; i++) {
        this.uuidSet.delete(iter.next().value);
      }
    }
    this.uuidSet.add(uuid);
    return true;
  }

  report(uuid) {
    // 发到 sentry 或者自研监控平台
    console.error(`UUID 重复警告:${uuid}`, this.duplicates[this.duplicates.length - 1]);
  }
}

// 使用
const monitor = new UUIDMonitor();
function safeGenerateUUID() {
  const id = crypto.randomUUID ? crypto.randomUUID() : 'xxx-xxx'.replace(/x/g, () => Math.random().toString(16)[2]);
  if (!monitor.check(id)) {
    // 重复了,重新生成(虽然理论上不应该)
    return safeGenerateUUID();
  }
  return id;
}

console.log 大法:时间戳定位

如果怀疑某个 UUID 有问题,可以在生成的时候打详细的 log:

function debugUUID() {
  const id = crypto.randomUUID();
  console.log(
    `%c生成 UUID: ${id}`,
    'color: #1890ff; font-weight: bold;',
    `\n时间:${new Date().toISOString()}`,
    `\n页面:${location.href}`,
    `\n用户:${localStorage.getItem('userId') || '未登录'}`,
    `\n堆栈:`,
    new Error().stack
  );
  return id;
}

这样出问题的时候,你可以在控制台 Filter 里搜这个 UUID,看它是什么时候在哪生成的。

冷门但好用的小技巧

用 performance.now() 提升时间精度

Date.now() 只能到毫秒,但 performance.now() 可以精确到微秒(虽然也是假的,是高分表时间),适合用来做时间戳部分:

function highResTimestamp() {
  // 基础时间戳 + 高分表时间,精度更高
  const base = Date.now();
  const highRes = performance.now();
  return `${base}-${Math.floor(highRes * 1000)}`;
}

navigator.userAgent 做轻量设备指纹

虽然不建议依赖 userAgent,但用来做盐值(salt)增加随机性还是可以的:

function getDeviceSalt() {
  const ua = navigator.userAgent;
  let hash = 0;
  for (let i = 0; i < ua.length; i++) {
    const char = ua.charCodeAt(i);
    hash = ((hash << 5) - hash) + char + (i % 7); // 加点料
  }
  return Math.abs(hash).toString(16).substring(0, 4);
}

// 混入 UUID 生成
function saltyUUID() {
  const base = crypto.randomUUID ? crypto.randomUUID() : 'xxx';
  return base.replace(/^.{4}/, getDeviceSalt()); // 把前 4 位换成设备指纹
}

Symbol 临时兜底

如果你只是需要在当前运行时唯一的标识,不需要持久化,用 Symbol 最简单:

const uniqueKey = Symbol('draft_key');
const cache = {};
// 保证当前进程唯一
cache[uniqueKey] = { data: 'xxx' };

// 但是注意,Symbol 不能 JSON 序列化,不能存 localStorage,不能跨 iframe 传递
// 只适合内存中的临时标识

还有个小众场景:UUID 压缩。标准的 UUID 是 36 个字符(带横杠),如果存数据库觉得太长,可以转成 Base64 或者去掉横杠:

function compressUUID(uuid) {
  // 去掉横杠,32 位
  return uuid.replace(/-/g, '');
}

function decompressUUID(short) {
  // 还原横杠
  return `${short.substring(0, 8)}-${short.substring(8, 12)}-${short.substring(12, 16)}-${short.substring(16, 20)}-${short.substring(20)}`;
}

// 更狠的,转成 Base62(alphanumeric),可以缩短到 22 位左右
// 但这需要额外库,而且可能丢精度,慎用

最后唠叨两句,也是掏心窝子的话

看完了前面的,你应该发现了,UUID 这玩意儿看着简单,水挺深的。

我最想说的是:别再拿 new Date().getTime() 当万能钥匙了。我见过太多代码,包括一些大厂的老项目,还在用时间戳当 ID。是,毫秒级时间戳在单机单用户场景下好像不会重复,但你要考虑:

  • 用户疯狂连点,JS 事件循环跑得快,1 毫秒内能执行好多次
  • 分布式系统里,多个用户同时操作
  • 用户电脑时间被手动调了(别笑,真有用户喜欢把手表调快 5 分钟)

时间戳这东西,做排序字段可以,做主键就是找死。真出事了你背锅,产品经理不会说是需求没写清楚,只会说'前端怎么搞的'。

另外,UUID 也不是银弹。它解决不了业务上的唯一性问题,比如同一个用户在同一秒提交了两次一样的表单,UUID 不同,但业务上是重复数据。这时候你需要业务层面的去重,比如根据用户 ID+ 内容 hash 判断。

还有,不要自己发明 UUID 算法。我看到过有人用 Math.random().toString(36).substring(2) 就当 UUID 用,结果长度不够,随机性差,字符集也不对。标准 UUID 是固定的 8-4-4-4-12 格式,32 个十六进制字符,别整那些花里胡短的'创新',到时候和别的系统对接对不上就尴尬了。

最后的最后,考虑兼容性。如果你的项目还要支持 IE,或者那些政企项目里的国产浏览器(内核可能还是 Chromium 60),记得做降级。crypto.randomUUID 虽好,但别直接 const id = crypto.randomUUID() 就完事了,先判断下 crypto 和 randomUUID 存不存在。

好了,说这么多,希望能帮你在下次被后端甩锅

目录

  1. JS 生成 UUID 的常见方案与生产环境避坑指南
  2. 为啥前端突然要搞这破玩意儿?
  3. 先整明白 UUID 到底是个啥
  4. 土法炼钢第一式:Math.random() 真的靠谱吗?
  5. 土法炼钢第二式:Date.now() 加料版
  6. 土法炼钢第三式:浏览器指纹大杂烩
  7. 正规军来了:uuid npm 包到底香不香?
  8. 先装上:npm install uuid
  9. 浏览器原生 API:crypto.randomUUID() 真香预警
  10. 生产环境翻车实录:那些我以为的唯一其实并不唯一
  11. 实战代码大放送:这些场景你肯定用得上
  12. 调试技巧:怎么验证你的 UUID 真的唯一?
  13. 冷门但好用的小技巧
  14. 最后唠叨两句,也是掏心窝子的话

更多推荐文章

查看全部
  • Python 高效处理 CAD 文件:ezdxf 库核心技术解析与实战指南
  • 算法模拟法解题实战
  • 在 Cursor 中配置并使用 MCP 服务进行自动化开发
  • Windows 10 系统安装 MySQL 9.4.0 教程
  • xAI Grok 图像转视频模型评测与 API 接入指南
  • Spring Boot 与 Leaflet 实现省级旅游口号 WebGIS 可视化
  • FLUX.1-dev与Stable Diffusion对比评测:图像质量与生成速度
  • OpenClaw 汉化版部署常见问题排查手册
  • 使用 AI 辅助开发 Java 电商系统核心模块实战
  • 单片机四舍五入算法原理与嵌入式边界处理实践
  • Boltz-2 安装及用法:结构与亲和力预测模型
  • 无人机路径规划核心算法解析与实战对比
  • 谷歌 Gemini 的 6 种免费使用渠道与方法指南
  • 高效邮件发送系统设计与实现:基于Python和SQLAlchemy的实践
  • LlamaFactory v0.9.4 正式发布:LLM 微调框架全面升级
  • Java HashMap 底层原理深度解析
  • Web 前端核心 API 入门:变量声明、DOM 操作与定时器
  • OpenClaw 实战调优:5 步让 AI 助手真正“能干活”
  • ROS1 全局参数:定义、获取与修改实战
  • C 语言指针与数组的深度关联及实战应用

相关免费在线工具

  • 加密/解密文本

    使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online

  • Gemini 图片去水印

    基于开源反向 Alpha 混合算法去除 Gemini/Nano Banana 图片水印,支持批量处理与下载。 在线工具,Gemini 图片去水印在线工具,online

  • Keycode 信息

    查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online

  • Escape 与 Native 编解码

    JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online

  • JavaScript / HTML 格式化

    使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online

  • JavaScript 压缩与混淆

    Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online