Cal.com 前端性能实践:缓存 localStorage / sessionStorage / Cookie 同步读取(js-cache-storage 规则详解)
本文详解 Vercel React Best Practices 规则库中 js-cache-storage(Cache Storage API Calls)规则的核心原理:为什么 localStorage、sessionStorage 和 document.cookie 的同步读取属于昂贵 I/O,如何用模块级 Map 缓存把重复读取降为内存访问,以及如何通过 storage 与 visibilitychange 事件正确失效缓存。文中给出的全部代码模式均来自规则文档,并结合 Cal.com 仓库中 时钟显示模块、最近模拟记录 与 安全存储封装 的真实实现加以印证。
规则定位:在 45 条性能规则中的位置
该规则的完整定义位于 js-cache-storage.md,其 frontmatter 元信息如下:
- 标题:Cache Storage API Calls
- 影响等级:LOW-MEDIUM(impactDescription: reduces expensive I/O,即减少昂贵 I/O)
- 标签:javascript、localStorage、storage、caching、performance
从 SKILL.md 的规则分类表看,这条规则属于第 7 类 JavaScript Performance(LOW-MEDIUM),与 js-cache-function-results、js-cache-property-access、js-index-maps 等同属"计算/访问成本削减"方向;它排在 Eliminating Waterfalls(async-,CRITICAL)与 Bundle Size(bundle-,CRITICAL)之后。这意味着:它不会像消除瀑布请求那样带来数量级的提升,但在高频调用场景(渲染循环中反复读存储)下可以稳定削减同步 I/O 开销。
问题本质:存储读取是同步且昂贵的
规则文档给出的核心结论一句话概括:
localStorage、sessionStorage和document.cookie都是同步 API,读取成本高。应将读取结果缓存在内存中。
反模式示例(每次调用都触发一次存储读取):
function getTheme() {
return localStorage.getItem('theme') ?? 'light'
}
// Called 10 times = 10 storage reads
在列表渲染、每个组件各自取一次配置等场景下,"调用 10 次 = 10 次存储读取"会成倍放大。Cal.com 仓库中就有这类真实场景:useTheme 每次执行时都会执行 localStorage.getItem("app-theme")(见 packages/lib/hooks/useTheme.ts 第 16 行),而 getTheme 正是文档反例所使用的函数名,说明该模式在主题、时钟偏好这类"多处调用、值稳定"的场景中最为常见。
另一个真实反例是 recentImpersonations.ts:
// packages/lib/recentImpersonations.ts
export function getRecentImpersonations(): RecentImpersonation[] {
try {
const stored = localStorage.getItem(STORAGE_KEY); // 每次调用都读一次存储
if (!stored) return [];
return JSON.parse(stored);
} catch {
return [];
}
}
该函数每次调用都会执行一次 getItem 加一次 JSON.parse。在低频操作(记录最近模拟过的用户)中尚可接受,但若同类函数出现在渲染路径上,就是规则文档所警示的模式。
标准模式:模块级 Map 缓存
规则文档给出的正确写法是"Map 缓存":
const storageCache = new Map<string, string | null>()
function getLocalStorage(key: string) {
if (!storageCache.has(key)) {
storageCache.set(key, localStorage.getItem(key))
}
return storageCache.get(key)
}
function setLocalStorage(key: string, value: string) {
localStorage.setItem(key, value)
storageCache.set(key, value) // keep cache in sync
}
这里有三个关键设计点:
- 缓存粒度是 key。
Map<string, string | null>显式允许缓存null值——注意getItem对不存在的 key 返回null,如果只缓存"有值"的 key,不存在的 key 会每次穿透到存储,缓存对这类 key 完全失效。用has()而非get()判空正是为了区分"未查过"和"查过但不存在"。 - 写路径必须同步缓存。
setLocalStorage在写存储后立即storageCache.set(key, value),保证同一模块内的读写一致性。任何绕过该封装直接调用localStorage.setItem的代码都会破坏这一不变式。 - 用模块级变量而不是 Hook。规则文档明确说明:使用 Map(而非 Hook),使其在工具函数、事件处理器等任意位置都可用,而不限于 React 组件内。这与同目录 js-cache-function-results.md 的结论一致——该规则同样要求"module-level Map",并强调 Map 方案可以覆盖非组件上下文。
Cal.com 仓库中已经存在该模式的真实落地形态:clock.ts 用模块级 timeOptions 对象把 localStorage 中的 24 小时制偏好与时区偏好读入内存:
// apps/web/lib/clock.ts(节选)
const timeOptions: TimeOptions = {
is24hClock: false,
inviteeTimeZone: "",
};
const initClock = () => {
if (isInitialized) {
return;
}
if (getIs24hClockFromLocalStorage() === null) set24hClock(isBrowserLocale24h());
timeOptions.is24hClock = !!getIs24hClockFromLocalStorage();
timeOptions.inviteeTimeZone = localStorage.getItem("timeOption.preferredTimeZone") || CURRENT_TIMEZONE;
};
const set24hClock = (is24hClock: boolean) => {
setIs24hClockInLocalStorage(is24hClock);
timeOptions.is24hClock = is24hClock; // 写入存储的同时更新内存缓存
};
is24h() / timeZone() 读取时优先命中 timeOptions 内存值,写入时(set24hClock / setTimeZone)同步更新内存——这与规则文档"read from memory, write-through to cache"的封装完全同构。差异在于它用单对象缓存固定 key,而文档的 Map 版本可缓存任意 key,两者按场景取舍。
Cookie 缓存:一次性解析整条 document.cookie
document.cookie 的读取成本更典型:每次读取都要解析整个字符串。文档给出的模式是"懒加载整表缓存":
let cookieCache: Record<string, string> | null = null
function getCookie(name: string) {
if (!cookieCache) {
cookieCache = Object.fromEntries(
document.cookie.split('; ').map(c => c.split('='))
)
}
return cookieCache[name]
}
要点:
- 用单一布尔哨兵(
null/对象)而非 Map 的has(),因为 Cookie 表要么整表解析、要么完全未解析,不存在"部分缓存"状态; split('; ').map(c => c.split('='))假设 Cookie 值中不含=分隔冲突的简单结构,遇到带 URL 编码或=的值时,split('=')会产生多于两个元素,此时应在map中改用c.split('=')的[key, ...value]拆法自行加固(此为基于代码结构的推断,文档未展开);- Cookie 由服务端 Set-Cookie 更新,因此 Cookie 缓存天然比 localStorage 缓存更易失鲜,必须配合下一节的失效策略。
缓存失效:外部变更的两种触发源
文档特别标注了 Important 部分——如果存储可能被外部修改(另一个标签页写入、服务端下发的 Cookie),必须主动失效缓存,否则会读到过期值:
window.addEventListener('storage', (e) => {
if (e.key) storageCache.delete(e.key)
})
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
storageCache.clear()
}
})
两段监听的分工是明确的:
storage事件:只在其他标签页/窗口修改同一 origin 的存储时触发,且带有e.key(被修改的 key)。因此可以精确delete(e.key)单条失效;e.key为null表示执行了clear(),此时保守做法是清空整个storageCache。visibilitychange事件:标签页重新变为可见时整体clear()。这覆盖了"后台标签页期间被其他来源(包括服务端 Cookie、同一浏览器的其他标签页)修改"以及storage事件不可靠的边缘情况,代价只是可见性恢复后重新读一次存储。
这两条规则共同回答了缓存方案最常被质疑的问题:"内存缓存与真实存储脱节怎么办"——精确失效 + 粗粒度兜底双层设计。
与安全存储封装的组合:Cal.com 的做法
值得注意的是,Cal.com 并没有裸用 localStorage,而是通过 webstorage.ts 提供了带 try/catch 的安全封装:
// packages/lib/webstorage.ts(节选)
export const localStorage = {
getItem(key: string) {
try {
return window.localStorage.getItem(key);
} catch {
// In case storage is restricted. Possible reasons
// 1. Third Party Context in Chrome Incognito mode.
return null;
}
},
setItem(key: string, value: string) { /* 同样的 try/catch 降级 */ },
removeItem: (key: string) => { /* 同样的 try/catch 降级 */ },
};
文件头注释说明其动机:在第三方 iframe(嵌入的预订页)或 Chrome 无痕模式的第三方上下文里,window.localStorage 访问会直接抛异常;封装层将其降级为"读返回 null、写静默失败"。仓库中 useTheme、clock.ts 等模块全部 import { localStorage } from "@calcom/lib/webstorage" 而非裸 API。
将文档的缓存模式叠加在这层封装之上,就得到兼顾"少读"与"安全读"的完整形态(在文档模式基础上把底层读取替换为安全封装):
import { localStorage } from "@calcom/lib/webstorage";
const storageCache = new Map<string, string | null>();
function getLocalStorage(key: string) {
if (!storageCache.has(key)) {
storageCache.set(key, localStorage.getItem(key)); // 受限环境下安全地返回 null
}
return storageCache.get(key);
}
function setLocalStorage(key: string, value: string) {
localStorage.setItem(key, value);
storageCache.set(key, value); // keep cache in sync
}
这样在嵌入场景下,null 只会被真实读取一次并缓存,同时保留"后续标签页写入后通过 storage 事件失效"的能力。
落地清单
结合规则文档与仓库实现,在 Cal.com 前端代码中应用该规则时可对照以下要点:
- 识别高频读:凡是渲染路径、列表循环、多处组件各自执行的
localStorage.getItem/document.cookie读取,优先考虑缓存(对照反例:recentImpersonations.ts 的每次调用都getItem+JSON.parse); - 封装成 get/set 对:按 js-cache-storage.md 的 Map 模式提供
getLocalStorage/setLocalStorage,写路径同步缓存,并用has()判断以正确缓存null; - 用模块级变量而非 Hook:保证工具函数与事件处理器中同样可用;
- 加两层失效:
storage事件按 key 精确失效,visibilitychange可见时整体clear();服务端 Cookie 场景尤其依赖后者; - 底层走安全封装:Cal.com 的读取应基于 webstorage.ts 的
localStorage/sessionStorage导出,以兼容嵌入式 iframe 与无痕模式的受限存储环境; - 固定少量 key 的场景可参考 clock.ts 的单对象初始化缓存(
initClock+ 写时同步),结构更简单、无 Map 开销。
该规则的影响等级为 LOW-MEDIUM,收益集中在"昂贵 I/O 削减";在大型应用中应与同类的 js-cache-function-results(模块级 Map 缓存纯函数结果)配合使用,共同压降渲染期的重复计算与重复存储访问。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00