tanstack-table 是这样用闭包的

321 阅读4分钟

个人博客:www.vino.wang

实现一个计算斐波那契数列函数

const fibonacci = (n) => {
  if (n <= 1) return n
  return fibonacci(n - 1) + fibonacci(n - 2)
}

['first','second','third'].forEach(item =>{
    console.log(item,fibonacci(40))
})

将上述代码复制到浏览器控制台执行,你会发现一次计算会比较耗时,并且三次执行的耗时是非常接近的。

我们需要做如下优化:执行三次fibonacci(40) 的结果是相同的,但是三次执行消耗了三倍的世间,能不能优化成执行三次,但是只消耗一次执行的时间呢

Memo 函数

对于上述的需求,我们可以考虑实现一个memo函数,这类似于 Vue的计算属性或者是React.memo的功能。

💡 memo 函数是一个记忆化(memoization)工具,主要用于性能优化。它的主要功能包括:

  1. 记住上一次计算的结果和依赖项
  2. 只有当依赖项发生变化时才重新计算
  3. 如果依赖项没有变化,直接返回缓存的结果
function memo(getDeps,fn) {
  let prevDeps
  let prevResult
  return () => {
    // 获取新的依赖项
    const newDeps = getDeps() // 返回依赖的数组
    // 如果是第一次运行,或者依赖项发生变化
    const depsChanged =
      !prevDeps ||
      newDeps.length !== prevDeps.length ||
      newDeps.some((dep, index) => dep !== prevDeps[index])
    
    // 如果依赖项没变,返回缓存的结果
    if (!depsChanged) {
      return prevResult
    }
    // 依赖项变化,重新计算
    prevDeps = newDeps
    prevResult = fn(...newDeps)
    return prevResult
  }
}

添加memo函数,并对调用做如下修改:

const memoizedFibonacci = memo(() => [40], fibonacci)

['first','second','third'].forEach(item =>{
    console.log(item,memoizedFibonacci())
})

复制上述代码在浏览器执行。肉眼可见第二次和第三次执行的速度是非常快的。

  • Typescript 版本

    export function memo<TDeps extends any[], TResult>(
      getDeps: () => TDeps,
      fn: (...args: TDeps) => TResult,
    ) {
      let prevDeps: TDeps | undefined
      let prevResult: TResult | undefined
      return () => {
        // 获取新的依赖项
        const newDeps = getDeps()
        // 如果是第一次运行,或者依赖项发生变化
        const depsChanged =
          !prevDeps ||
          newDeps.length !== prevDeps.length ||
          newDeps.some((dep, index) => dep !== prevDeps![index])
    
        console.log('depsChanged', depsChanged)
        // 如果依赖项没变,返回缓存的结果
        if (!depsChanged) {
          return prevResult!
        }
        // 依赖项变化,重新计算
        prevDeps = newDeps
        prevResult = fn(...newDeps)
        return prevResult
      }
    }
    

你理解闭包了吗

有没有发现,memo函数运用到的最核心的技巧就是闭包,通过闭包保存了 prevDeps 和 prevResult 的状态。

💡 什么是闭包?

闭包是由捆绑起来(封闭的)的函数和函数周围状态(词法环境)的引用组合而成。换言之,闭包让函数能访问它的外部作用域。在 JavaScript 中,闭包会随着函数的创建而同时创建。

闭包 - JavaScript | MDN

在memo 函数中,闭包的关键作用是状态保持

export function memo<TDeps extends any[], TResult>() {
  // 这两个变量通过闭包被保存在内存中
  let prevDeps: TDeps | undefined
  let prevResult: TResult | undefined
  
  return () => {
    // 内部函数可以访问并修改这些变量
    // ... 
  }
}

闭包使得:

  • prevDeps 和 prevResult 在函数多次调用之间保持状态
  • 这些变量对外部不可见,形成了私有变量
  • 返回的函数可以持续访问这些变量

实际例子

// 使用示例
const calculateSum = memo(
  () => [numbers],
  (numbers) => numbers.reduce((a, b) => a + b, 0)
)

// 第一次调用
calculateSum() // 会计算并缓存结果
// 第二次调用
calculateSum() // 如果 numbers 没变,直接返回缓存结果

如果不使用闭包,我们就需要:

  • 要么使用全局变量(不安全)
  • 要么每次都重新计算(低效)
  • 要么需要用户手动管理缓存(麻烦)

闭包的好处

  • 封装性

  • 缓存变量完全私有

  • 外部无法直接修改 prevDeps 和 prevResult

  • 状态隔离

    // 每次调用 memo 都会创建独立的闭包
    const sum1 = memo(/*...*/)
    const sum2 = memo(/*...*/)
    // sum1 和 sum2 的缓存是相互独立的
    
  • 内存管理

  • 当不再使用 memo 返回的函数时

  • 闭包中的变量会被垃圾回收

  • 不会造成内存泄漏

这就是为什么闭包在这种缓存场景中特别有用,它提供了一种优雅的方式来维护函数的内部状态。

Tanstack/table的实际应用

TanStack Table

在 tanstack/table 源代码里面,运用了大量的 memo函数,用于表格组件的列计算,排序,过滤,分组等操作。通过记忆优化可显著的提升性能。

// packages/table-core/src/core/table.ts 部分源代码

import { createRow } from '../core/row'
import { Table, Row, RowModel, RowData } from '../types'
import { getMemoOptions, memo } from '../utils'

export function getCoreRowModel<TData extends RowData>(): (
  table: Table<TData>
) => () => RowModel<TData> {
  return table =>
    memo(
      () => [table.options.data],
      (
        data
      ): {
        rows: Row<TData>[]
        flatRows: Row<TData>[]
        rowsById: Record<string, Row<TData>>
      } => {
					//.....
          return rows
        }
        rowModel.rows = accessRows(data)
        return rowModel
      },
      getMemoOptions(table.options, 'debugTable', 'getRowModel', () =>
        table._autoResetPageIndex()
      )
    )
}

完。