Back

JavaScript 中的实用记忆化模式

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

正确的异步记忆化

异步记忆化需要对并发调用和失败进行特殊处理:

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 最佳实践

useMemoReact.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——引用相等性。新的对象字面量会在每次渲染时破坏记忆化。

决策框架

在进行记忆化之前,问自己:

  1. 函数是纯函数吗? 没有副作用,相同的输入总是产生相同的输出。
  2. 它真的很慢吗? 先进行性能分析。不要猜测。
  3. 参数是原始类型还是稳定的引用? 对象参数需要仔细处理。
  4. 缓存的生命周期是什么? 无界缓存会造成内存泄漏。

结论

记忆化用内存换取速度。确保你得到了一笔好交易。首先通过性能分析确认确实存在性能问题,然后有选择地对具有可预测参数的纯函数应用记忆化。实施缓存限制以防止内存泄漏,仔细处理异步操作,并记住 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.

OpenReplay