前端唯一标识生成指南:UUID、设备 ID 与指纹识别的正确姿势

993 阅读3分钟

前端唯一标识生成指南:UUID、设备 ID、指纹识别的正确打开方式

做埋点、做登录态、做 A/B 测试、做离线缓存……几乎所有前端场景都需要给用户一个唯一标识。

但 "搞个唯一 ID" 这件事,坑比想象中多得多:

  • Math.random() 生成的 ID 真的唯一吗?
  • 用户清掉 Cookie/localStorage 怎么办?
  • 如何在不侵犯隐私的前提下做设备指纹?
  • 浏览器指纹在隐身模式下还能用吗?

本文把这些坑一个个填上。

一、UUID:最通用的唯一标识方案

什么是 UUID?

UUID(Universally Unique Identifier)是一个 128 位的标识符,标准格式是 32 个十六进制字符分成 5 段:

550e8400-e29b-41d4-a716-446655440000

理论上一台机器每秒生成 10 亿个 UUID,要花 100 亿年才有 50% 的概率重复——基本可以认为不重复。

前端生成 UUID 的正确方式

✅ 推荐方案:Crypto.randomUUID()

const uuid = crypto.randomUUID()
// → "73b8c5f0-3e2a-4c1a-9b7f-6d8e5f4a3c2b"

这是现代浏览器原生支持的 API,使用密码学安全的随机数生成器(CSPRNG),质量最高。

兼容性:Chrome 92+、Firefox 95+、Safari 15.4+、Edge 92+。

如果你需要兼容更老的浏览器:

// 降级方案:使用 crypto.getRandomValues
function generateUUID() {
  if (crypto.randomUUID) {
    return crypto.randomUUID()
  }
  
  // UUID v4 格式:xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
  const bytes = new Uint8Array(16)
  crypto.getRandomValues(bytes)
  
  // 设置版本号 (4) 和变体 (10xx)
  bytes[6] = (bytes[6] & 0x0f) | 0x40
  bytes[8] = (bytes[8] & 0x3f) | 0x80
  
  const hex = Array.from(bytes).map(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() 生成 UUID

// 千万不要!
function badUUID() {
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
    const r = Math.random() * 16 | 0  // Math.random() 不够随机!
    return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16)
  })
}

Math.random() 使用伪随机数生成器,在某些场景下(如高并发)可能产生重复 ID。而且它不是密码学安全的。

短 UUID(适合展示给用户的场景)

如果你需要给用户展示一个短 ID(比如订单号、分享链接),用完整 UUID 太长。可以考虑:

// 从 UUID 中取一部分 + 时间戳 + 校验位
function shortId() {
  const timestamp = Date.now().toString(36).toUpperCase()
  const random = Math.random().toString(36).substring(2, 6).toUpperCase()
  const chars = timestamp + random
  // 简单校验位:所有字符 ASCII 码的和 mod 36
  const check = chars.split('').reduce((s, c) => s + c.charCodeAt(0), 0) % 36
  return chars + check.toString(36).toUpperCase()
  // → "1A2B3C4D5E" 10 位,比 UUID 短得多
}

或者用 NanoID(轻量库,只有 130 字节):

import { nanoid } from 'nanoid'
nanoid() // → "V1StGXR8_Z5jdHi6B-myT"

二、设备 ID:持久化才是关键

UUID 本身不持久。每次生成都是全新的 ID——如果页面刷新后 ID 变了,那这个 ID 就没有任何实际价值。

设备 ID 的核心问题是存储,不是生成。

方案一:localStorage(最常用)

// device-id.ts
const DEVICE_ID_KEY = 'device_id'

function getDeviceId(): string {
  let deviceId = localStorage.getItem(DEVICE_ID_KEY)
  if (!deviceId) {
    deviceId = crypto.randomUUID()
    localStorage.setItem(DEVICE_ID_KEY, deviceId)
  }
  return deviceId
}

