React源码05 - 各类组件的 Update

多种不同类型的组件的更新过程,以及如何遍历节点形成新的 Fiber 树,即 reconcilerChildren 调和子节点的过程。

-1. 入口和优化

  • 判断组件更新是否可以优化
  • 根据节点类型分发处理
  • 根据 expirationTime 等信息判断是否可以跳过

帮助优化整个树的更新过程的方法。
只有 ReactDOM.render() 的时才会更新 RootFiber,其后的更新都是在子节点上。

workLoop

function workLoop(isYieldy) {
  if (!isYieldy) {
    // Flush work without yielding
    while (nextUnitOfWork !== null) {
      nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
    }
  } else {
    // Flush asynchronous work until the deadline runs out of time.
    while (nextUnitOfWork !== null && !shouldYield()) {
      nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
    }
  }
}

performUnitOfWork:更新子树,调用了 beginWork:

function performUnitOfWork(workInProgress: Fiber): Fiber | null {
  const current = workInProgress.alternate;
  // See if beginning this work spawns more work.
  startWorkTimer(workInProgress);
  let next;
    if (enableProfilerTimer) {
    if (workInProgress.mode & ProfileMode) {
      startProfilerTimer(workInProgress);
    }

    next = beginWork(current, workInProgress, nextRenderExpirationTime);
    workInProgress.memoizedProps = workInProgress.pendingProps;

    if (workInProgress.mode & ProfileMode) {
      // Record the render duration assuming we didn't bailout (or error).
      stopProfilerTimerIfRunningAndRecordDelta(workInProgress, true);
    }
  } else {
    // 这里返回子节点
    next = beginWork(current, workInProgress, nextRenderExpirationTime);
    workInProgress.memoizedProps = workInProgress.pendingProps;
  }
  if (next === null) {
    // If this doesn't spawn new work, complete the current work.
    next = completeUnitOfWork(workInProgress);
  }
  ReactCurrentOwner.current = null;
  return next;
}

beginWork:

  • 判断如果是非首次渲染(current !== null):

新老 props 一样,而且本次更新任务的优先级并没有超过现有任务的最高优先级,则做一些优化的工作,然后调用 xxx 用于跳过当前 Fiber 树及其子节点的所有更新。

  • 然后可能是非首次但没能跳过,也可能仍然是首次渲染(代码太多,没贴)。
function beginWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderExpirationTime: ExpirationTime,
): Fiber | null {
  const updateExpirationTime = workInProgress.expirationTime;

  // 传入的 current,第一次渲染
  if (current !== null) {
    const oldProps = current.memoizedProps;
    const newProps = workInProgress.pendingProps;
    if (
      oldProps === newProps &&
      !hasLegacyContextChanged() &&
      (updateExpirationTime === NoWork ||
        updateExpirationTime > renderExpirationTime)
    ) {
      // 处理不同类型的节点
      // This fiber does not have any pending work. Bailout without entering
      // the begin phase. There's still some bookkeeping we that needs to be done
      // in this optimized path, mostly pushing stuff onto the stack.
      switch (workInProgress.tag) {
        case HostRoot:
            // 太多,暂略
      }
      // 用于跳过子节点的更新
      return bailoutOnAlreadyFinishedWork(
        current,
        workInProgress,
        renderExpirationTime,
      );
    }
  }
    
  // 然后可能是非首次但没能跳过,也可能仍然是首次渲染(代码太多,没贴)。
}

bailoutOnAlreadyFinishedWork:
用于跳过子节点的更新。
但也要看任务优先级也不紧急的话,就函数返回 null,外部的 while 遍历就停止了,也就跳过了所有子组件的更新。
但如果优先级更高的话,则克隆 current 上面的 child 并返回,然后再返回到 workLoop 中,进入下次 child 更新循环,去尝试更新子节点。这就是个不断向下遍历节点的过程。

function bailoutOnAlreadyFinishedWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderExpirationTime: ExpirationTime,
): Fiber | null {
  cancelWorkTimer(workInProgress);

  if (current !== null) {
    // Reuse previous context list
    workInProgress.firstContextDependency = current.firstContextDependency;
  }

  if (enableProfilerTimer) {
    // Don't update "base" render times for bailouts.
    stopProfilerTimerIfRunning(workInProgress);
  }

  // Check if the children have any pending work.
  const childExpirationTime = workInProgress.childExpirationTime;
  if (
    childExpirationTime === NoWork ||
    childExpirationTime > renderExpirationTime
  ) {
    // The children don't have any work either. We can skip them.
    // TODO: Once we add back resuming, we should check if the children are
    // a work-in-progress set. If so, we need to transfer their effects.
    return null;
  } else {
    // This fiber doesn't have work, but its subtree does. Clone the child
    // fibers and continue.
    cloneChildFibers(current, workInProgress);
    return workInProgress.child;
  }
}

