React 设计精粹(1) -- fiber 节点的内存优化

219 阅读3分钟

React 是一个高度成熟的框架。在代码中可以看到非常多高度抽象的概念,如 lane,fiber,wip,current,alternate,bailout,以及最近新增的 suspense,同时代码内还有大量对边界 case 的兜底,以及警告 --- log 日志和备注上的警告。这两方面原因导致 React 源码非常不好读,虽然能理解主体逻辑,甚至还能写出一个 mini-react,但没能理解前人的巧妙设计还是让人感到遗憾。

所以这里抛砖引入,以极其主观的方式,用一系列文章中列举一些我认为值得借鉴的设计。

前言

在 React 16 之后开始出现 fiber 这个概念,不少文章会介绍 fiber 的“双缓存”技术,以实现 render 阶段(即处理 state 更新,diff 状态便后前后的 Virtual DOM,判断出那些 DOM 需要更新)可中断。

但这样会有一个问题,假设一个大型应用有 1w 个节点,双缓存也就是有 2w 个,这样势必对用户的内存是个大挑战,其次是每次更新都需要重新创建 1w 个对象和销毁 1w 个对象,GC 便是一个沉重负担。那 React 是怎么优化的呢?

懒创建

还是以刚才 1w 节点的应用为例,在初始化时,React 只会创建一份 fiber 树,也就是一开始只有 1w 个 fiber 节点,也就是说,另一颗 alternate fiber 树是按需初始化的。

我们来验证一下:

如上图所示,这是初始化时的 fiber 节点状态,他的 alternate 属性为 null,说明 React 是按需生成 fiber 节点。

同时图中第一个 snapshot 是更新状态后录制的,内存占用是 51.5MB,大概是第二个的两倍大小,Fiber 节点数量也是第二个的两倍。

另外,如果一个 fiber 节点一直没被更新的话,那么该 fiber 节点的 alternate 也会一直为空,节约内存使用。

对象池

上面介绍了 React 如何节约内存,那么频繁 GC 怎么解决?还是以 1w 个节点的应用为例,React 会保证最多只创建 2w 次 fiber 对象,后续不再销毁 --- 那么也就不需要 GC。那“对象池”是怎么实现的?

其实没有太大的技术含量,这里理解需要一点 React 上下文,但没关系,我们可以认为每次更新,React 都需要调用 createWorkInProgress 这个方法创建更新后的 fiber 节点。那 React 是如何创建 Fiber 节点的:

export function createWorkInProgress(current: Fiber, pendingProps: any): Fiber {
  let workInProgress = current.alternate;
  // 
  if (workInProgress === null) {
    workInProgress = createFiber(
      current.tag,
      pendingProps,
      current.key,
      current.mode,
    );
    // 忽略,复制 current 的属性到 workInProgress
    workInProgress.elementType = current.elementType;
    workInProgress.type = current.type;
    workInProgress.stateNode = current.stateNode;

    workInProgress.alternate = current;
    current.alternate = workInProgress;
  } else {
    // 忽略,复制 current 的属性到 workInProgress
  }
  // 不用看,就是复制 current 的属性到 workInProgress
  workInProgress.flags = current.flags & StaticMask;
  workInProgress.childLanes = current.childLanes;
  workInProgress.lanes = current.lanes;

  workInProgress.child = current.child;
  workInProgress.memoizedProps = current.memoizedProps;
  workInProgress.memoizedState = current.memoizedState;
  workInProgress.updateQueue = current.updateQueue;

  ....
  return workInProgress;
}

代码做了简化,大概逻辑是:如果 alternate 没初始化,就创建一个 fiber 实例;如果已经初始化,那么就将 current 的属性复制给 alternate(workInProgress),再设置最新的状态。

可以看到,一旦 fiber 实例创建后,便后续一直通过重新赋值的方式复用,不会再重新创建。

结束语

这和现代 js 推荐编程的方式格格不入 ---- 少重置一个状态直接 bug,重新生成一个实例不香吗?这也是 React 源码难读的原因 --- 状态漫天飞舞,然而还能迭代,只能说大佬真是大佬。

这么看,内存优化是很磨人的,如果要优化一个现代应用的内存使用,意味着需要牺牲可维护性为代价,做底层逻辑重构,重构结束还需要大量补充单测,否则这堆屎山一碰就倒。