Plate 项目实战:用跨请求 LRU 缓存替代 React.cache(),规避服务端重复查询
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
在 React / Next.js 服务端渲染与数据获取场景中,React.cache()只能保证单个请求内的重复调用去重,一旦用户连续点击按钮 A、再点击按钮 B,两次独立请求之间依然会重复执行数据库查询。本文基于.agents/skills/vercel-react-best-practices/rules/server-cache-lru.md的规则文档,讲解如何借助lru-cache实现跨请求的内存级 LRU 缓存,并结合本仓库(Plate,开源富文本编辑器)中 highlight-code.ts 的真实落地案例,给出可直接复制运行的完整方案。读完你将掌握 LRU 缓存的容量、TTL 参数设计,以及它在 Vercel Fluid Compute 与传统 Serverless 环境下的适用边界。
一、为什么需要"跨请求"缓存:React.cache() 的能力边界
在服务端数据获取中,"请求级去重"与"跨请求缓存"是两件完全不同的事:
| 维度 | React.cache() | LRU 缓存 |
|---|---|---|
| 生效范围 | 单个请求(同一次渲染树内) | 跨多个顺序请求(进程/实例内) |
| 典型用途 | 同请求内多处调用getUser(1)只查一次库 | 用户点 A 查了用户数据,点 B 时不再查库 |
| 生命周期 | 请求结束即释放 | 按 max 容量与 ttl 过期时间管理 |
| 适用场景 | 认证检查、数据库查询、重计算等非 fetch 异步任务 | 秒级时间窗内多个端点共享同一份数据 |
在 server-cache-react.md 规则中,React.cache()被定位为请求内去重:同一请求中多次调用getCurrentUser()只执行一次查询。而本文的server-cache-lru.md规则进一步指出:当数据需要在顺序到达的多个请求之间共享时(例如用户先点击按钮 A 再点击按钮 B,两个接口都要读同一用户资料),就必须使用 LRU 缓存把结果保留在进程内存中。
在 Plate 仓库的服务端代码中,这一规则被真实落地:apps/www的 Shiki 代码高亮渲染是典型的 CPU 密集操作,同一段代码在多个请求中反复出现,如果不做跨请求缓存,每次渲染都要重新跑一遍 Shiki 分词与主题转换。
二、标准实现:lru-cache 的完整代码模板
规则文档给出的核心实现如下:
import { LRUCache } from 'lru-cache' const cache = new LRUCache<string, any>({ max: 1000, ttl: 5 * 60 * 1000 // 5 minutes }) export async function getUser(id: string) { const cached = cache.get(id) if (cached) return cached const user = await db.user.findUnique({ where: { id } }) cache.set(id, user) return user } // Request 1: DB query, result cached // Request 2: cache hit, no DB query这套模板有三个关键设计点:
- 缓存键:这里直接用
id字符串作为键。如果查询条件由多个维度组成,应拼接成稳定的字符串键(见第四节 Plate 用 SHA-256 派生键的做法)。 - 先查后写:
cache.get命中直接返回;未命中才执行真实查询,并把结果set进缓存。注意lru-cache支持返回undefined的哨兵语义,若数据本身可能是undefined/null,建议用has()判断后再取值,或给缓存包一层非空结果对象。 - 边界与顺序:缓存实例应定义在模块顶层(模块级单例),与请求无关;这样所有请求共享同一实例,才能实现"跨请求"效果。切勿把
new LRUCache()放进函数体内,否则每次调用都会新建缓存、退化为无缓存。
三、参数详解:max、ttl 与相关选项
规则文档中的max: 1000与ttl: 5 * 60 * 1000分别控制容量与过期时间,lru-cache(仓库中apps/www的依赖版本为^11.2.4,见 package.json)还提供了更多精细选项:
| 选项 | 示例 | 作用 |
|---|---|---|
max | 1000 | 最多缓存条目数,超过后淘汰最久未使用(LRU)的条目 |
maxSize+sizeCalculation | 按字节计 | 当缓存对象大小差异悬殊时,用总字节数做容量上限更合理 |
ttl | 5 * 60 * 1000 | 条目存活时间(毫秒),过期后get返回undefined并触发set时的dispose |
ttlAutopurge | true | 是否自动清除过期条目(而非惰性清除),适合需要精确控制内存的场景 |
updateAgeOnGet | true | 每次命中是否刷新条目的"新鲜度",避免热点数据被过早淘汰 |
noDisposeOnSet | true | 覆盖同键时是否跳过dispose回调,防止并发写导致旧值资源被误释放 |
dispose(value, key, reason) | 回调 | 条目被淘汰/过期/覆盖时触发,适合释放文件句柄、取消请求等资源 |
容量与 TTL 需要根据数据特性权衡:热点高、变化慢的数据(如用户资料、代码高亮 HTML)适合较长的 TTL 与较大的 max;变化快、时效强的数据(如库存、价格)应缩短 TTL 并考虑在写入时主动delete(key)失效。
四、Fluid Compute 与传统 Serverless:LRU 缓存能否生效的分水岭
规则文档强调了一个关键环境差异,直接决定 LRU 缓存的落地方式:
- Vercel Fluid Compute 环境:多个并发请求可以复用同一个函数实例,LRU 缓存因此能在请求间持久共享。此时无需引入 Redis 等外部存储,进程内缓存即可显著削减重复查询——这正是"跨请求缓存"规则最适用、收益最高的运行形态。
- 传统 Serverless 环境:每次调用在隔离实例中运行,缓存无法跨请求存活。此时应退回到 Redis / Upstash 等外部存储做跨进程缓存,进程内 LRU 仅能优化单次调用内的重复计算。
判断依据:如果你的部署平台支持实例复用(如长期运行的服务、Fluid Compute、自建 Node 服务),模块级 LRU 是零依赖成本的首选;如果是纯 FaaS 冷启动模型,就不要把 LRU 当作跨请求方案,它只能作为进程内的补充优化。
五、Plate 仓库的真实落地:Shiki 代码高亮缓存
规则并非纸上谈兵——Plate 的apps/www文档站就在 highlight-code.ts 中实现了完全一致的跨请求 LRU 缓存模式,用它对 Shiki 的 HTML 渲染结果做缓存:
import { createHash } from 'node:crypto'; import { LRUCache } from 'lru-cache'; import { codeToHtml } from 'shiki'; const highlightCache = new LRUCache<string, string>({ max: 500, ttl: 1000 * 60 * 60, }); export async function highlightCode(code: string, language = 'tsx') { const cacheKey = createHash('sha256') .update(`${language}:${code}`) .digest('hex'); const cached = highlightCache.get(cacheKey); if (cached) { return cached; } const html = await codeToHtml(code, { lang: language, themes: { dark: 'github-dark', light: 'github-light', }, transformers: [ { pre(node) { /* ...设置滚动与内边距 class... */ }, code(node) { node.properties['data-line-numbers'] = ''; }, line(node) { node.properties['data-line'] = ''; }, }, ], }); highlightCache.set(cacheKey, html); return html; }对照规则模板,可以提炼出这套生产级实现的三点升级:
- 派生缓存键:用
language + code拼原始串后经createHash('sha256')派生定长十六进制键。相比直接以长字符串为键,SHA-256 键更短、更均匀,且避免把整段代码原文当作 Map 键导致的内存驻留。 - 有界容量与时效:
max: 500、ttl: 1000 * 60 * 60(1 小时)。Shiki 高亮输出较大,容量必须封顶防止内存膨胀;文档站代码片段变化极低,1 小时 TTL 足以覆盖绝大多数重复访问。 - 批量入口不变:
highlightFiles仍通过Promise.all对注册表文件逐条调用highlightCode,并按文件扩展名(css/json/ts/tsx/js/jsx,默认tsx)推导语言,缓存层对上层完全透明。
这段实现的同步过程记录在 2026-05-30-sync-shadcn-highlight-code-cache.md,其中明确记录了落地的三个关键事实:
- 此前 Plate 对重复出现的代码片段会反复重跑 Shiki,没有任何缓存;
lru-cache原本只是传递依赖,为了在apps/www中直接import,被提升为 package.json 的直接依赖(^11.2.4);- 缓存层只负责提速,注册表文件高亮入口
highlightFiles的行为与输出完全保持不变,并通过apps/www/src/app/api/registry-source/[name]/route.test.ts的 2 个测试用例验证(见 route.test.ts)。
六、最佳实践与常见陷阱
结合规则文档与 Plate 落地经验,跨请求 LRU 缓存需要注意以下几点:
- 缓存实例必须是模块级单例:放在组件或函数内部会退化为无缓存;放在模块顶层,所有请求共享同一个实例。
- 键的设计要稳定且可序列化:优先使用原始类型拼接的字符串键,或用哈希派生键;避免直接使用每次新建的对象引用作为键(这一点与
React.cache()用Object.is判断命中的陷阱同理,详见 server-cache-react.md)。 - 为 CPU 密集型操作优先加缓存:Shiki 高亮、模板渲染、加解密、图片处理这类重计算,命中缓存能省下整个计算链路;数据库查询则需结合数据一致性要求决定 TTL 长短。
- 写入与失效路径要成对考虑:数据被更新时记得
cache.delete(key)或cache.clear(),避免脏读;并发写同一键时用noDisposeOnSet防止旧值资源被误释放。 - 确认运行环境支持实例复用:Fluid Compute 或自托管 Node 服务中 LRU 才能跨请求生效;传统 Serverless 下请改用 Redis 等外部缓存(规则文档明确建议)。
七、小结
跨请求 LRU 缓存是对React.cache()的自然延伸:前者解决"同一请求内"的去重,后者解决"顺序请求之间"的复用。以 Plate 仓库 highlight-code.ts 的 Shiki 缓存为范例,max封顶容量、ttl控制时效、SHA-256 派生稳定键,即可在几十行代码内消除服务端重复的重计算与数据库查询。部署在 Vercel Fluid Compute 这类支持实例复用的平台上时,进程内 LRU 是最轻量的跨请求缓存方案;若运行在传统 Serverless 隔离实例上,则应切换为外部缓存,避免把 LRU 误用为跨进程方案。
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考