0. 各种不同类型组件的更新

先说整体概念,既然是不同类型的组件更新,因此关注的粒度就是在一整棵 fiber 树中,某一层是某一种类型的组件,其上的更新。而其子组件的更新,会在下一次 workLoop 遍历的时候再真正处理。

接下来是各种组件类型的更新,也就是调和 Fiber 子节点的过程。
在 react-reconciler/ReactFiberBeginWork.js/beginWork() 方法中:

Fiber 上的 tag 标记了不同的组件类型,在这里用作 switch 的判断,根据不同组件类型分别进行 fiber 的调和更新:

  switch (workInProgress.tag) {
    case IndeterminateComponent: {
      const elementType = workInProgress.elementType;
      return mountIndeterminateComponent(
        current,
        workInProgress,
        elementType,
        renderExpirationTime,
      );
    }
    case LazyComponent: {
      const elementType = workInProgress.elementType;
      return mountLazyComponent(
        current,
        workInProgress,
        elementType,
        updateExpirationTime,
        renderExpirationTime,
      );
    }
    case FunctionComponent: {
      const Component = workInProgress.type;
      const unresolvedProps = workInProgress.pendingProps;
      const resolvedProps =
        workInProgress.elementType === Component
          ? unresolvedProps
          : resolveDefaultProps(Component, unresolvedProps);
      return updateFunctionComponent(
        current,
        workInProgress,
        Component,
        resolvedProps,
        renderExpirationTime,
      );
    }
    case ClassComponent: {
      const Component = workInProgress.type;
      const unresolvedProps = workInProgress.pendingProps;
      const resolvedProps =
        workInProgress.elementType === Component
          ? unresolvedProps
          : resolveDefaultProps(Component, unresolvedProps);
      return updateClassComponent(
        current,
        workInProgress,
        Component,
        resolvedProps,
        renderExpirationTime,
      );
    }
    case HostRoot:
      return updateHostRoot(current, workInProgress, renderExpirationTime);
    case HostComponent:
      return updateHostComponent(current, workInProgress, renderExpirationTime);
    case HostText:
      return updateHostText(current, workInProgress);
    case SuspenseComponent:
      return updateSuspenseComponent(
        current,
        workInProgress,
        renderExpirationTime,
      );
    case HostPortal:
      return updatePortalComponent(
        current,
        workInProgress,
        renderExpirationTime,
      );
    case ForwardRef: {
      const type = workInProgress.type;
      const unresolvedProps = workInProgress.pendingProps;
      const resolvedProps =
        workInProgress.elementType === type
          ? unresolvedProps
          : resolveDefaultProps(type, unresolvedProps);
      return updateForwardRef(
        current,
        workInProgress,
        type,
        resolvedProps,
        renderExpirationTime,
      );
    }
    case Fragment:
      return updateFragment(current, workInProgress, renderExpirationTime);
    case Mode:
      return updateMode(current, workInProgress, renderExpirationTime);
    case Profiler:
      return updateProfiler(current, workInProgress, renderExpirationTime);
    case ContextProvider:
      return updateContextProvider(
        current,
        workInProgress,
        renderExpirationTime,
      );
    case ContextConsumer:
      return updateContextConsumer(
        current,
        workInProgress,
        renderExpirationTime,
      );
    case MemoComponent: {
      const type = workInProgress.type;
      const unresolvedProps = workInProgress.pendingProps;
      const resolvedProps = resolveDefaultProps(type.type, unresolvedProps);
      return updateMemoComponent(
        current,
        workInProgress,
        type,
        resolvedProps,
        updateExpirationTime,
        renderExpirationTime,
      );
    }
    case SimpleMemoComponent: {
      return updateSimpleMemoComponent(
        current,
        workInProgress,
        workInProgress.type,
        workInProgress.pendingProps,
        updateExpirationTime,
        renderExpirationTime,
      );
    }
    case IncompleteClassComponent: {
      const Component = workInProgress.type;
      const unresolvedProps = workInProgress.pendingProps;
      const resolvedProps =
        workInProgress.elementType === Component
          ? unresolvedProps
          : resolveDefaultProps(Component, unresolvedProps);
      return mountIncompleteClassComponent(
        current,
        workInProgress,
        Component,
        resolvedProps,
        renderExpirationTime,
      );
    }
    default:
      invariant(
        false,
        'Unknown unit of work tag. This error is likely caused by a bug in ' +
          'React. Please file an issue.',
      );
  }

