在 localStorage 之上搭一层真正能用的存储层:命名空间、容量淘汰与结构升级
localStorage 本身没什么好讲的:setItem、getItem、removeItem,五分钟能学完。麻烦出现在这几个 API 用熟之后——一个中后台系统跑到第三年、四年,十几个业务模块都往同一个源(origin)底下的这一份存储空间里塞东西,这堆键值对开始互相踩踏,容量逼近上限时直接报错阻断页面,某次需求改了字段结构又让老用户浏览器里躺着的旧数据变成一堆无法解析的垃圾。这三件事分别对应命名空间设计、容量与淘汰策略、结构版本迁移,是这一年我们把"最近搜索历史"和"离线草稿自动保存"这两个功能重新过一遍之后沉淀下来的东西。
先把问题摆清楚:单模块的坑和多模块的坑不是一回事
单个模块用 localStorage 存点东西,网上能查到的经验基本够用:存字符串、JSON.stringify 序列化、try...catch 兜底解析失败、写入前判断配额。这些是"点"上的坑,一个模块自己注意就能避开。
但一个真实的中后台项目,localStorage 是按源共享的,这意味着订单模块、客服模块、报表模块、权限模块,只要部署在同一个域名下,写的是同一份存储空间。如果每个模块各写各的、键名随手起,出问题的时点通常不是开发阶段,而是某次两个团队分别往 filter、draft、settings 这类过于直白的键名里写东西,谁也没告诉谁,直到某天一个页面的筛选条件莫名其妙被清空,排查半天才发现是另一个页面写了同名的键、结构还不一样,直接把值覆盖了。这类问题在单模块视角下完全测不出来,因为触发条件是"两个模块恰好选中了同一个字符串",属于典型的集成期问题而不是单元问题。
容量也是一样。单个模块自己测的时候数据量小,配额永远够用;但当十几个模块都往一份 5MB 左右的空间里写、又都不主动清理旧数据的时候,某个模块突然发现自己写不进去了,原因往往不在自己身上,而是别的模块攒了大量从未清理的历史数据,把配额占满了。这时候单纯给自己模块加 try...catch 只能保证不白屏,解决不了"为什么写不进去"这个根本问题。
结构升级是第三类,也是最容易被低估的一类。一个字段改名、一个枚举值调整,代码发布之后新用户看到的都是新结构,但老用户上次打开页面存下的还是旧结构,这份数据不会自己变,它会一直躺在用户浏览器里,直到某次被读到、被拿去做判断、然后出现一堆解释不通的行为。这个坑往往要等真实用户带着老数据回来才会暴露,测试环境很难覆盖,因为测试环境的数据都是新写的。
这三件事共同指向一个结论:光会调 localStorage 的 API 解决不了这些问题,需要搭的是这一层之上的公共存储层,把命名空间、容量、版本这三件事统一管起来,不再让十几个模块各自为战。
值得说清楚的是,这一层封装解决的从来不是 localStorage 本身的能力不足——它的 setItem/getItem 已经足够简单可靠,问题出在"简单"这件事本身:正因为调用起来毫无门槛,才更容易被十几个人在十几个模块里各自随手写出十几种不同的键名习惯、十几种不同的容错方式。存储层要补的不是浏览器的坑,是团队协作规模变大之后,一份共享资源如果没有约定就必然走向混乱这件事。
命名空间:给每个模块一段自己的地盘
命名空间的设计思路并不复杂,本质是约定一个键名前缀规则,让不同模块、不同数据类型天然不会撞车。我们这一年定的规则是三段式:应用标识:模块标识:数据类型,比如 admin:order:draft-list、admin:search:recent-keywords。应用标识区分的是"如果以后这个域名下部署了不止一个应用"这种情况,模块标识对应具体业务线,数据类型说明这份数据是干什么用的。
1const NAMESPACE = 'admin'; 2 3function buildKey(module, type) { 4 return `${NAMESPACE}:${module}:${type}`; 5} 6 7// 使用方式 8const draftKey = buildKey('order', 'draft-list'); 9const searchKey = buildKey('search', 'recent-keywords');
光有前缀规则还不够,真正有用的是把这个前缀规则和一层统一的读写封装绑在一起,而不是让每个模块自己拼字符串——拼字符串这件事一旦分散在各处,规则迟早会被写歪。我们把它收进了一个内部叫 storageNamespace 的小模块里,对外只暴露"传模块名和数据类型,拿到一个读写句柄"这一种用法:
1function createNamespacedStorage(moduleName) { 2 const prefix = `${NAMESPACE}:${moduleName}:`; 3 4 return { 5 set(type, value) { 6 const key = prefix + type; 7 try { 8 window.localStorage.setItem(key, JSON.stringify(value)); 9 return { ok: true }; 10 } catch (error) { 11 return { ok: false, error }; 12 } 13 }, 14 get(type, fallback = null) { 15 const key = prefix + type; 16 const raw = window.localStorage.getItem(key); 17 if (raw === null) { 18 return fallback; 19 } 20 try { 21 return JSON.parse(raw); 22 } catch (error) { 23 return fallback; 24 } 25 }, 26 remove(type) { 27 window.localStorage.removeItem(prefix + type); 28 }, 29 keys() { 30 const result = []; 31 for (let i = 0; i < window.localStorage.length; i++) { 32 const key = window.localStorage.key(i); 33 if (key?.startsWith(prefix)) { 34 // 只返回去掉前缀之后的裸类型名,调用方拿到的永远是能直接传回 35 // get/set/remove 的那种"类型",不需要自己再判断这一段是不是已经带前缀 36 result.push(key.slice(prefix.length)); 37 } 38 } 39 return result; 40 }, 41 }; 42} 43 44const orderStorage = createNamespacedStorage('order'); 45orderStorage.set('draft-list', drafts);
这里用到了可选链 key?.startsWith(prefix)——localStorage.key(i) 理论上在索引越界时会返回 null,虽然循环条件已经卡住了越界的可能,但多一层可选链能防住极端情况下的空值判断,这个写法这一年在 Chrome、Firefox、新版 Safari 里都可以放心用,不需要再绕回 key && key.startsWith(prefix) 这种更啰嗦的写法。
keys() 这个方法看起来只是个辅助函数,但它是后面容量淘汰能实现的前提——没有它,一个模块没办法知道"我自己名下到底存了哪些键",只能盲写盲删。这里有个容易埋雷的设计决定:keys() 到底应该返回原始的完整键(带命名空间前缀),还是返回去掉前缀之后的裸类型名?我们统一选择了后者——get、set、remove 三个方法的入参都是裸类型名,keys() 拿到的结果也必须是同一种东西,这样调用方不需要在"这个字符串到底有没有前缀"这件事上做任何判断,keys() 返回什么,转手传给 remove() 就一定是对的。如果让 keys() 返回带前缀的完整键,调用方稍不注意就会把这个完整键再传进 remove(type),内部再拼一次前缀,实际删除的键名对不上,草稿明明该被清掉却怎么也删不掉——这类问题往往要到线上堆积了一批本该被淘汰、却怎么也删不掉的旧数据才会被发现,本地测试时数据量小,感觉不出差别。
命名规则落地之后还有一步容易被跳过:把这份规则写进代码评审的检查项里,而不是只写在文档里指望大家记得。文档写得再清楚,真正起作用的时机是有人提交代码时被卡住一次——评审时如果看到有人绕开 createNamespacedStorage、直接调用原生的 window.localStorage.setItem 拼一个没有前缀的键名,这个改动就该被打回去重写。团队里这条规则真正被认真执行起来,是在两个模块撞键名那次事故之后,之前虽然文档里写了,但没人较真过,直到出问题才补上了评审这一环。
容量与淘汰:把无限增长的数据变成有边界的缓存
最近搜索历史是个很适合拿来说清楚淘汰策略的场景。用户每搜一次关键词,理论上都可以记一条,但如果不做任何限制,搜索历史会无限增长,年头久了用户的搜索记录能攒到几百上千条,其中早就该过期的占了绝大多数。这时候真正需要的不是"能不能存",是"超过多少条之后怎么把老的挤出去"——本质上是一个简化版的 LRU(最近最少使用)淘汰策略,容量满了之后优先淘汰最久没被访问、而不是最先写入的那条。
单纯按写入时间淘汰(先进先出)和按访问时间淘汰(LRU)是两种不同的策略,效果差异在一个具体场景里很明显:假设用户三个月前搜过一次"退款流程",之后又反复用到这个词,如果只按写入时间淘汰,这条记录会因为写入时间早而被优先清掉,即便它其实一直被频繁用到;按访问时间淘汰则会在每次命中时把这条记录的"最后访问时间"往前推,只要还在用,就不会被挤出去。搜索历史这类场景,LRU 明显更贴近用户的真实心智。
1const MAX_ENTRIES = 20; 2 3function readHistory(storage) { 4 return storage.get('recent-keywords', []); 5} 6 7function touchKeyword(storage, keyword) { 8 const list = readHistory(storage); 9 const now = Date.now(); 10 11 const filtered = list.filter((item) => item.keyword !== keyword); 12 filtered.push({ keyword, lastUsedAt: now }); 13 14 filtered.sort((a, b) => b.lastUsedAt - a.lastUsedAt); 15 16 const trimmed = filtered.slice(0, MAX_ENTRIES); 17 18 storage.set('recent-keywords', trimmed); 19 return trimmed; 20}
这段实现里,每次搜索都会把这个关键词从旧位置摘掉、重新插到最新的位置,再按最后使用时间倒序排列、截断到上限条数。数据结构本身很简单,一个数组加上每一项的时间戳,不需要引入额外的库。真正决定这套策略是否合用的是 MAX_ENTRIES 这个上限怎么定——定得太小,用户会觉得"我记得我搜过这个词,怎么没了";定得太大,又回到了无限增长的老问题。我们这一年的做法是先估算单条记录的字节数(一个关键词加时间戳,JSON.stringify 之后大概几十字节),再倒推一个几十到一百条量级的上限,留出充分余量,不需要每次都算到临界值。
草稿自动保存是另一个需要淘汰策略、但淘汰维度不一样的场景。搜索历史淘汰的是"单个列表内部超出条数的旧记录",草稿场景更常见的问题是"用户同时开了多篇文章在编辑,每篇都存了一份草稿,但只有当前这几篇是真的在用"。这种场景更适合按草稿本身的更新时间做整体淘汰,而不是像搜索历史那样维护一个动态排序的列表:
1const MAX_DRAFTS = 10; 2const DRAFT_EXPIRE_DAYS = 30; 3 4function pruneDrafts(storage) { 5 // storage.keys() 拿到的已经是裸类型名(不带命名空间前缀), 6 // 后面统一用 storage.get(type) / storage.remove(type) 读写, 7 // 不再直接碰 window.localStorage,避免自己手动拼一次前缀 8 const allTypes = storage.keys(); 9 const drafts = allTypes 10 .map((type) => { 11 const data = storage.get(type); 12 return { type, data }; 13 }) 14 .filter((item) => item.data?.updatedAt); 15 16 const expireBefore = Date.now() - DRAFT_EXPIRE_DAYS * 24 * 60 * 60 * 1000; 17 18 drafts 19 .filter((item) => item.data.updatedAt < expireBefore) 20 .forEach((item) => storage.remove(item.type)); 21 22 const remaining = drafts 23 .filter((item) => item.data.updatedAt >= expireBefore) 24 .sort((a, b) => b.data.updatedAt - a.data.updatedAt); 25 26 remaining.slice(MAX_DRAFTS).forEach((item) => { 27 storage.remove(item.type); 28 }); 29}
这里做了两层淘汰:先按绝对过期时间(超过 30 天没更新的草稿,大概率用户已经不需要了)清掉一批,再对剩下的按数量上限截断。两层叠加是有意为之——只按数量淘汰的话,一个用户如果同时维护的草稿数量本来就不多,但其中有几篇是半年前的僵尸草稿,永远不会被自然挤出去;只按时间淘汰的话,如果用户短时间内开了大量新草稿,还没到过期时间,数量依然会失控。这个淘汰函数不需要每次写入都跑,挂在应用初始化时机执行一次,或者定时每隔一段时间跑一次就够了,属于维护性任务而不是关键路径。
这里刻意把 pruneDrafts 内部的存取全部收拢到 storage.get/storage.remove 这一层,不再像早期版本那样直接调用 window.localStorage.getItem/removeItem。原因是一旦一部分代码走封装层、一部分代码绕开封装层直接摸原生 API,两边对"键"的抽象层级就不统一了——封装层内部的方法期待收到的是裸类型名、自己去拼前缀,如果调用方在拿到 storage.keys() 返回的裸类型名之后,误以为这是可以直接交给原生 localStorage.removeItem 使用的完整键,删除操作实际上删的是一个从未存在过的键,草稿列表在 pruneDrafts 跑完之后看起来毫无变化。统一走封装层的方法,这一层混淆就不可能发生。
淘汰策略上线之后,还需要给容量留一道兜底——万一淘汰没赶上写入速度,或者用户浏览器本身配额就设置得很小,写入依然可能失败。这时候不该让整个保存流程直接报错中断,而是退一步,主动清理一批过期数据后重试一次:
1function saveWithRetry(storage, type, value) { 2 const result = storage.set(type, value); 3 if (result.ok) { 4 return result; 5 } 6 7 pruneDrafts(storage); 8 9 return storage.set(type, value); 10}
这个重试不是无限重试,只做一次——如果清理之后依然写不进去,大概率是数据本身就超过了单条上限,或者浏览器本身处于隐私模式这类根本不允许写入的状态,继续重试没有意义,应该直接把失败结果交给上层去提示用户。
结构版本升级:字段变了,老用户浏览器里的旧数据怎么办
版本号加个字段这件事本身不难,难的是版本号不匹配之后具体怎么处理——是直接丢弃重置,还是写迁移函数尽量保留旧数据。这个选择背后其实是产品层面的取舍,不是纯技术问题。草稿这类用户投入了时间成本的数据,直接丢弃对用户体验的伤害很大,值得写迁移函数;而像筛选条件这类丢了大不了重新选一次的数据,直接重置反而更省事、更不容易因为迁移逻辑写错而引入新问题。
草稿结构升级是个值得展开的例子。假设最初的草稿只有标题和正文两个字段:
1// v1 2{ version: 1, title: '标题', content: '正文' }
后来需求变化,草稿要支持标签和分类,同时把"正文"这个字段名改成了更准确的 body:
1// v2 2{ version: 2, title: '标题', body: '正文', tags: [], category: null }
字段改名加新增字段,对新用户没有任何影响,因为新用户是直接按 v2 结构写入的。真正麻烦的是那些在字段变更之前就已经保存过草稿、又还没在这之后重新编辑过的老用户——他们浏览器里存的仍然是 v1 结构,字段名是 content 不是 body,也完全没有 tags 和 category。如果代码直接按 v2 结构去读 draft.body,会读到 undefined,草稿内容表面上"消失了",但其实数据还在,只是换了个字段名。
迁移函数要做的就是把不同版本的旧结构,统一搬运成当前版本认得的样子,而且要考虑到用户可能连续跳过了好几个版本没打开过页面,所以迁移要写成链式的、一级一级往上升,而不是假设老数据只会落后一个版本:
1const CURRENT_VERSION = 2; 2 3function migrateV1toV2(draft) { 4 return { 5 version: 2, 6 title: draft.title || '', 7 body: draft.content || '', 8 tags: [], 9 category: null, 10 }; 11} 12 13const migrations = { 14 1: migrateV1toV2, 15}; 16 17function migrateDraft(draft) { 18 let current = draft; 19 20 while (current && current.version < CURRENT_VERSION) { 21 const step = migrations[current.version]; 22 if (!step) { 23 return null; 24 } 25 current = step(current); 26 } 27 28 return current; 29}
这里 migrations 是一张从"当前版本号"到"升到下一版本的转换函数"的映射表,migrateDraft 从旧数据实际的版本号开始,顺着这张表一步步往上迁移,直到到达当前版本或者找不到对应的迁移步骤为止。这个设计的好处是以后再加 v3、v4,只需要往 migrations 表里追加一个新的转换函数,不需要改 migrateDraft 本身的逻辑,也不用担心一个停留在 v1 的用户会因为迁移函数只写了"v1 直接到 v3"这种跳跃式假设而漏掉中间步骤该做的字段填充。
读取草稿的地方统一走这个迁移函数,而不是在业务代码里到处判断版本号。这里要留意一个容易踩的细节:storage 封装层的 get/set 收到的参数应该是裸类型名,内部会自己拼上命名空间前缀,业务代码不需要、也不应该在调用时自己再拼一段前缀式的字符串进去,草稿按 id 区分时,正确的做法是把 draft 和 id 拼成一个类型名整体传进去,而不是在类型名里再嵌一层看起来像前缀的冒号分隔结构,避免以后维护的人误以为这段字符串本身已经是完整键、从而在别的地方又去拼一次真正的命名空间前缀:
1function draftType(id) { 2 return `draft-${id}`; 3} 4 5function readDraft(storage, id) { 6 const type = draftType(id); 7 const raw = storage.get(type); 8 if (!raw) { 9 return null; 10 } 11 12 const migrated = migrateDraft(raw); 13 if (!migrated) { 14 return null; 15 } 16 17 if (migrated.version !== raw.version) { 18 storage.set(type, migrated); 19 } 20 21 return migrated; 22}
这里有个细节:迁移完成之后,如果版本号确实发生了变化,会顺手把迁移后的结果重新写回存储。这样做的目的是让迁移只在这条数据第一次被读到时发生一次,之后这条数据就已经是最新结构了,不需要每次读取都重新跑一遍迁移函数——这对高频读取的场景(比如每次进编辑页都要读一次草稿)能省掉不必要的重复计算,也能避免因为某次迁移函数本身有 bug、每次都重新执行反而放大问题的影响面。
版本迁移策略不是所有数据都值得做。判断要不要写迁移函数,我们现在会先问一句:这份数据对用户来说,丢了要重新生成的成本有多高?草稿、长期积累的偏好设置这类值得迁移;像分页参数、临时排序状态这类几秒钟就能重新设置好的数据,版本不匹配直接返回 null、让业务代码走默认值,比维护一份迁移逻辑要划算得多。硬要给所有存储数据都套上迁移框架,是过度设计,维护成本会超过它带来的收益。
token 这类敏感信息,命名空间和淘汰策略都救不了它
命名空间解决的是键名冲突,容量淘汰解决的是空间占用,结构版本解决的是字段兼容,但这三层设计都建立在一个前提上:存进 localStorage 的数据本身是可以被明文读取的。任何同源运行的 JavaScript,不管是自己写的业务代码,还是引入的埋点脚本、客服组件、某个间接依赖的第三方包,都能直接执行 localStorage.getItem 把整份存储读个遍——这一点不会因为你把键名设计得再规范、加了再完善的版本号字段而改变。
登录凭证这类东西,我们这一年内部达成的共识是:不把长期有效的 token 明文放进 localStorage。如果业务上确实需要在前端保留一份能读的登录态标识,更合理的做法是后端下发一个短期有效、只用于展示"当前是否登录"这类弱状态判断的令牌,真正用来鉴权的长期凭证交给 HttpOnly 的 Cookie 管理,前端代码本身够不着它,也就不存在被 XSS 读走的风险。这个分工在我们内部经历过一次讨论——起因是权限模块想在前端根据 token 里的过期时间字段提前给用户弹一个"即将过期"的提示,这个需求本身没问题,但实现方式不该是把这枚真正用来鉴权的 token 整个丢进 localStorage 再解码判断,而是让后端专门返回一个只包含过期时间戳的轻量字段,前端拿这个字段做提示判断就够了,敏感的凭证本身完全不需要经过前端的可读存储。
命名空间设计里我们特意留了一条约定:任何被判定为敏感的数据类型,不允许进入这一层封装的 set 方法,代码评审时如果看到 storage.set('token', ...) 这类调用会被直接打回。这条约定本身依赖的不是技术手段,是团队内部达成一致之后的评审纪律——存储层的封装能挡住命名冲突和容量失控,挡不住"有人手滑把不该存的东西存进来",这一步还是要靠人来把关。
多标签页同时写同一份列表,淘汰逻辑会不会把自己写乱
搜索历史和草稿列表这类数据有个特点:它们不是"写一次读一次"的简单键值对,而是一份需要"读出旧列表、修改、再整份写回"的复合数据。这种读改写模式在只有一个标签页时没有任何问题,但中后台系统的用户经常习惯开着好几个标签页并行操作,如果用户在标签页 A 搜索了一个关键词、几乎同时又在标签页 B 搜索了另一个关键词,两个标签页各自执行的是"读当前列表、把新关键词插进去、整份写回",localStorage 本身不提供任何事务保证,后写回的那个标签页会直接用自己内存里的旧列表整份覆盖掉对方刚写入的结果,先完成的那次更新就这样悄无声息地丢了。
这个问题在草稿场景里的表现是"标签页 A 保存的这份草稿更新,被标签页 B 几乎同时触发的淘汰逻辑覆盖回了淘汰之前的状态",症状是用户明明看到过淘汰生效(列表变短了),刷新之后又发现某条本该被清掉的旧草稿重新出现了。排查这类问题的思路和排查普通竞态问题类似:先确认是不是真的存在两个标签页同时写同一个键,再看两次写入的时间间隔是不是短到中间没有任何读取介入去感知对方的变更。
彻底杜绝这类竞态需要引入锁机制,但 localStorage 本身没有锁,硬要在浏览器端模拟一套跨标签页的锁相当麻烦,性价比不高。更现实的做法是接受"极小概率下会丢一次更新"这个代价,把伤害控制在可接受范围内,具体做法有两处:一是每次写回列表之前,先重新读一次当前存储里的最新值,而不是完全依赖内存里可能已经过期的旧列表,缩小两次读写之间的时间窗口;二是配合 storage 事件,让淘汰逻辑不只在写入时机执行,也在感知到其他标签页写入变化时重新校准一次自己手里的列表:
1window.addEventListener('storage', (event) => { 2 if (!event.key?.startsWith('admin:search:')) { 3 return; 4 } 5 6 syncLocalSearchState(); 7}); 8 9function syncLocalSearchState() { 10 const latest = readHistory(searchStorage); 11 updateSearchPanelUI(latest); 12}
这段代码本身解决不了写入覆盖的根本问题,但能让界面上展示的内容尽量贴近存储里真正的最新状态,不至于因为标签页 A 的操作被标签页 B 覆盖之后,标签页 A 的界面还长时间停留在一份已经过期的数据上。真正需要强一致性保证的场景(比如两个标签页同时修改同一份订单草稿、要求绝对不能丢任何一次编辑),已经超出了 localStorage 这层能力的合理范围,应该转向以服务端为准的方案,前端本地存储只承担缓存和体验加速的角色,不该被要求扛下事务级别的正确性。
给存储层加一道容量监控,而不是等报错才发现
前面淘汰策略里提到的重试兜底,本质上是"出问题之后的补救",更主动的做法是让存储层自己感知到容量压力,在写满之前就采取行动,而不是等 setItem 抛出异常才反应。这一年可以用 navigator.storage.estimate() 拿到当前源大致的存储配额使用情况,虽然它统计的是包含 IndexedDB、Cache Storage 在内的整个源级别用量,不单独区分 localStorage 占了多少,但作为一个粗略的健康度判断已经够用:
1async function checkStorageHealth() { 2 if (!navigator.storage?.estimate) { 3 return null; 4 } 5 6 const estimate = await navigator.storage.estimate(); 7 const percentUsed = (estimate.usage / estimate.quota) * 100; 8 9 return { percentUsed, usage: estimate.usage, quota: estimate.quota }; 10}
这个函数可以挂在应用初始化的时机跑一次,如果占用比例超过某个阈值(比如 80%),主动触发一轮全量淘汰——把各个模块名下过期的草稿、超出条数上限的历史记录都清理一遍,而不是等某个具体的 setItem 调用失败之后才被动响应。这一年 navigator.storage 在 Chrome、Firefox 里已经能稳定拿到,Safari 还没跟上,判断可选链加一句短路检查就够了,不需要额外的兼容库。
命名空间设计带来的一个额外好处在这里体现得很明显:因为每个模块的键名都有统一前缀,真要精确定位到"具体是哪个模块占用了大头",可以遍历 localStorage 的所有键、按命名空间前缀分组统计每一段的字节长度,不需要凭感觉猜:
1function reportUsageByModule() { 2 const usage = {}; 3 4 for (let i = 0; i < window.localStorage.length; i++) { 5 const key = window.localStorage.key(i); 6 if (!key?.startsWith(`${NAMESPACE}:`)) { 7 continue; 8 } 9 10 const moduleName = key.split(':')[1]; 11 const value = window.localStorage.getItem(key) || ''; 12 usage[moduleName] = (usage[moduleName] || 0) + key.length + value.length; 13 } 14 15 return usage; 16}
这份统计在容量告警真正触发的时候很有用——不需要在 Application 面板里一条条肉眼核对,直接能看到是哪个模块的数据占了大头,淘汰策略也就知道该优先从哪里下手,而不是眉毛胡子一把抓地把所有模块都清一遍。
淘汰策略和版本迁移放在一起时,先后顺序会影响结果
单独看淘汰策略、单独看版本迁移,各自的逻辑都不复杂,但把两者放进同一个读取路径时,先后顺序处理不好会互相干扰。一个真实遇到的情况是:草稿读取时先跑版本迁移、再跑淘汰判断,还是先判断是否过期、再决定要不要迁移,这两种顺序在旧数据本身已经过期的场景下,结果是一样的(都应该被清掉),但在旧数据还没过期、又处于旧版本结构的场景下,顺序不对会导致迁移函数读到不完整的字段。
具体来说,如果淘汰逻辑里判断"是否过期"依赖的是 updatedAt 字段,而 v1 版本的草稿里这个字段可能叫别的名字或者干脆不存在,淘汰逻辑在迁移之前就去读 data.updatedAt,读到的是 undefined,undefined < expireBefore 这个比较的结果是 false,一份原本已经过期很久的旧草稿反而会被误判成"没有过期时间、当作还有效"永久保留下来。反过来,如果淘汰逻辑在迁移之后再执行,先把旧结构统一转换成当前版本认得的字段名,再判断是否过期,就不会有这个问题:
1function readAndMaybePrune(storage, type) { 2 const raw = storage.get(type); 3 if (!raw) { 4 return null; 5 } 6 7 const migrated = migrateDraft(raw); 8 if (!migrated) { 9 storage.remove(type); 10 return null; 11 } 12 13 const expireBefore = Date.now() - DRAFT_EXPIRE_DAYS * 24 * 60 * 60 * 1000; 14 if (migrated.updatedAt < expireBefore) { 15 storage.remove(type); 16 return null; 17 } 18 19 return migrated; 20} 21 22// 调用方传入的同样是裸类型名,和 readDraft、pruneDrafts 保持一致 23readAndMaybePrune(orderStorage, draftType('123'));
这里的顺序是先迁移、再判断过期,任何依赖字段名的判断逻辑都建立在"字段名已经是当前版本的样子"这个前提之上,不会因为旧数据字段名不一致而产生误判。这条经验背后其实是一个更通用的原则:只要一份数据同时存在"结构可能过时"和"内容可能过期"这两种状态需要处理,处理顺序永远应该是先把结构统一到当前认知,再基于统一后的结构做业务判断,颠倒过来的话,业务判断逻辑就得同时兼容所有历史版本的字段命名,代码会迅速变得难以维护。
这个函数的参数从最初写的 key 改成了 type,不只是改个名字这么简单——它强调的是这个参数从头到尾都应该是那个可以直接传给 storage.get/storage.remove 的裸类型名,不是一个已经拼过命名空间前缀的完整键。整篇文章里所有和存储打交道的函数,现在统一遵守同一条规则:只有 createNamespacedStorage 内部知道真正的前缀是什么,外部所有代码、包括 pruneDrafts、readDraft、readAndMaybePrune,拿到手的和传出去的都是裸类型名,没有任何一处会把一个已经带前缀的完整键再传回封装层的方法里去。早期的实现里,pruneDrafts 走的是 storage.keys() 返回完整键、直接调用原生 API 删除;readDraft 又是自己在类型名里拼了一段 draft: 前缀传给 storage.get;两条路径对"键"的抽象层级完全不统一,一旦有第三个函数不小心把 pruneDrafts 里拿到的完整键传给了 storage.remove,内部就会在这个已经带前缀的字符串前面再拼一次 admin:order:,变成 admin:order:admin:order:draft-123 这样对不上号的键,实际存储里那条 admin:order:draft-123 根本没被删掉。统一成"外部只认裸类型名,前缀只在封装层内部出现一次"之后,这类重复拼接的可能性从设计上就被消除了。
迁移函数怎么验证,而不是等老用户反馈才知道写错了
版本迁移这类代码有个特殊的地方:它面向的输入是"用户浏览器里可能已经存在好几年的老数据",本地开发环境几乎不可能自然产生这类数据,正常写代码、走查、联调都测不出迁移函数本身是不是写对了。等真正线上有老用户带着旧结构数据触发到这段逻辑,往往已经是发布之后的事,这时候如果迁移函数本身有问题,影响面是不可控的——不知道有多少用户手里攥着什么版本的旧数据。
这一年的做法是把迁移函数当成普通业务逻辑一样写单元测试,测试输入直接构造各个历史版本的样例数据,而不是依赖"凑巧从线上导出一份真实的旧数据":
1describe('migrateDraft', () => { 2 it('v1 结构应该正确迁移到 v2', () => { 3 const v1 = { version: 1, title: '标题', content: '正文内容' }; 4 const result = migrateDraft(v1); 5 6 expect(result.version).toBe(2); 7 expect(result.body).toBe('正文内容'); 8 expect(result.tags).toEqual([]); 9 }); 10 11 it('v1 缺少 content 字段时应该给出安全默认值', () => { 12 const broken = { version: 1, title: '标题' }; 13 const result = migrateDraft(broken); 14 15 expect(result.body).toBe(''); 16 }); 17 18 it('未知版本号应该返回 null 而不是抛异常', () => { 19 const unknown = { version: 99, title: '标题' }; 20 expect(migrateDraft(unknown)).toBeNull(); 21 }); 22});
第二个用例专门测的是"字段缺失"这种情况——旧数据不一定总是完整的,可能是更早期的一次故障导致某次写入本身就没写全,迁移函数如果假设旧结构一定包含所有预期字段,遇到这类残缺数据会直接抛异常,而不是优雅降级成一个安全的默认值。第三个用例测的是版本号本身超出已知范围的情况,这在实际场景里对应的是"用户浏览器里存的数据版本号比当前代码认识的还新"——比如用户开了两个标签页,一个页面已经刷新加载了更新后的代码把数据写成了 v3,另一个还没刷新的标签页仍在跑旧代码、只认得到 v2,这时候旧代码面对一份 v3 数据不应该崩溃,返回 null 让业务代码走一次"当作没有草稿"的默认路径,是相对安全的选择。
这几个测试用例不需要多复杂,但只要写出来一次,后面每次调整迁移函数、新增字段、修改默认值,跑一遍测试就能立刻知道有没有破坏已有版本的兼容性,不需要每次都手工在浏览器控制台里 localStorage.setItem 造一份旧数据再肉眼核对。淘汰策略里的排序和截断逻辑同样值得写几个边界用例——列表为空时、列表恰好等于上限条数时、列表远超上限时分别验证一遍,这类边界条件在实际使用中出现的频率不高,但一旦触发就是"某个用户的历史记录莫名其妙清空了"这类不好复现的反馈,提前用测试挡住比等反馈之后再排查要省事得多。
这一层封装值不值得搭
回到最开始的问题:一个中后台系统发展到十几个模块共用同一份 localStorage 空间的阶段,值不值得专门抽一层命名空间加淘汰加版本迁移的封装,还是继续让每个模块各写各的?我们的判断标准落在模块数量和数据生命周期这两件事上——如果一个项目里只有两三个模块偶尔用到本地存储,各自维护自己的键名规范、各自处理版本号,成本可以接受;一旦模块数量上到两位数、且草稿、历史记录这类数据本身有持续增长的趋势,命名冲突和容量失控几乎是必然会发生的事,与其等出问题之后逐个模块排查,不如提前把这一层统一封装起来,让新增模块直接复用现成的 createNamespacedStorage,不需要重新发明一遍键名规则和淘汰逻辑。
这套封装本身不复杂,命名空间是字符串前缀加遍历过滤,淘汰策略是排序加截断,版本迁移是一张映射表加一个循环,加起来不到两百行代码。真正的价值不在代码量,在于把这三类问题从"每个模块各自隐式处理、出问题各自排查"变成"统一显式处理、新问题在一个地方修",这是十几个模块共用一份存储空间时唯一能长期维护下去的方式。