首页
/ Cal.com 前端性能实践:缓存 localStorage / sessionStorage / Cookie 同步读取(js-cache-storage 规则详解)

Cal.com 前端性能实践:缓存 localStorage / sessionStorage / Cookie 同步读取(js-cache-storage 规则详解)

2026-09-05 12:59:31作者:何将鹤

本文详解 Vercel React Best Practices 规则库中 js-cache-storage(Cache Storage API Calls)规则的核心原理:为什么 localStoragesessionStoragedocument.cookie 的同步读取属于昂贵 I/O,如何用模块级 Map 缓存把重复读取降为内存访问,以及如何通过 storagevisibilitychange 事件正确失效缓存。文中给出的全部代码模式均来自规则文档,并结合 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-resultsjs-cache-property-accessjs-index-maps 等同属"计算/访问成本削减"方向;它排在 Eliminating Waterfalls(async-,CRITICAL)与 Bundle Size(bundle-,CRITICAL)之后。这意味着:它不会像消除瀑布请求那样带来数量级的提升,但在高频调用场景(渲染循环中反复读存储)下可以稳定削减同步 I/O 开销。

问题本质:存储读取是同步且昂贵的

规则文档给出的核心结论一句话概括:

localStoragesessionStoragedocument.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
}

这里有三个关键设计点:

  1. 缓存粒度是 keyMap<string, string | null> 显式允许缓存 null 值——注意 getItem 对不存在的 key 返回 null,如果只缓存"有值"的 key,不存在的 key 会每次穿透到存储,缓存对这类 key 完全失效。用 has() 而非 get() 判空正是为了区分"未查过"和"查过但不存在"。
  2. 写路径必须同步缓存setLocalStorage 在写存储后立即 storageCache.set(key, value),保证同一模块内的读写一致性。任何绕过该封装直接调用 localStorage.setItem 的代码都会破坏这一不变式。
  3. 用模块级变量而不是 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.keynull 表示执行了 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、写静默失败"。仓库中 useThemeclock.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 前端代码中应用该规则时可对照以下要点:

  1. 识别高频读:凡是渲染路径、列表循环、多处组件各自执行的 localStorage.getItem / document.cookie 读取,优先考虑缓存(对照反例:recentImpersonations.ts 的每次调用都 getItem + JSON.parse);
  2. 封装成 get/set 对:按 js-cache-storage.md 的 Map 模式提供 getLocalStorage / setLocalStorage,写路径同步缓存,并用 has() 判断以正确缓存 null
  3. 用模块级变量而非 Hook:保证工具函数与事件处理器中同样可用;
  4. 加两层失效storage 事件按 key 精确失效,visibilitychange 可见时整体 clear();服务端 Cookie 场景尤其依赖后者;
  5. 底层走安全封装:Cal.com 的读取应基于 webstorage.tslocalStorage / sessionStorage 导出,以兼容嵌入式 iframe 与无痕模式的受限存储环境;
  6. 固定少量 key 的场景可参考 clock.ts 的单对象初始化缓存(initClock + 写时同步),结构更简单、无 Map 开销。

该规则的影响等级为 LOW-MEDIUM,收益集中在"昂贵 I/O 削减";在大型应用中应与同类的 js-cache-function-results(模块级 Map 缓存纯函数结果)配合使用,共同压降渲染期的重复计算与重复存储访问。

登录后查看全文
热门项目推荐
相关项目推荐