很多同学一听到 LRU 缓存,第一反应就是“双向链表 + 哈希表”那道经典面试题。但在 Open UI5 这种企业级框架里,LRU 不是拿来应付面试的,而是要实打实地解决“跨会话重复计算”的性能问题。这篇我来拆一下 LRUPersistentCache.js 这个模块,搞清楚它在 Open UI5 里的定位、实现逻辑,以及实际使用中那些文档里不会写的问题。
先说结论:LRUPersistentCache.js 做的事情,是把 LRU(Least Recently Used)淘汰策略和浏览器持久化存储结合在一起,让一部分“算出来一次就能用很久”的数据,在页面刷新甚至浏览器重启之后还能直接复用,而不是每次启动都重新生成一遍。
1. 缓存为什么非要“持久化”:性能瓶颈到底在哪
1.1 从 LRUCache 说起:进程内缓存解决什么问题,解决不了什么
Open UI5 框架内部本来就有内存版的 LRUCache,它的作用和所有内存缓存一样:在页面运行期间,保存一些访问频率高、计算代价大的结果,避免反复执行同样的逻辑。
但内存缓存有一个天然的边界——页面一刷新,全没了。对于一次页面会话内的重复操作来说,这没问题;但对于“每次进入应用都要重复生成”的数据,内存缓存就无能为力了。
举个典型场景:一个复杂的企业级 Fiori 应用,启动时要解析库元数据、计算主题参数、汇总资源清单。这些数据本身是稳定的,不会因为用户刷新一次页面就变化。如果不做持久化,用户每次打开应用都要把这些事情重新做一遍。哪怕单次只要几百毫秒,放大到每次会话、每个用户,成本就很可观。
1.2 PersistentCache 在 Open UI5 源码中的位置与职责
LRUPersistentCache.js 在 Open UI5 里的定位,就是“带淘汰策略的跨会话存储”。它和普通 localStorage 直接读写的区别在于:
- 有容量上限,超过上限会按 LRU 顺序淘汰旧数据;
- 有结构化的读写接口,不要求使用方关心序列化细节;
- 带有过期时间(TTL)概念,可以控制缓存数据的有效周期;
- 对存储异常做了容错处理,不会因为缓存损坏导致应用崩溃。
换句话说,它是在 localStorage/sessionStorage 之上又包了一层“缓存管理逻辑”。package 里如果只看 Cache.js、Storage.js,你会发现 Cache 负责纯内存逻辑,Storage 负责底层持久化存取,而 LRUPersistentCache 是把两者的优点拼在一起。
注意,这里说的“持久化”不是数据库级别的持久化,而是相对页面生命周期而言的持久化——只要浏览器存储还在,这些缓存数据就在。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 读源码前必须建立的模型:LRU 数据结构与淘汰语义
2.1 双向链表 + Map 的经典组合,还是 Map 的边删除边插入技巧?
大学里讲 LRU 基本都是“哈希表 + 双向链表”,但 Open UI5 的实现其实更偏向一种简化写法:用 Map 模拟 LRU 语义。
如果你要自己实现一个最小版,核心逻辑是这样的:
javascript复制class LRUCache {
constructor(limit) {
this.limit = limit;
this.cache = new Map();
}
get(key) {
if (!this.cache.has(key)) {
return null;
}
// 读取即刷新:先删掉再重新插入,把 key 移到 Map 末端
const value = this.cache.get(key);
this.cache.delete(key);
this.cache.set(key, value);
return value;
}
set(key, value) {
if (this.cache.has(key)) {
this.cache.delete(key);
} else if (this.cache.size >= this.limit) {
// Map 的迭代顺序就是插入顺序,第一个 key 就是最久未使用的
this.cache.delete(this.cache.keys().next().value);
}
this.cache.set(key, value);
}
}
这种方式比手写 DoublyLinkedList 短得多,而且 JavaScript 引擎对 Map 的 delete/set 做了优化,性能未必比双向链表差。
Open UI5 的 LRUPersistentCache 在结构上也是类似思路:内部会维护一个“最近使用顺序”的记录,每次 get/set 都会更新这个顺序。底层是否每一条都直接写进 localStorage,则需要区分:有些元数据适合全量加载后一次性写入,有些则需要增量更新,这要结合缓存数据的大小来决定。
2.2 命中、更新、淘汰三个核心操作的时序
LRU 最核心的三个操作是 get、set、evict。理解它们的时序,基本就理解了整个模块。
get(key) 的流程是:
- 尝试从持久化存储中取出对应记录;
- 如果记录不存在,返回 null,这是一次 miss;
- 如果记录存在,检查 TTL,已过期则删除并返回 null;
- 如果没过期,把该 key 的记录“提前”到最近使用的位置,并返回值。
set(key, value) 的流程是:
- 判断 key 是否已存在,存在则先移除旧记录;
- 判断当前容量是否已满,满了则淘汰最久未使用的记录;
- 写入新记录并标记为最近使用。
evict(淘汰)在这里不是一个显式方法,它发生在 set 过程中。容量判断的依据通常是“记录条数”或“存储体积”,两者各有取舍。只看条数的话实现简单,但可能每条数据体积差异很大,导致实际空间不可控;只看体积的话更精确,但每次写入都要扫描一遍所有记录来算体积,成本偏高。Open UI5 这种框架级缓存,通常会选一个折中策略:以条数为主,但写入前对单条数据大小做预检。
2.3 为什么是 LRU 而不是 LFU:真实页面场景的访问特征
面试里常被问“LRU 和 LFU 的区别”,落到 Open UI5 的真实场景里其实很好选。
LFU(Least Frequently Used)看的是访问频率,适合访问模式长期稳定的场景,但它在 Web 前端有两个问题:
- 需要维护每个 key 的访问计数,存储额外开销更大;
- 对新数据不友好——一条新数据就算马上要用,频率也比不上历史高频数据,容易被“饿死”。
LRU 只看“最近有没有被用过”,实现开销小,而且和 Web 应用的实际访问模式很匹配:用户进入一个页面,短时间内通常会反复读取同一批元数据;离开页面切换到别的功能后,上一批数据的访问热度自然下降,下次再回来再重新加载即可。
3. LRUPersistentCache.js 的落盘机制与实现拆解
3.1 序列化与存储后端的配合
持久化缓存绕不开序列化。localStorage 里只能存字符串,所以任何要缓存的对象,最后都要经过 JSON.stringify 变成字符串。
但“变成字符串”只是第一步,还有一些细节需要处理:
javascript复制const STORAGE_KEY_PREFIX = "ui5.persistentcache";
const TTL_DEFAULT = 7 * 24 * 60 * 60 * 1000; // 默认 7 天
function serialize(key, value, ttl) {
return JSON.stringify({
value: value,
storedAt: Date.now(),
expiresAt: ttl ? Date.now() + ttl : 0
});
}
function deserialize(raw) {
try {
const record = JSON.parse(raw);
if (record.expiresAt && Date.now() > record.expiresAt) {
return null;
}
return record.value;
} catch (e) {
// 数据损坏,直接当 miss 处理
return null;
}
}
你是不是觉得这个封装很简单?没错,核心就是这么朴素。真正复杂的地方在于:怎么把 LRU 的“顺序信息”也持久化下来。
因为 localStorage 本身是键值对存储,没有“顺序”概念。框架只能把顺序保存在另一个键里,例如维护一个数组 ["key3", "key1", "key2"],每次命中后更新数组顺序。这样一来,每次 beyond 操作都需要先读出顺序列表,再做数组操作,再写回。这个“顺序列表”就是整个模块最容易出 bug 的地方。
3.2 容量上限与 TTL 的设计考虑
容量上限决定了缓存膨胀的上限。Open UI5 的 LRUPersistentCache 在实例化时可以传入配置项,其中比较关键的有:
| 配置项 | 作用 | 常见值 |
|---|---|---|
orderKey |
保存 LRU 顺序的存储键名 | "ui5.lru.order" |
ttl |
缓存过期时间 | 7 天 / 30 天 |
maxItems |
最大缓存条数 | 100 ~ 200 |
storage |
底层存储对象 | window.localStorage |
关于 TTL,有一个容易被忽略的点:TTL 应该从“写入时间”算起,还是从“最后一次访问时间”算起?
- 从写入时间算,逻辑简单,但可能导致“明明用户昨天还在用,今天凌晨过期了”的问题;
- 从最后一次访问时间算,更贴近真实使用习惯,但每次 get 都要更新过期时间,写入频率会高很多。
框架级实现通常采用“写入时间 + 固定 TTL”的策略,原因在于:很多元数据本身就是跟着版本号走的,与其用访问时间续期,不如干脆等它过期后重新生成。这样存储写入次数更少,也更可控。
3.3 失效恢复与版本保护
持久化缓存比内存缓存多了一个头疼的问题:存储里的数据可能是旧的、损坏的,甚至是被其他标签页改了一半的。
首先是解析失败。JSON.parse 遇到非法字符串会抛异常,不能让它直接冒泡到业务代码里。正确的做法是在反序列化入口统一 try/catch,解析失败就当作缓存未命中。
其次是版本问题。我参与一个项目时就踩过这类坑:先上线了 v1 的缓存逻辑,后来字段结构改了,没有改缓存前缀,导致旧数据被当成新数据用。一种安全的做法是给缓存键加“版本前缀”:
javascript复制const CACHE_VERSION = "v2";
const STORAGE_KEY_PREFIX = `ui5.persistentcache.${CACHE_VERSION}.`;
只要改了缓存结构,就顺手改版本号,旧数据自然失效。这个习惯能省很多排查时间。
第三是降级策略。如果 localStorage 本身不可用(比如某些隐私模式、浏览器禁用存储),不能直接抛错。此时应该自动降级为内存缓存,或者直接放弃缓存,保证功能正常。
4. 在 Open UI5 运行时里,谁在用这个缓存,怎么配合
4.1 典型消费场景:库元数据和资源清单的跨会话复用
Open UI5 的模块加载和库管理非常依赖“元数据”。每次启动应用,框架需要知道:
- 当前用了哪些库里有哪些组件、控件;
- 每个库的版本号是多少;
- 语言资源文件(
messagebundle.properties)应该加载哪个; - 主题参数应该在哪些 CSS 变量之间做映射。
这些元数据计算一次之后,只要依赖的库版本不变,结果就是稳定的。把它们存进 LRUPersistentCache,可以显著减少重复计算。
比如库元数据可以组织成这种缓存键:
text复制ui5.library.sap.m.version -> "1.120.0"
ui5.library.sap.ui.core.libMeta -> {"name":"sap.ui.core","version":"1.120.0",...}
ui5.library.sap.m.controls -> ["Button","Input","Dialog",...]
下次启动时,先查缓存,命中就直接用;不命中再走一次完整的库解析流程,并把结果写回去。
4.2 与同步加载、异步加载的关系
Open UI5 早期版本大量依赖同步加载,sap-ui-core.js 里很多逻辑是阻塞式的。引入持久化缓存后,加载路径变成了:
- 启动阶段读取
localStorage中的元数据; - 如果命中,跳过解析逻辑,直接进入控件初始化;
- 如果未命中,加载对应库资源并解析,然后把结果写入持久化缓存。
由于 localStorage 的读取在同步模式下是很快的(几十微秒到几毫秒级别),相比重新解析整个库来说,这点开销可以忽略。异步模式下的使用方式也类似,只要保证缓存的读写都在同一个事件循环或 Promise 链里即可。
这种“先查缓存,再走完整逻辑”的模式,其实是所有缓存系统共通的套路。难点不在模式本身,而在于保证缓存键设计的唯一性和失效策略的准确性。
4.3 缓存键(Cache Key)设计:一个反直觉的坑
缓存键设计是最容易被低估的部分。很多人直接把业务对象的 id 当 key,用起来才发现有问题。
在 Open UI5 的场景里,一个合理的缓存 key 建议包含这几个维度:
- 数据所属域:是库元数据、主题参数,还是资源清单;
- 实体标识:具体是哪个库、哪个组件;
- 版本信息:依赖的框架版本或数据版本;
- 语言环境(如果和国际化相关)。
比如:
text复制ui5.i18n.messagebundle.sap.m.zh_CN.v1
比裸的 "sap.m.messagebundle" 要稳得多。版本信息和语言环境错了,轻则数据浪费,重则界面显示错误。
5. 实测下来最容易踩的坑,以及对应的排查方法
5.1 怎么判断缓存到底有没有命中
判断缓存有没有生效,最直接的办法是在 get 分支埋点。打开开发者工具,在关键调用处打印或计数:
javascript复制const hitCount = window.__cacheHitCount || 0;
const missCount = window.__cacheMissCount || 0;
也可以直接用 chrome://discards、Performance 面板里的 localStorage 读写统计大致估算。但最直观的还是业务侧埋点:
- 在
getEntry返回非 null 时,hitCount++; - 在返回 null 时,
missCount++; - 刷新页面后再看两个计数,如果命中数一直为 0,说明缓存键没对上,或者 TTL 设置太短。
我遇到过一个很隐蔽的情况:代码里设置了 7 天 TTL,但实际单位写成了毫秒,导致 7 毫秒就过期。这种问题光看业务效果很难发现,最后是看存储里的 expiresAt 字段比对当前时间才定位出来的。
5.2 localStorage 配额与存储膨胀问题
localStorage 的配额一般浏览器是 5MB 左右。如果一个应用缓存的数据量特别大,比如把整个库的资源文件都塞进去,很容易撑爆。
这里有个实测数据供参考:一个包含 200 个控件的库,它的元数据 JSON 序列化后大约 50KB 到 200KB。如果缓存 3 到 5 个库,也就是 1MB 以内,5MB 配额暂时安全。但如果把 CSS 内容、字体文件也往里塞,那就危险了。
要避免存储膨胀,建议做两件事:
- 只缓存“派生数据”,不缓存原始资源内容;
- 在写入前估算
JSON.stringify后的体积,超过阈值直接放弃缓存。
javascript复制const MAX_ITEM_SIZE = 256 * 1024; // 256KB
if (serialized.length > MAX_ITEM_SIZE) {
storage.removeItem(key);
return null;
}
5.3 跨标签页同步与“最后写入覆盖”的冲突
浏览器里多个标签页打开同一个应用时,localStorage 是共享的。这时如果两个标签页同时往同一个 key 写入数据,会出现“最后写入覆盖”的问题。再加上 LRU 顺序列表的更新,如果两个标签页各自维护一份“顺序数组”,互相覆盖,最终顺序可能错乱。
常用的解法是给每个缓存记录加一个时间戳或版本号,写入前做一次简单比对:
javascript复制function writeWithGuard(storageKey, newRecord, comparisonField = "storedAt") {
const oldRaw = storage.getItem(storageKey);
if (oldRaw) {
const oldRecord = JSON.parse(oldRaw);
if (oldRecord[comparisonField] > newRecord[comparisonField]) {
return false; // 旧数据更新,放弃写入
}
}
storage.setItem(storageKey, JSON.stringify(newRecord));
return true;
}
这个法子不能解决所有并发问题,但至少能把“互相覆盖脏数据”的概率降到很低。如果业务场景对缓存一致性要求极高,建议干脆不用持久化缓存,改为每次请求都重新加载线上最新数据。
5.4 隐私模式下 localStorage 不可用的降级处理
Safari 旧版隐私模式、部分安卓 WebView 下,localStorage 的操作会直接抛异常,特别是 setItem。
处理方式很简单:把所有存储读写都包在 try/catch 里,捕获异常后降级到内存缓存:
javascript复制let persistentStorage = null;
try {
const testKey = "__ui5_test__";
window.localStorage.setItem(testKey, "1");
window.localStorage.removeItem(testKey);
persistentStorage = window.localStorage;
} catch (e) {
persistentStorage = null;
}
初始化时做一次探测,后面所有读写都判断 persistentStorage 是否可用。这样即使存储不可用,也不影响核心功能。
5.5 不要缓存不可序列化的对象
持久化缓存只能存“可以被 JSON 序列化”的数据。函数、DOM 节点、Promise 实例、循环引用对象,都不能直接缓存。
有人会问:那我缓存一个返回 Promise 的函数行不行?不行,函数序列化后会被丢掉,恢复出来的只是一个 undefined。
如果非要缓存异步结果,正确做法是“缓存结果,不缓存 Promise”。等 Promise resolve 之后,把最终结果写进缓存,下次直接读结果。
javascript复制async function getResourceWithCache(key) {
const cached = cache.get(key);
if (cached !== null) {
return cached;
}
const data = await fetchResource(key);
cache.set(key, data);
return data;
}
6. 从一个真实项目看 LRUPersistentCache 的调优过程
去年我参与过一个基于 Open UI5 构建的报表类应用,启动时库加载和初始化平均要 4 到 5 秒,其中大概有 800 毫秒左右花在解析库元数据和构建资源索引上。
第一版上持久化缓存后,启动时间降了大约 400 到 500 毫秒,但命中率只有 60% 左右,排查发现两个问题:
- 缓存键里没有带框架版本号,升级 Open UI5 补丁版本后,大量缓存还在被读取,但因为数据格式没变所以不报错,只是白白占用空间;
- 库版本检查逻辑写得太激进,每次启动都把库版本信息重新拉一遍,导致一部分缓存永远命中不了。
修正方案是:
- 缓存键统一改成
baseKey + 版本号; - 版本检查改为“小时级 TTL”,而不是每次启动都做;
- 把一次启动需要用的多条缓存数据打包成一个“会话快照”,减少
localStorage的getItem次数。
经过这三项调整,启动时间最终稳定在 2.5 秒左右,缓存命中率提升到 90% 以上。
从这次调优里我体会最深的一点是:持久化缓存不是简单地“加一层存储”就完事,它是一个涉及 key 设计、TTL 策略、容量控制和异常降级的系统工程。LRUPersistentCache.js 的价值不在于代码多华丽,而在于它把这几件事都想到了。
如果你正准备在自己的 Open UI5 项目里引入持久化缓存,我的建议是先别急着改框架代码,找个稳定的元数据场景试一试——把一次计算开销较大、结果稳定、可序列化的数据放进去,跑几天看看命中率和存储占用,再逐步扩大范围。这种“先局部验证、再全面铺开”的方式,比上来就大改要稳得多。
