JavaScript 中的实用记忆化模式
你已经对应用进行了性能分析,发现某个函数使用相同的输入运行了数千次。记忆化似乎是显而易见的解决方案。但在你用缓存包装所有内容之前,你应该知道:错误的记忆化会产生比最初的性能问题更难发现的 bug。
本文涵盖了实用的 JavaScript 记忆化模式、JavaScript 开发者常遇到的记忆化陷阱,以及如何在生产代码中安全地应用这些技术——包括异步记忆化和 React useMemo 最佳实践。
核心要点
- 记忆化基于参数缓存函数结果,用内存换取速度——但只有在纯函数和原始类型参数的情况下才能可靠工作。
- 当数据变化时,对象引用会导致过期的缓存命中;使用像 fast-memoize 这样的库来自定义键生成,或坚持使用原始类型。
- 无界缓存会在长时间运行的应用中造成内存泄漏;实施 LRU 淘汰、TTL 过期,或将缓存范围限定在组件生命周期内。
- 异步记忆化需要立即缓存 promise 并删除失败的条目,以防止重复请求并支持重试。
- React 的 useMemo 是一种针对性的优化,而非默认选项——先进行性能分析,只在计算可测量地缓慢时才应用它。
记忆化实际上做什么
记忆化基于函数的参数缓存函数结果。使用相同的输入再次调用函数时,你会得到缓存的结果而不是重新计算。
JavaScript 没有内置的记忆化功能。TC39 曾讨论过提案(例如 Function.prototype.memo),但目前还没有可用于生产的方案(提案链接)。你需要自己实现或使用库。
以下是单参数函数的基本模式:
function memoize(fn) {
const cache = new Map()
return (...args) => {
const key = args[0]
if (cache.has(key)) return cache.get(key)
const result = fn(...args)
cache.set(key, result)
return result
}
}
这对原始类型参数有效。对其他所有情况,它会以微妙的方式失效。
对象引用问题
对象是按引用而非按值缓存的。这经常困扰开发者:
const memoizedFn = memoize(processData)
const config = { threshold: 10 }
memoizedFn(config) // 计算
config.threshold = 20
memoizedFn(config) // 返回过期的缓存结果
相同的引用意味着缓存命中,即使数据已经改变。
一些开发者尝试通过使用 JSON.stringify(args) 作为缓存键来解决这个问题。这对简单数据可能有效,但在循环引用时会失败,会丢失函数和 symbol,并且对大型对象可能很慢。
解决方案: 只对具有原始类型参数的函数进行记忆化,或使用像 fast-memoize 这样的库,它支持自定义键解析器/序列化器来处理更复杂的情况。
记忆化何时会导致问题
非纯函数
对非纯函数进行记忆化会产生难以调试的问题:
// 永远不要对此进行长期缓存
const getData = memoize(() => {
return fetch('/api/data').then(r => r.json())
})
第一次调用会缓存 promise。每次后续调用都返回同一个 promise——即使服务器数据已经改变。
异步记忆化只有在你有意想要去重并发请求,或者同时实现了失效或基于 TTL 的过期机制(下文会介绍)时才是安全的。
无界缓存增长
没有缓存淘汰策略,你的缓存会无限增长:
// 等待发生的内存泄漏
const processUserInput = memoize((input) => expensiveOperation(input))
每个唯一的输入都会添加到缓存中。在长时间运行的应用中,这会造成内存泄漏。
解决方案:
- 设置最大缓存大小(LRU 淘汰)
- 添加 TTL(生存时间)过期
- 将缓存范围限定在组件生命周期内
function memoizeWithLimit(fn, maxSize = 100) {
const cache = new Map()
return (...args) => {
const key = args[0]
if (cache.has(key)) return cache.get(key)
if (cache.size >= maxSize) {
const firstKey = cache.keys().next().value
cache.delete(firstKey)
}
const result = fn(...args)
cache.set(key, result)
return result
}
}
Discover how at OpenReplay.com.
正确的异步记忆化
异步记忆化需要对并发调用和失败进行特殊处理:
function memoizeAsync(fn) {
const cache = new Map()
return async (...args) => {
const key = args[0]
if (cache.has(key)) return cache.get(key)
const promise = fn(...args).catch(err => {
cache.delete(key) // 不要缓存失败
throw err
})
cache.set(key, promise)
return promise
}
}
立即缓存 promise。这可以防止重复的并发请求。失败时删除,以便重试能够工作。
React useMemo 最佳实践
useMemo 和 React.memo 是针对性的优化,而非默认选项。误用时它们会增加复杂性并可能损害性能(参见 React 官方文档:https://react.dev/reference/react/useMemo)。
何时使用 useMemo:
- 从 props/state 计算派生数据可测量地缓慢时
- 你正在向已记忆化的子组件传递对象时
何时跳过 useMemo:
- 计算很简单时
- 你还没有测量到性能问题时
// 可能不必要
const doubled = useMemo(() => value * 2, [value])
// 可能有用
const sortedItems = useMemo(
() => items.slice().sort((a, b) => a.name.localeCompare(b.name)),
[items]
)
React 的依赖项比较使用 Object.is——引用相等性。新的对象字面量会在每次渲染时破坏记忆化。
决策框架
在进行记忆化之前,问自己:
- 函数是纯函数吗? 没有副作用,相同的输入总是产生相同的输出。
- 它真的很慢吗? 先进行性能分析。不要猜测。
- 参数是原始类型还是稳定的引用? 对象参数需要仔细处理。
- 缓存的生命周期是什么? 无界缓存会造成内存泄漏。
结论
记忆化用内存换取速度。确保你得到了一笔好交易。首先通过性能分析确认确实存在性能问题,然后有选择地对具有可预测参数的纯函数应用记忆化。实施缓存限制以防止内存泄漏,仔细处理异步操作,并记住 React 的 useMemo 是一个优化工具——而不是默认模式。做得对时,记忆化可以消除冗余计算。做错时,它会引入比性能提升更持久的微妙 bug。
常见问题
对 API 调用进行记忆化是有风险的,因为服务器数据会随时间变化。如果你缓存 promise,后续调用会返回过期数据。只有在你明确想要去重并发请求,并且实现了缓存失效或 TTL 过期以定期刷新数据时,才对 API 调用进行记忆化。
对象是按引用而非按值进行比较的。如果你修改一个对象并再次调用记忆化函数,它会返回缓存的结果,因为引用没有改变。使用不可变数据模式,创建新对象而不是修改,或者对简单情况使用 JSON.stringify 序列化参数。
使用浏览器 DevTools 或 Node.js 性能分析工具在前后进行性能分析。测量执行时间和内存使用。如果函数运行不频繁或计算很快,记忆化的开销可能超过节省的时间。缓存命中率也很重要——低命中率意味着浪费内存而收益甚微。
不应该。useMemo 会增加依赖项跟踪和比较的开销。对于简单计算,如基本数学运算或字符串拼接,记忆化的成本超过了计算成本。将 useMemo 保留给昂贵的操作,如对大型数组排序、复杂过滤,或创建传递给已记忆化子组件的对象。
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Check our GitHub repo and join the thousands of developers in our community.