优点:简单、可靠 缺点:用户清除浏览器数据会丢失

方案二:localStorage + Cookie(双保险)

// device-id-durable.ts
const DEVICE_ID_KEY = 'device_id'

function getDeviceId(): string {
  // 1. 优先从 localStorage 读取
  let deviceId = localStorage.getItem(DEVICE_ID_KEY)
  if (deviceId) {
    // 同步到 Cookie(如果有的话),确保后续请求也能获取
    syncToCookie(deviceId)
    return deviceId
  }
  
  // 2. 从 Cookie 读取(可能上次用 localStorage 存在 Cookie 里了)
  deviceId = getCookie(DEVICE_ID_KEY)
  if (deviceId) {
    localStorage.setItem(DEVICE_ID_KEY, deviceId)
    return deviceId
  }
  
  // 3. 生成新 ID
  deviceId = crypto.randomUUID()
  localStorage.setItem(DEVICE_ID_KEY, deviceId)
  setCookie(DEVICE_ID_KEY, deviceId, 365) // 365 天过期
  return deviceId
}

优点:清除 localStorage 后 Cookie 还在;清除 Cookie 后 localStorage 还在 缺点:还是不能完全防清除

方案三:IndexedDB(更隐蔽的存储)

// device-id-idb.ts
const DB_NAME = 'AppStorage'
const STORE_NAME = 'identifiers'

function getDeviceIdFromIDB(): Promise<string> {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, 1)
    
    request.onupgradeneeded = (event) => {
      const db = (event.target as IDBOpenDBRequest).result
      if (!db.objectStoreNames.contains(STORE_NAME)) {
        db.createObjectStore(STORE_NAME)
      }
    }
    
    request.onsuccess = (event) => {
      const db = (event.target as IDBOpenDBRequest).result
      const transaction = db.transaction(STORE_NAME, 'readwrite')
      const store = transaction.objectStore(STORE_NAME)
      const getRequest = store.get('device_id')
      
      getRequest.onsuccess = () => {
        let deviceId = getRequest.result
        if (!deviceId) {
          deviceId = crypto.randomUUID()
          store.put(deviceId, 'device_id')
        }
        resolve(deviceId)
      }
    }
    
    request.onerror = () => reject(request.error)
  })
}

优点:用户不太会主动清理 IndexedDB,数据更持久 缺点:异步 API,使用起来比 localStorage 麻烦

最佳实践:三级存储策略

设备 ID 读取优先级:
  1. IndexedDB(最持久)
  2. localStorage(快)
  3. Cookie(最后的保底)

设备 ID 写入:
  只要有一个存储层有 ID,就同步到所有层

三、设备指纹:不存数据也能识别用户

如果用户清除了所有本地数据,怎么还能认出他?

**设备指纹(Device Fingerprinting)**通过收集设备的硬件和软件特征,生成一个大概率唯一的签名。

常见的指纹维度

维度采集方式稳定性
屏幕分辨率window.screen中(外接显示器会变)
时区Intl.DateTimeFormat高
语言navigator.language高
字体列表document.fonts高
Canvas 指纹canvas.toDataURL() 渲染差异高
WebGL 指纹WebGL 渲染器信息高(显卡型号+驱动版本)
AudioContext 指纹音频处理链的差异中
CPU 核心数navigator.hardwareConcurrency高
已安装字体通过测量文字宽度推断高

简单实现:Canvas 指纹

// fingerprint.ts
async function getCanvasFingerprint(): Promise<string> {
  const canvas = document.createElement('canvas')
  canvas.width = 256
  canvas.height = 256
  const ctx = canvas.getContext('2d')!
  
  // 绘制一系列形状和文字
  ctx.textBaseline = 'alphabetic'
  ctx.fillStyle = '#f60'
  ctx.fillRect(100, 100, 50, 50)
  
  ctx.fillStyle = '#069'
  ctx.font = '14px Arial'
  ctx.fillText('Cwm fjordbank glyphs vext quiz, 😃', 2, 20)
  
  ctx.fillStyle = '#999'
  ctx.font = '12px Times New Roman'
  ctx.fillText('abcdefghijklmnopqrstuvwxyz', 2, 40)
  
  // 获取渲染数据
  const imageData = canvas.toDataURL()
  
  // 哈希处理
  const hashBuffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(imageData))
  const hashArray = Array.from(new Uint8Array(hashBuffer))
  return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
}

