ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

游客模式实战:UUID、Cookie与LocalStorage的匿名用户身份方案

游客模式实战:UUID、Cookie与LocalStorage的匿名用户身份方案 先讲一个我踩过的坑。之前给某品牌做H5集卡活动用户辛辛苦苦答了15道题切出去回了个微信再切回来页面刷新答题进度归零。用户当然不会认为是微信切走的锅只会觉得这页面有问题。当时产品提了个很朴素的需求能不能在用户不登录的情况下也把他认出来。这就是游客模式的典型场景。所谓游客模式说白了就是一套匿名的、可恢复的、可追踪的临时身份体系。用户在没有任何账号信息的前提下访问产品系统要能给他一个稳定的标识Visitor ID让他的购物车、收藏、答题进度、行为埋点都能跨页面、跨会话、跨天保留。这篇博客我会把我实际落地的一套方案完整复盘一遍核心是三件套的组合UUID负责生成全局唯一的游客IDCookie负责让服务端每个请求都能识别这个IDLocalStorage负责把完整游客档案在浏览器侧持久化。三个组件各管一段合在一起就是一套能上生产的游客模式。适合谁来读前端同学可以直接抄作业全栈和偏后端的同学重点看第3节和第6节服务端怎么配合、游客ID怎么校验都在里面。我会把生成原理、存储分工、代码实现、并发/隐私模式排坑、合规安全全部串起来讲而不是只给一个生成UUID写进Cookie的玩具方案。1. 为什么需要一个游客态业务场景倒逼出的架构需求1.1 匿名用户的四种典型诉求很多人以为游客模式就是允许不登录访问其实真正的复杂度在于匿名状态下仍然要提供有状态的服务。以电商购物车为例。用户没登录加了3本书进购物车关掉浏览器第二天打开页面购物车还在。这个过程牵扯到身份识别、持久化存储、服务端档案三件事。不能识别身份购物车就是一次性的不持久化刷新就丢服务端不知道这个ID跨设备同步就无从谈起。另一种常见场景是行为埋点。产品想看未注册用户从首页到详情页的转化路径没有游客ID的话埋点数据就是一团散沙连同一用户的连续行为都拼不出来更别说后续的留存分析和推荐召回。还有一类是活动进度类需求。答题做到一半、游戏存档、连续签到天数、抽奖剩余次数这些状态天然依赖临时身份 服务端存档。我开头说的H5集卡活动就是典型的这类场景。再有就是收藏和订阅。内容社区里用户对某篇文章点了收藏当时没登录收藏数据挂在谁名下挂游客ID上等他登录了再合并。1.2 游客架构的三个核心目标可识别、可恢复、可合并我把游客模式的技术目标收敛成三个词可识别、可恢复、可合并。可识别每次请求服务端都能知道这又是上次那个人。这个要靠一个全局唯一的ID在Cookie、请求头、服务端存储之间打通来实现。可恢复用户清了Cookie、重启浏览器、换了网络身份不能丢。纯Cookie方案在Cookie被清后当场失忆所以需要LocalStorage做第二副本反之LocalStorage被清Cookie还能反哺。这就是我后面要讲的双写互恢复。可合并游客状态不是终态用户终归会登录。登录之后游客期间积累的购物车、收藏、积分、行为数据要能顺利合并到正式账号下。合并之后游客ID要么废弃要么降级为历史行为关联键。这三个目标里可恢复和可合并是很多人设计游客模式时最容易漏掉的两环。只做识别的方案上线后迟早会出问题。2. 身份标识选型为什么是UUID v4而不是自增ID或雪花ID2.1 客户端生成ID的约束条件游客ID必须在浏览器端生成这跟你平时设计服务端ID是完全不同的约束环境。第一条约束是不能依赖服务端发号。用户在断网、弱网、首屏瞬间可能根本连不上接口身份生成却必须在这之前完成。也尽量不要有首访先调注册接口拿ID这种设计——多一次网络往返就多一个失败点。服务端配合的方式应该是被动接受而不是主动发号。第二条约束是不能依赖协调中心。自增ID需要计数器雪花ID需要机器ID和统一时钟这些都是服务端或分布式基础设施的资产浏览器端拿不到。第三条约束是不可枚举。如果游客ID是可顺序自增的整数用户改一下URL里的ID就有可能瞄到别人的游客档案这是严重越权。第四条约束是生成要快、要离线可用。UUID v4在本地纯计算微秒级完成不需要任何网络请求完美匹配。自增ID为什么不行游客场景下它直接违反不可枚举和客户端单方面生成两条硬约束想都不用想。雪花IDSnowflake ID也经常被问起。它的结构是时间戳机器ID序列号依赖时钟同步浏览器端获取机器ID本身就别扭而且它是可解析的会让别人知道你什么时候首次访问这既是隐私顾虑也容易被用作枚举探测。苹果早年用过的 identifierForVendor 其实也踩过类似的坑不是不能解析而是可解析带来的追踪风险不值得。2.2 UUID版本与生成方案的工程对比UUID不是只有一种它有多个版本游客场景我推荐v4。版本生成方式特点适合游客ID吗v1MAC地址时间戳可追踪机器、可猜测趋势不适合v3/v5命名空间名称哈希确定性生成不适合v4纯随机122位不可枚举、无规律、碰撞概率极低适合v7时间戳随机可排序适合数据库索引可作为进阶选项有人问过为什么不用8位短随机串当游客ID。这里有个简单的空间计算32位随机串的理论空间是42亿左右百万级用户时生日碰撞概率就相当可观了64位随机串虽然有1800万亿空间但工程上还要考虑服务端存ID、日志切分、数据库索引等场景的通用性。UUID v4有122位随机空间冲突概率低到可以忽略而且它是行业基础设施——数据库、日志系统、HTTP协议、埋点平台全都原生认识这个格式。具体生成代码现代浏览器可以直接用原生API// 原生方式Chrome 92 / Safari 15.4 / Firefox 95 const id crypto.randomUUID();需要兼容老浏览器的降级方案用 crypto.getRandomValues 补齐function generateVisitorId() { const cryptoObj window.crypto || window.msCrypto; if (!cryptoObj) { throw new Error(当前环境不支持安全随机数生成); } if (typeof cryptoObj.randomUUID function) { return cryptoObj.randomUUID(); } // 手工拼装 UUID v4 const bytes cryptoObj.getRandomValues(new Uint8Array(16)); bytes[6] (bytes[6] 0x0f) | 0x40; // 版本号置为 4 bytes[8] (bytes[8] 0x3f) | 0x80; // 变体置为 10 const hex Array.from(bytes, b b.toString(16).padStart(2, 0)); return ${hex.slice(0, 4).join()}-${hex.slice(4, 6).join()}-${hex.slice(6, 8).join()}-${hex.slice(8, 10).join()}-${hex.slice(10).join()}; }注意不要用Math.random()拼UUID。Math.random() 是伪随机数在某些环境下可被预测而且不保证分布均匀。安全随机数必须来自crypto.getRandomValues或原生crypto.randomUUID()。另外补充一句如果你后续有海量数据存储考量可以把UUID转成BINARY(16)存MySQL或者直接用UUID v7在数据库里获得更好的索引排序特性。游客ID只是客户端侧的标识服务端完全可以在落库时做内部映射。3. Cookie与LocalStorage的角色分工谁管短会话谁管长档案3.1 两种存储介质的能力边界很多初学者二选一式地纠结Cookie还是LocalStorage实际工程里两个都要用职责完全不同。先看一张能力对比表维度CookieLocalStorage容量单域名约4KB单域名约5MB请求携带每次HTTP请求自动带不会自动携带服务端可读可以HttpOnly更安全读不到前端可读可除非HttpOnly可过期控制可设置Expires/Max-Age由JS手动删除无自动过期作用域按域名路径可跨子域按域名同源策略生命周期到过期时间前一直有效持久除非用户清除从这张表能看出Cookie天然适合让服务端认出你LocalStorage天然适合存一份前端自己维护的详细档案。我的分工原则很简单Cookie 管会话凭证只存一个游客IDvisitor_id每次请求自动带着走后端拿到它就能查档案。空间占用几十字节4KB限制完全不是问题。LocalStorage 管前端档案存完整的游客对象包含id、创建时间、最后活跃时间、状态标记等。刷新页面、重开浏览器时前端用它恢复记忆。两者互相兜底Cookie被清了LocalStorage里还有ID下次请求前重新种CookieLocalStorage被清了Cookie还在重新回填一份新的档案对象。只要有一个还活着游客身份就不丢。3.2 Cookie 配置里的几个关键参数写Cookie不是document.cookie visitor_idxxx就完事了实际要配的参数不少。function setVisitorCookie(id, days 30) { const expires new Date(Date.now() days * 864e5).toUTCString(); document.cookie ${visitor_id}${encodeURIComponent(id)}; expires${expires}; path/; SameSiteLax; }逐个说每个参数的意义expires游客身份的存活期。我习惯设30天滑动过期每次活跃都刷新一次超过30天没回来就让他自然遗忘。太短会影响长期未登录用户回来还有购物车的体验太长会增加服务端存储压力。path/全站生效避免只在某个路径下才带Cookie。SameSiteLax顺手做掉一部分CSRF防护。Lax允许顶层导航发送Cookie又不会在跨站子请求中带出游客场景够用。HttpOnly这里我特意不设因为前端需要读Cookie做自愈回填。如果你的团队对XSS防护要求特别高可以改成HttpOnly然后前端通过一个轻量接口GET /api/visitor/current来读代价是多一次请求。Cookie的值虽然UUID本身是ASCII安全字符但我要特别提醒如果你后续要在Cookie里放别的非ASCII内容记得encodeURIComponent编码、decodeURIComponent解码。网上Cookie中文乱码的坑基本都是没做编码导致的Cookie头本身就不应该出现裸的中文。3.3 顺便理清 Cookie 与 Session/Token 的关系热词榜上经常有人搜cookie和session和token详解在游客模式这个场景里值得顺带讲清楚因为很多人在这一步就开始混。Cookie是一种浏览器侧的存储和传递机制它负责携带一段数据Session和Token是服务端身份认证方案负责证明你是谁。游客模式里Cookie只是运载工具运的是一个匿名ID不代表任何登录状态。登录之后建议切到Token体系比如JWT不要让用户登录状态继续依赖Cookie中的游客ID。很多团队把游客ID和登录态全塞在一个Cookie里结果就是用户登录了游客档案也变了行为数据对不上。我的建议是游客状态和登录状态彻底分开前者走X-Visitor-Id请求头 visitor_id Cookie后者走Authorization: Bearer token。两条链路各自清晰合并时再打通数据。4. 完整落地实现从SDK初始化到请求链路打通4.1 标识生成与读取的完整函数下面这套代码我直接放在前端项目的utils/visitor.js里核心是一个 VisitorTracker 对象负责读档案、建档案、写档案、种Cookie。const VISITOR_KEY visitor_profile; const COOKIE_NAME visitor_id; const VisitorTracker { // 初始化应用启动时调用一次 init() { let profile this.loadProfile(); if (!profile) { profile this.createProfile(); this.saveProfile(profile); } this.refreshCookie(profile); return profile; }, // 优先从 LocalStorage 读完整档案 loadProfile() { try { const raw localStorage.getItem(VISITOR_KEY); if (!raw) return null; const parsed JSON.parse(raw); return this.isValid(parsed) ? parsed : null; } catch (e) { return null; } }, // 校验档案结构重新生成一个合法游客对象 createProfile() { return { id: generateVisitorId(), createdAt: Date.now(), lastActiveAt: Date.now(), upgraded: false // 是否已合并到正式账号 }; }, // UUID v4 格式校验不合法就当没有 isValid(profile) { if (!profile || typeof profile.id ! string) return false; return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(profile.id); }, saveProfile(profile) { try { localStorage.setItem(VISITOR_KEY, JSON.stringify(profile)); } catch (e) { // Safari 隐私模式下 localStorage 不可写这里降级到内存 this._memoryProfile profile; } }, // 每次调用都刷新 Cookie实现滑动过期 refreshCookie(profile) { setVisitorCookie(profile.id, 30); }, // 供请求拦截器调用 getVisitorId() { const profile this.loadProfile() || this._memoryProfile; return profile ? profile.id : null; } };这套代码的核心思路是读写分离、双端互备。loadProfile严格校验避免脏数据破坏身份链saveProfile做降级处理隐私模式下不抛异常refreshCookie每次活跃都续期实现滑动过期。4.2 请求拦截与后端校验前端联调时Axios请求拦截器直接加请求头axios.interceptors.request.use(config { const vid VisitorTracker.getVisitorId(); if (vid) { config.headers[X-Visitor-Id] vid; } return config; });为什么同时有Cookie还要加请求头两个原因一是如果后端和前端不同域第三方Cookie可能被浏览器拦掉请求头能兜底二是请求头里的ID明确表示这是业务身份跟Cookie自动携带的会话数据语义上解耦。后端配合的Node中间件长这样import crypto from node:crypto; const UUID_RE /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; function visitorMiddleware(req, res, next) { let visitorId req.headers[x-visitor-id] || req.cookies?.visitor_id; if (visitorId UUID_RE.test(visitorId)) { req.visitorId visitorId; // 查 Redis这里简化示意 redisClient.expire(visitor:${visitorId}, 30 * 86400); return next(); } // 非法或缺失生成一个新ID交给客户端保存 const newId crypto.randomUUID(); req.visitorId newId; res.setHeader(X-Visitor-Id, newId); setVisitorCookie(newId, req, res); return next(); }后端校验的重点不是防伪造而是防脏数据和统一口径。正则校验保证进入业务逻辑的ID格式一致Redis滑动续期保证不活跃的游客档案自动淘汰。4.3 登录状态下的身份合并与切换游客身份最终归宿是合并进正式账号这一步最容易做砸。我推荐的合并流程是用户发起登录前端在登录成功回调里带上visitorId和userId调后端合并接口。后端事务里做这几件事把游客购物车合并进账号购物车把游客收藏合并进账号收藏把游客行为埋点重新关联到账号ID。合并完成后前端更新游客档案把upgraded置为true清空本地游客资产。后续请求头从X-Visitor-Id切换成Authorization: Bearer token。async function handleLoginSuccess(user) { const visitorId VisitorTracker.getVisitorId(); if (visitorId) { try { await axios.post(/api/visitor/merge, { visitorId, userId: user.id }); // 标记合并完成保留ID进日志但不作业务身份 const profile VisitorTracker.loadProfile(); if (profile) { profile.upgraded true; VisitorTracker.saveProfile(profile); } } catch (e) { // 合并失败不阻塞登录写日志后续重试 console.error(visitor merge failed, e); } } }这里有个工程细节合并接口失败时不要阻塞登录流程。游客购物车合并这件事可以采用标记待合并 定时重试的异步策略而不是让用户卡在登录转圈界面等购物车。数据一致性靠服务端补偿任务保证用户体验优先。5. 多标签页并发、隐私模式与跨域隔离实测中的排坑记录5.1 并发创建导致的游客分裂与修复这是我在真实环境里踩到的第一个坑用户同一浏览器开了两个标签页同时访问两个页面都发现本地没有游客ID然后各自generateVisitorId()生成了两个不同ID后写入的覆盖先写入的。后果是什么服务端同一用户产生了两个游客档案一个装满了购物车数据另一个是空壳。用户自己感知可能不明显但埋点数据从此就裂成两段了留存分析、转化路径全对不上。修复方案我用了两层第一层是读前等待。初始化时先检查localStorage是否已经有档案如果有就直接用不要急着生成。并发场景下A页面写入先于B页面读取完成B页面读到A的数据自然就避免了分裂。第二层是监听 storage 事件。同一个浏览器的不同标签页之间localStorage变化会触发storage事件。如果发现自己维护的ID和别人写入的不同以后者为准回源并重新种Cookie。window.addEventListener(storage, (e) { if (e.key VISITOR_KEY e.newValue) { try { const newProfile JSON.parse(e.newValue); if (VisitorTracker.isValid(newProfile)) { VisitorTracker.saveProfile(newProfile); VisitorTracker.refreshCookie(newProfile); } } catch (err) { // 解析失败忽略等下一次事件 } } });还有一个更高级的工具是navigator.locks可以用互斥锁保证检查-写入的原子性但对普通项目来说 storage 事件先读后写已经够用。5.2 隐私模式下存储失效的降级方案Safari 开启私有浏览模式时localStorage.setItem会直接抛QuotaExceededError而且每次访问都像一张白纸。很多团队在这里翻车因为没做try/catch就直接崩。我在saveProfile里做了内存降级。降级之后游客身份只在当前标签页内有效关掉标签页就丢这是隐私模式下的物理限制。对应策略是埋点和购物车照常工作当前页面内身份连续。服务端按visitor_id查档案查不到就按新游客处理。明确告诉产品同学隐私模式下不做跨会话恢复承诺。Cookie在隐私模式下通常还是能写的所以Cookie LocalStorage双写在这里救了命LocalStorage挂了Cookie还在身份至少能撑过整个Safari会话期。5.3 第三方Cookie限制下的跨域对策这两年Safari的ITP和Chrome逐步弃用第三方Cookie导致一个很现实的问题如果你的游客应用被嵌在第三方iframe里或者你的域名和API服务域名不同Cookie可能会被浏览器拦掉。我处理过的一个真实案例开放平台的小应用以iframe嵌入主站游客身份拿不到。第三方Cookie不是每次都失效但隔三差五失效一次根本没法稳定复现。对策方案优先级如下尽量同域主站和子应用的API都走同一域名或至少把Cookie种到顶级域名上domain.example.com这是成本最低的解。请求头传递跨域时不用Cookie作为主通道改用前端X-Visitor-Id请求头传递。这样做要求前端显式维护游客ID也就是本文的LocalStorage方案。iframe postMessage主页面生成游客ID通过postMessage传给iframe子应用子应用本地存储。这样第三方Cookie失效也不影响。Storage Access API在Safari下申请带iframe存储权限属于标准方案但需要用户交互触发不能完全自动化。我自己现在的默认方案是同域用Cookie跨域一律改请求头LocalStorage。游客身份既然是业务身份跟着请求头走本来就比跟着Cookie走更灵活。6. 游客ID的合规底线与安全防护6.1 匿名化与化名化的边界游客ID这玩意儿看着跟账号体系没关系但合规上很容易踩线。它本身是随机生成的不可逆推回个人这属于匿名化方向可一旦你把游客ID和行为数据、设备信息关联起来它就可能变成化名化数据也就是间接识别到具体个人。现在的监管环境对这个边界越来越敏感。我的落地建议很具体在隐私政策里明确写入我们会通过匿名标识进行访问计数与行为分析不要把游客ID藏着掖着。给游客数据设置保留周期我推荐180天。超过周期的游客档案直接删掉不搞永久存储。给用户提供清除游客数据的能力。用户清Cookie、清站点数据时前端调一次DELETE /api/visitor/{id}服务端同步销毁档案。埋点数据里不要把设备指纹、地理位置、实名信息跟游客ID塞同一个字段至少做字段级脱敏。这一套东西不是法律意见但按这个底线做至少不会在产品上线时被合规团队拦下来。6.2 服务端对游客ID的校验与限流游客ID天然是客户端可控的用户完全可以自己伪造一个ID往你服务端打。所以服务端对游客ID的定位应该是识别而不是信任。三个要点第一格式校验是最基础的。咱们中间件里已经用UUID v4的正则做了不符合直接生成新ID。这能挡住90%的脏数据。第二游客ID永远不能作为安全边界。判断这个用户有没有权限看这份数据绝不能依赖游客ID。游客ID能被枚举、能被重放、能被伪造权限判断必须等登录后走正式账号体系。第三要叠加风控策略。同一个游客ID在1分钟内发起100次抽奖明显是脚本在刷同一IP下出现一万个游客ID大概率是黑产批量注册。服务端要针对游客ID做频率限制和异常检测。// 简易频率限制示意 async function rateLimitByVisitor(req, res, next) { const vid req.visitorId; if (!vid) return next(); const key rl:visitor:${vid}; const current await redisClient.incr(key); if (current 1) { await redisClient.expire(key, 60); } if (current 30) { return res.status(429).json({ code: 429, message: 请求过于频繁 }); } return next(); }存储层面游客档案我用Redis承载键是visitor:{id}TTL 30天滑动续期。这跟Cookie的过期策略保持一致不至于Cookie还活着服务端档案先没了。数据库量级可控Redis完全扛得住。6.3 Cookie中文与非ASCII字符的编码规范这个坑我真是见一次提一次。Cookie头在RFC规范里只允许ASCII字符你往里写中文或者表情符号要么乱码要么直接被浏览器拦掉。游客场景里游ID是纯ASCII但你不能保证后来的人不会往Cookie里塞个用户名、昵称、偏好设置。规范做法一律是写入前编码、读取后解码function setCookie(name, value, days) { const encoded encodeURIComponent(value); document.cookie ${name}${encoded}; max-age${days * 86400}; path/; SameSiteLax; } function getCookie(name) { const m document.cookie.match(new RegExp((?:^|; ) name ([^;]*))); return m ? decodeURIComponent(m[1]) : null; }顺手做掉这步能省掉未来一整个Cookie中文乱码的排查周期。说点掏心窝的话。游客模式看着就是生成UUID写个Cookie六个字但要让它真正扛住生产环境需要把生命周期、双端同步、并发冲突、隐私模式、跨域限制、合规边界全部想清楚。我做过的项目里凡是只用一种存储介质、不做双写互备的最后都或多或少的在用户换浏览器、清Cookie、开隐私模式之后掉链子。序列化一下我的最终建议UUID v4做身份源Cookie和LocalStorage双写互备份请求头携带进业务链路登录时合并切Token服务端校验格式并保留期TTL最后补一层用户清除通道。这套组合拳打下来游客模式才算真正闭环。
返回列表