前端唯一标识生成指南: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('')
}
设备指纹的局限性
- 隐身模式:大部分浏览器在隐身模式下会限制或修改指纹信息
- 隐私保护:iOS Safari、Firefox 已开始限制指纹 API
- 同一设备的指纹会变化:系统更新、浏览器升级都会导致指纹变化
- 法律风险:在某些地区(如 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 标识"这个人"。两者通过关联事件打通,而不是互相替代。