1. Function component 的更新

updateFunctionComponent:
之前说过每个 Fiber 节点上的 type 就是指 createReactElement 时传入第一个参数,即 函数/class/原生dom标签字符串/内置的某些类型(如React.Fragment 什么的,大多数时候会是个 symbol 标记)。
所以从 type 上获取对应的组件函数,传入 nextProps 和 context 执行后获取 nextChildren,也就是函数组件返回的东西,作为自己的 children。
但是 children 是 react element,因此需要还需要调用 reconcileChildren 涉及到 转化为 Fiber 对象和更新等。
然后返回 workInProgress.child,因为刚才 reconcileChildren 时会把处理好的 fiber 挂载到 child 上。

函数组件的更新如此看来是比较简单的,主要复杂的地方在 reconcileChildren 的过程中。

function updateFunctionComponent(
  current,
  workInProgress,
  Component,
  nextProps: any,
  renderExpirationTime,
) {
  const unmaskedContext = getUnmaskedContext(workInProgress, Component, true);
  const context = getMaskedContext(workInProgress, unmaskedContext);

  let nextChildren;
  prepareToReadContext(workInProgress, renderExpirationTime);
  if (__DEV__) {
    ReactCurrentOwner.current = workInProgress;
    ReactCurrentFiber.setCurrentPhase('render');
    nextChildren = Component(nextProps, context);
    ReactCurrentFiber.setCurrentPhase(null);
  } else {
    // 这里调用函数组件,传入props和context,等到该函数组件的子 element 树。
    nextChildren = Component(nextProps, context);
  }

  // React DevTools reads this flag.
  workInProgress.effectTag |= PerformedWork;
  // 复杂的在这个方法中
  reconcileChildren(
    current,
    workInProgress,
    nextChildren,
    renderExpirationTime,
  );
  return workInProgress.child;
}

2. reconcileChildren

  • 根据 reactElement 上的 props.children 生成 fiber 子树。
  • 判断 Fiber 对象是否可以复用。因为只有第一次是整体全部渲染,而后续更新时自然要考虑复用。
  • 列表根据 key 优化。
  • 最终迭代处理完整个 fiber 树。

调和子节点,主要分为第一次渲染,和后续更新。二者区别通过变量 shouldTrackSideEffects “是否追踪副作用” 来区分,也就是非第一次渲染,会涉及到相关副作用的处理和复用。
reconcileChildren:

export function reconcileChildren(
  current: Fiber | null,
  workInProgress: Fiber,
  nextChildren: any,
  renderExpirationTime: ExpirationTime,
) {
  if (current === null) {
    workInProgress.child = mountChildFibers(
      workInProgress,
      null,
      nextChildren,
      renderExpirationTime,
    );
  } else {
    workInProgress.child = reconcileChildFibers(
      workInProgress,
      current.child,
      nextChildren,
      renderExpirationTime,
    );
  }
}
export const reconcileChildFibers = ChildReconciler(true);
export const mountChildFibers = ChildReconciler(false);

props.children 中的合法的成员主要就是 数组/字符串/数字 以及 react element。

  • React.Fragment 的是临时的节点,渲染更新时要被跳过,newChild = newChild.props.children,也就是把下一层的 children 赋值为当前某个待更新的组件的 children。
  • 找到可复用的节点进行 return,而不可复用的节点,也就是 key 变了,就不会复用老的 fiber,老的 fiber 被删除。就涉及到重新创建子节点。

而重新创建子节点时要看子节点的类型:

对于 REACT_ELEMENT_TYPE:
在 reconcileSingleElement 中根据不同的组件类型,得到不同的 fiberTag,然后调用 createFiber(fiberTag, pendingProps, key, mode) 创建创建不同的 Fiber。

对于 string 或者 number,也就是文本节点:
只看第一个节点是不是文本节点:

  • 如果此前老的第一个子节点也是文本节点,那么就复用留着,而删除相邻节点,因为现在要更新为文本节点了,所以留一个节点就够用了。
  • 如果不是,那么就整个删除老的子节点。