组合指纹:多维度加权

// composite-fingerprint.ts
interface FingerprintComponents {
  userAgent: string
  screen: string
  timezone: string
  language: string
  canvas: string
  webgl: string
  cpuCores: number
  platform: string
}

async function getCompositeFingerprint(): Promise<string> {
  const components: FingerprintComponents = {
    userAgent: navigator.userAgent,
    screen: `${screen.width}x${screen.height}x${screen.colorDepth}`,
    timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
    language: navigator.language,
    canvas: await getCanvasFingerprint(),
    webgl: getWebGLFingerprint(),
    cpuCores: navigator.hardwareConcurrency || 0,
    platform: navigator.platform,
  }
  
  // 组合成字符串
  const fingerprint = Object.values(components).join('|||')
  
  // 哈希
  const hashBuffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(fingerprint))
  const hashArray = Array.from(new Uint8Array(hashBuffer))
  return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
}

设备指纹的局限性

  1. 隐身模式:大部分浏览器在隐身模式下会限制或修改指纹信息
  2. 隐私保护:iOS Safari、Firefox 已开始限制指纹 API
  3. 同一设备的指纹会变化:系统更新、浏览器升级都会导致指纹变化
  4. 法律风险:在某些地区(如 GDPR 下的欧盟)未经同意采集设备指纹可能违规

建议:设备指纹作为辅助手段,不要作为唯一的用户识别方式。

四、完整的混合识别策略

实际项目中,单一方案不够用。推荐这套混合识别策略:

// identity-manager.ts
class IdentityManager {
  async getUserId(): Promise<string> {
    // 1. 优先用用户登录 ID
    const loginId = await this.getLoginId()
    if (loginId) return loginId
    
    // 2. 次优先用持久化设备 ID
    const deviceId = await this.getPersistentDeviceId()
    if (deviceId) return deviceId
    
    // 3. 最后用设备指纹(不带持久化)
    return this.getDeviceFingerprint()
  }
  
  async getPersistentDeviceId(): Promise<string> {
    // 三级存储策略
    for (const storage of [this.idb, this.localStorage, this.cookie]) {
      const id = await storage.get()
      if (id) {
        // 同步到所有存储
        await Promise.all([
          this.idb.set(id),
          this.localStorage.set(id),
          this.cookie.set(id),
        ])
        return id
      }
    }
    
    // 生成新 ID
    const newId = crypto.randomUUID()
    await Promise.all([
      this.idb.set(newId),
      this.localStorage.set(newId),
      this.cookie.set(newId),
    ])
    return newId
  }
  
  onLogin(userId: string) {
    // 登录成功后,关联设备 ID 和用户 ID
    this.reportAssociation(this.lastDeviceId, userId)
  }
}

总结:各方案的适用场景

方案唯一性持久性隐私合规适用场景
Crypto.randomUUID()★★★★★无★★★★★通用唯一 ID 生成
localStorage + UUID★★★★★★★★★★★★★常规设备标识
三级存储策略★★★★★★★★★★★★★★生产环境通用方案
Canvas 指纹★★★★★★★★★★辅助识别、反爬虫
复合设备指纹★★★★★★★★高风险场景备用
Cookie 中的 ID★★★★★★★★★★★★服务端同样需要场景

最后一点建议:设备 ID 和用户登录 ID 是两个东西,不要混用。设备 ID 标识"这台设备",用户 ID 标识"这个人"。两者通过关联事件打通,而不是互相替代。