对于 Array 或者 IteratorFn(有迭代器的函数):下一节再说。
**
如果以上情况都不符合,那就全部当做非法(我编的术语)子节点,因此就将其全部“删除”即可。

嘴上说着删除,但实际上,不能真的删,现在是在 workInProgress fiber 树上进行更新操作,并不会真的删除 dom,而只是打相应的标记,是删除操作?那就给 fiber 节点打上 Deletion 标记,也就是:
childToDelete.effectTag = Deletion。

之前说过更新分两个阶段,render (有可能被打断) 和 commit (不会被打断) 阶段,在 render 阶段为这些 fiber 打上相应的操作标记后,在后面的 commit 阶段在根据这些标记,去真正的操作浏览器 dom。

3. key 和数组调和

  • key 的作用。作为对比判断依据,从而尽量复用老的 fiber 节点。
  • 对比数组 children 是否可复用。
  • generator 和 Array 的区别,基本差不多,只是前者是 ES6 迭代器相关知识,需要不断调用 next() 来获取成员。

使用 react 时如果返回的是数组(如使用 Array.prototype.map),需要为每个子项指定 key 值。
**
以相同顺序分别遍历新老 children,对比 key 是否相同 来决定是否复用老的 fiber 节点:
直到遇到 key 开始不相同了,就不再对标着复用,而此时 props.children 也就是 react element 的数组还有剩余,也就是还没全部转化为 fiber。那么有两种情况:

  • 对位的老的子节点 oldFiber 已经用完了,那么就为剩余未转换的 react element 每个都单独创建 fiber 对象。
  • 如果 oldFiber 还有剩余,只是一一对位的 key 开始变得和新的 key 不匹配,所以才打断了第一阶段的复用。但其实还有机会进行复用,可以遍历剩余的 oldFiber,以其 key 作为 Map 数据结构的 key,进行存储。然后看新的 key 是否能从 Map 中找到相应的 oldFiber,以便进行复用。这说明本次更新中,某个节点是位置只是位置顺序变了。还是可以找到并复用的。Map 中剩余的就是真的没用了,就标记为删除。

4. ClassComponent

在 react hooks 出现之前,唯一能引起二次更新的方法,就是 class 实例上的 setState 和 forceUpdate

  • 计算新的 state:会使用 Object.assign({}, preState, particalState),用局部 state 对 preState 进行浅覆盖,来生成新的 state。
  • 在 class 实例上,分别根据初次渲染还是后续更新来调用不同的生命周期方法。

5. IndeterminateComponent

在最初第一次渲染时,对于所有的 functionalComponent 都初始标记为 IndeterminateComponent 类型,
然后主要根据其返回的 value 中是否有 render 方法,从而才将 workInProgress.tag 其进一步明确为 ClassComponent 还是 FunctionComponent。
基于内部这种判断逻辑,我们竟然可以通过在函数式组件中返回的对象上提供 render 函数,以此将函数式组件“模拟”出了 class 组件的形式。这算是个小 hack 技巧,实际中应该没人这么干。

import React from 'react'

export default function TestIndeterminateComponent() {
  return {
    componentDidMount() {
      console.log('invoker')
    },
    render() {
      return <span>aaa</span>
    },
  }
}

6. HostRoot

该特殊类型对应的是 FiberRoot 节点。

7. HostComponent & HostText

  • HostComponent:原生 dom 节点,也就是 jsx 中小写的那种。
  • HostText:文本节点。

8. PortalComponent

独特地方在于其需要有单独的挂载点。

9. ForwardRef

  • 下次更新传入的 ref 如果没变化,会跳过当前节点的更新( bailoutOnAlreadyFinishedWork )。
  • 要注意被 ForwardRef 包裹后的组件内部获取不到外部提供的 context。
  • 然后同样是调和子节点,根据调用 render 得到新的 react element,调和为相应的 fiber 节点。

10. Mode

  • ConCurrentMode
  • StrictMode

这样的组件类型其实只是一种标记,在 Fiber 的 mode 属性(通过位运算)上进行记录,在后面的创建更新时,mode 作为计算不同的 expirationTime 的依据。

11. MemoComponent

本质的更新逻辑和 FunctionalComponent 一样,只是多了一步对新老 props 的 shallowEqual 浅比较,从而有机会跳过本次更新。

LazyComponent 和 SuspenseComponent 后面单独研究。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269