10

点击进入React源码调试仓库。

概述

每个fiber节点在更新时都会经历两个阶段:beginWork和completeWork。在Diff之后(详见深入理解React Diff原理),workInProgress节点就会进入complete阶段。这个时候拿到的workInProgress节点都是经过diff算法调和过的,也就意味着对于某个节点来说它fiber的形态已经基本确定了,但除此之外还有两点:

  • 目前只有fiber形态变了,对于原生DOM组件(HostComponent)和文本节点(HostText)的fiber来说,对应的DOM节点(fiber.stateNode)并未变化。
  • 经过Diff生成的新的workInProgress节点持有了flag(即effectTag)

基于这两个特点,completeWork的工作主要有:

  • 构建或更新DOM节点,

    • 构建过程中,会自下而上将子节点的第一层第一层插入到当前节点。
    • 更新过程中,会计算DOM节点的属性,一旦属性需要更新,会为DOM节点对应的workInProgress节点标记Update的effectTag。
  • 自下而上收集effectList,最终收集到root上

对于正常执行工作的workInProgress节点来说,会走以上的流程。但是免不了节点的更新会出错,所以对出错的节点会采取措施,这涉及到错误边界以及Suspense的概念,本文只做简单流程分析。

这一节涉及的知识点有

  • DOM节点的创建以及挂载
  • DOM属性的处理
  • effectList的收集
  • 错误处理

流程

completeUnitOfWork是completeWork阶段的入口。它内部有一个循环,会自下而上地遍历workInProgress节点,依次处理节点。

对于正常的workInProgress节点,会执行completeWork。这其中会对HostComponent组件完成更新props、绑定事件等DOM相关的工作。

function completeUnitOfWork(unitOfWork: Fiber): void {
  let completedWork = unitOfWork;
  do {
    const current = completedWork.alternate;
    const returnFiber = completedWork.return;

    if ((completedWork.effectTag & Incomplete) === NoEffect) {
      // 如果workInProgress节点没有出错,走正常的complete流程
      ...

      let next;

      // 省略了判断逻辑
      // 对节点进行completeWork,生成DOM,更新props,绑定事件
      next = completeWork(current, completedWork, subtreeRenderLanes);

      if (next !== null) {
        // 任务被挂起的情况,
        workInProgress = next;
        return;
      }

      // 收集workInProgress节点的lanes,不漏掉被跳过的update的lanes,便于再次发起调度
      resetChildLanes(completedWork);

      // 将当前节点的effectList并入父级节点
       ...

      // 如果当前节点他自己也有effectTag,将它自己
      // 也并入到父级节点的effectList
    } else {
      // 执行到这个分支说明之前的更新有错误
      // 进入unwindWork
      const next = unwindWork(completedWork, subtreeRenderLanes);
      ...

    }

    // 查找兄弟节点,若有则进行beginWork -> completeWork
    const siblingFiber = completedWork.sibling;
    if (siblingFiber !== null) {

      workInProgress = siblingFiber;
      return;
    }
    // 若没有兄弟节点,那么向上回到父级节点
    // 父节点进入complete
    completedWork = returnFiber;
    // 将workInProgress节点指向父级节点
    workInProgress = completedWork;
  } while (completedWork !== null);

  // 到达了root,整棵树完成了工作,标记完成状态
  if (workInProgressRootExitStatus === RootIncomplete) {
    workInProgressRootExitStatus = RootCompleted;
  }
}

由于React的大部分的fiber节点最终都要体现为DOM,所以本文主要分析HostComponent相关的处理流程。

function completeWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {

  ...

  switch (workInProgress.tag) {
    ...
    case HostComponent: {
      ...
      if (current !== null && workInProgress.stateNode != null) {
        // 更新
      } else {
        // 创建
      }
      return null;
    }
    case HostText: {
      const newText = newProps;
      if (current && workInProgress.stateNode != null) {
        // 更新
      } else {
        // 创建
      }
      return null;
    }
    case SuspenseComponent:
    ...
  }
}

由completeWork的结构可以看出,就是依据fiber的tag做不同处理。对HostComponent 和 HostText的处理是类似的,都是针对它们的DOM节点,处理方法又会分为更新和创建。

若current存在并且workInProgress.stateNode(workInProgress节点对应的DOM实例)存在,说明此workInProgress节点的DOM节点已经存在,走更新逻辑,否则进行创建。

DOM节点的更新实则是属性的更新,会在下面的DOM属性的处理 -> 属性的更新中讲到,先来看一下DOM节点的创建和插入。

DOM节点的创建和插入

我们知道,此时的completeWork处理的是经过diff算法之后产生的新fiber。对于HostComponent类型的新fiber来说,它可能有DOM节点,也可能没有。没有的话,就需要执行先创建,再插入的操作,由此引入DOM的插入算法。

if (current !== null && workInProgress.stateNode != null) {
    // 表明fiber有dom节点,需要执行更新过程
} else {
    // fiber不存在DOM节点
    // 先创建DOM节点
    const instance = createInstance(
      type,
      newProps,
      rootContainerInstance,
      currentHostContext,
      workInProgress,
    );

    //DOM节点插入
    appendAllChildren(instance, workInProgress, false, false);

    // 将DOM节点挂载到fiber的stateNode上
    workInProgress.stateNode = instance;

    ...

}

需要注意的是,DOM的插入并不是将当前DOM插入它的父节点,而是将当前这个DOM节点的第一层子节点插入到它自己的下面。

图解算法

此时的completeWork阶段,会自下而上遍历workInProgress树到root,每经过一层都会按照上面的规则插入DOM。下边用一个例子来理解一下这个过程。

这是一棵fiber树的结构,workInProgress树最终要成为这个形态。

  1              App
                  |
                  |
  2              div
                /
               /
  3        <List/>--->span
            /
           /
  4       p ----> 'text node'
         /
        /
  5    h1

构建workInProgress树的DFS遍历对沿途节点一路beginWork,此时已经遍历到最深的h1节点,它的beginWork已经结束,开始进入completeWork阶段,此时所在的层级深度为第5层。

第5层

  1              App
                  |
                  |
  2              div
                /
               /
  3        <List/>
            /
           /
  4       p
         /
        /
  5--->h1

此时workInProgress节点指向h1的fiber,它对应的dom节点为h1,dom标签创建出来以后进入appendAllChildren,因为h1没有子节点,所以它的child为null,无子节点可插入,退出。

h1节点完成工作往上返回到第4层的p节点。

此时的dom树为

      h1

第4层

  1              App
                  |
                  |
  2              div
                /
               /
  3        <List/>
            /
           /
  4 --->  p ----> 'text node'
         /
        /
  5    h1

此时workInProgress节点指向p的fiber,它对应的dom节点为p,进入appendAllChildren,发现 p 的child为 h1,并且是HostComponent组件,将 h1 插入 p,然后寻找子节点h1是否有同级的sibling节点。
发现没有,退出。

p节点的所有工作完成,workImProgress指针指向它的兄弟节点:HostText类型的组件'text',执行beginWork再进入completeWork。现在需要对'text'执行appendAllChildren,发现没有child,不执行插入操作。'text'的工作也完成,return到父节点<List/>,进入第3层

此时的dom树为

        p      'text'
       /
      /
     h1

第3层

  1              App
                  |
                  |
  2              div
                /
               /
  3 --->   <List/>--->span
            /
           /
  4       p ----> 'text'
         /
        /
  5    h1

此时workInProgress节点指向<List/>的fiber,对它进行completeWork,由于此时它是自定义组件,不属于HostComponent,所以不会对它进行子节点的插入操作。

寻找它的兄弟节点span,对span先进行beginWork再进行到completeWork,执行span子节点的插入操作,发现它没有child,退出。return到父节点div,进入第二层。

此时的dom树为

                span

        p      'text'
       /
      /
     h1

第2层

  1              App
                  |
                  |
  2 --------->   div
                /
               /
  3        <List/>--->span
            /
           /
  4       p ---->'text'
         /
        /
  5    h1

此时workInProgress节点指向div的fiber,对它进行completeWork,执行div的子节点插入。由于它的child是<List/>,不满足node.tag === HostComponent || node.tag === HostText的条件,所以不会将它插入到div中。继续向下找<List/>的child,发现是p,将P插入div,然后寻找p的sibling,发现了'text',将它也插入div。之后再也找不到同级节点,此时回到第三层的<List/>节点。

<List/>有sibling节点span,将span插入到div。由于span没有子节点,退出。

此时的dom树为

             div
          /   |   \
         /    |    \
       p   'text'  span
      /
     /
    h1

第1层
此时workInProgress节点指向App的fiber,由于它是自定义节点,所以不会对它进行子节点的插入操作。

到此为止,dom树基本构建完成。在这个过程中我们可以总结出几个规律:

  1. 向节点中插入dom节点时,只插入它子节点中第一层的dom。可以把这个插入可以看成是一个自下而上收集dom节点的过程。第一层子节点之下的dom,已经在第一层子节点执行插入时被插入第一层子节点了,从下往上逐层completeWork
    的这个过程类似于dom节点的累加。
  2. 总是优先看本身可否插入,再往下找,之后才是找sibling节点。

这是由于fiber树和dom树的差异导致,每个fiber节点不一定对应一个dom节点,但一个dom节点一定对应一个fiber节点。

   fiber树      DOM树

   <App/>       div
     |           |
    div        input
     |
  <Input/>
     |
   input

由于一个原生DOM组件的子组件有可能是类组件或函数组件,所以会优先检查自身,发现自己不是原生DOM组件,不能被插入到父级fiber节点对应的DOM中,所以要往下找,直到找到原生DOM组件,执行插入,最后再从这一层找同级的fiber节点,同级节点也会执行先自检,再检查下级,再检查下级的同级的操作。

可以看出,节点的插入也是深度优先。值得注意的是,这一整个插入的流程并没有真的将DOM插入到真实的页面上,它只是在操作fiber上的stateNode。真实的插入DOM操作发生在commit阶段。

节点插入源码

下面是插入节点算法的源码,可以对照上面的过程来看。

  appendAllChildren = function(
    parent: Instance,
    workInProgress: Fiber,
    needsVisibilityToggle: boolean,
    isHidden: boolean,
  ) {
    // 找到当前节点的子fiber节点
    let node = workInProgress.child;
    // 当存在子节点时,去往下遍历
    while (node !== null) {
      if (node.tag === HostComponent || node.tag === HostText) {
        // 子节点是原生DOM 节点,直接可以插入
        appendInitialChild(parent, node.stateNode);
      } else if (enableFundamentalAPI && node.tag === FundamentalComponent) {
        appendInitialChild(parent, node.stateNode.instance);
      } else if (node.tag === HostPortal) {
        // 如果是HostPortal类型的节点,什么都不做
      } else if (node.child !== null) {
        // 代码执行到这,说明node不符合插入要求,
        // 继续寻找子节点
        node.child.return = node;
        node = node.child;
        continue;
      }
      if (node === workInProgress) {
        return;
      }
      // 当不存在兄弟节点时往上找,此过程发生在当前completeWork节点的子节点再无子节点的场景,
      // 并不是直接从当前completeWork的节点去往上找
      while (node.sibling === null) {
        if (node.return === null || node.return === workInProgress) {
          return;
        }
        node = node.return;
      }
      // 当不存在子节点时,从sibling节点入手开始找
      node.sibling.return = node.return;
      node = node.sibling;
    }
  };

DOM属性的处理

上面的插入过程完成了DOM树的构建,这之后要做的就是为每个DOM节点计算它自己的属性(props)。由于节点存在创建和更新两种情况,所以对属性的处理也会区别对待。

属性的创建

属性的创建相对更新来说比较简单,这个过程发生在DOM节点构建的最后,调用finalizeInitialChildren函数完成新节点的属性设置。

if (current !== null && workInProgress.stateNode != null) {
    // 更新
} else {
    ...
    // 创建、插入DOM节点的过程
    ...

    // DOM节点属性的初始化
    if (
      finalizeInitialChildren(
        instance,
        type,
        newProps,
        rootContainerInstance,
        currentHostContext,
      )
     ) {
       // 最终会依据textarea的autoFocus属性
       // 来决定是否更新fiber
       markUpdate(workInProgress);
     }
}

finalizeInitialChildren最终会调用setInitialProperties,来完成属性的设置。这个过程好理解,主要就是调用setInitialDOMProperties将属性直接设置进DOM节点(事件在这个阶段绑定)

function setInitialDOMProperties(
  tag: string,
  domElement: Element,
  rootContainerElement: Element | Document,
  nextProps: Object,
  isCustomComponentTag: boolean,
): void {
  for (const propKey in nextProps) {
    const nextProp = nextProps[propKey];
    if (propKey === STYLE) {
      // 设置行内样式
      setValueForStyles(domElement, nextProp);
    } else if (propKey === DANGEROUSLY_SET_INNER_HTML) {
      // 设置innerHTML
      const nextHtml = nextProp ? nextProp[HTML] : undefined;
      if (nextHtml != null) {
        setInnerHTML(domElement, nextHtml);
      }
    }
     ...
     else if (registrationNameDependencies.hasOwnProperty(propKey)) {
      // 绑定事件
      if (nextProp != null) {
        ensureListeningTo(rootContainerElement, propKey);
      }
    } else if (nextProp != null) {
      // 设置其余属性
      setValueForProperty(domElement, propKey, nextProp, isCustomComponentTag);
    }
  }
}

属性的更新

若对已有DOM节点进行更新,说明只对属性进行更新即可,因为节点已经存在,不存在删除和新增的情况。updateHostComponent函数负责HostComponent对应DOM节点属性的更新,代码不多很好理解。

  updateHostComponent = function(
    current: Fiber,
    workInProgress: Fiber,
    type: Type,
    newProps: Props,
    rootContainerInstance: Container,
  ) {
    const oldProps = current.memoizedProps;
    // 新旧props相同,不更新
    if (oldProps === newProps) {
      return;
    }

    const instance: Instance = workInProgress.stateNode;
    const currentHostContext = getHostContext();

    // prepareUpdate计算新属性
    const updatePayload = prepareUpdate(
      instance,
      type,
      oldProps,
      newProps,
      rootContainerInstance,
      currentHostContext,
    );

    // 最终新属性被挂载到updateQueue中,供commit阶段使用
    workInProgress.updateQueue = (updatePayload: any);

    if (updatePayload) {
      // 标记workInProgress节点有更新
      markUpdate(workInProgress);
    }
  };

可以看出它只做了一件事,就是计算新的属性,并挂载到workInProgress节点的updateQueue中,它的形式是以2为单位,index为偶数的是key,为奇数的是value:

[ 'style', { color: 'blue' }, title, '测试标题' ]

这个结果由diffProperties计算产生,它对比lastProps和nextProps,计算出updatePayload。

举个例子来说,有如下组件,div上绑定的点击事件会改变它的props。

class PropsDiff extends React.Component {
    state = {
        title: '更新前的标题',
        color: 'red',
        fontSize: 18
    }
    onClickDiv = () => {
        this.setState({
            title: '更新后的标题',
            color: 'blue'
        })
    }
    render() {
        const { color, fontSize, title } = this.state
        return <div
            className="test"
            onClick={this.onClickDiv}
            title={title}
            style={{color, fontSize}}
            {...this.state.color === 'red' && { props: '自定义旧属性' }}
        >
            测试div的Props变化
        </div>
    }
}

lastProps和nextProps分别为

lastProps
{
  "className": "test",
  "title": "更新前的标题",
  "style": { "color": "red", "fontSize": 18},
  "props": "自定义旧属性",
  "children": "测试div的Props变化",
  "onClick": () => {...}
}

nextProps
{
  "className": "test",
  "title": "更新后的标题",
  "style": { "color":"blue", "fontSize":18 },
  "children": "测试div的Props变化",
  "onClick": () => {...}
}

它们有变化的是propsKey是style、title、props,经过diff,最终打印出来的updatePayload为

[
   "props", null,
   "title", "更新后的标题",
   "style", {"color":"blue"}
]

diffProperties内部的规则可以概括为:

若有某个属性(propKey),它在

  • lastProps中存在,nextProps中不存在,将propKey的value标记为null表示删除
  • lastProps中不存在,nextProps中存在,将nextProps中的propKey和对应的value添加到updatePayload
  • lastProps中存在,nextProps中也存在,将nextProps中的propKey和对应的value添加到updatePayload

对照这个规则看一下源码:

export function diffProperties(
  domElement: Element,
  tag: string,
  lastRawProps: Object,
  nextRawProps: Object,
  rootContainerElement: Element | Document,
): null | Array<mixed> {

  let updatePayload: null | Array<any> = null;

  let lastProps: Object;
  let nextProps: Object;

  ...

  let propKey;
  let styleName;
  let styleUpdates = null;

  for (propKey in lastProps) {
    // 循环lastProps,找出需要标记删除的propKey
    if (
      nextProps.hasOwnProperty(propKey) ||
      !lastProps.hasOwnProperty(propKey) ||
      lastProps[propKey] == null
    ) {
      // 对propKey来说,如果nextProps也有,或者lastProps没有,那么
      // 就不需要标记为删除,跳出本次循环继续判断下一个propKey
      continue;
    }
    if (propKey === STYLE) {
      // 删除style
      const lastStyle = lastProps[propKey];
      for (styleName in lastStyle) {
        if (lastStyle.hasOwnProperty(styleName)) {
          if (!styleUpdates) {
            styleUpdates = {};
          }
          styleUpdates[styleName] = '';
        }
      }
    } else if(/*...*/) {
      ...
      // 一些特定种类的propKey的删除
    } else {
      // 将其他种类的propKey标记为删除
      (updatePayload = updatePayload || []).push(propKey, null);
    }
  }
  for (propKey in nextProps) {
    // 将新prop添加到updatePayload
    const nextProp = nextProps[propKey];
    const lastProp = lastProps != null ? lastProps[propKey] : undefined;
    if (
      !nextProps.hasOwnProperty(propKey) ||
      nextProp === lastProp ||
      (nextProp == null && lastProp == null)
    ) {
      // 如果nextProps不存在propKey,或者前后的value相同,或者前后的value都为null
      // 那么不需要添加进去,跳出本次循环继续处理下一个prop
      continue;
    }
    if (propKey === STYLE) {
      /*
      * lastProp: { color: 'red' }
      * nextProp: { color: 'blue' }
      * */
      // 如果style在lastProps和nextProps中都有
      // 那么需要删除lastProps中style的样式
      if (lastProp) {
        // 如果lastProps中也有style
        // 将style内的样式属性设置为空
        // styleUpdates = { color: '' }
        for (styleName in lastProp) {
          if (
            lastProp.hasOwnProperty(styleName) &&
            (!nextProp || !nextProp.hasOwnProperty(styleName))
          ) {
            if (!styleUpdates) {
              styleUpdates = {};
            }
            styleUpdates[styleName] = '';
          }
        }
        // 以nextProp的属性名为key设置新的style的value
        // styleUpdates = { color: 'blue' }
        for (styleName in nextProp) {
          if (
            nextProp.hasOwnProperty(styleName) &&
            lastProp[styleName] !== nextProp[styleName]
          ) {
            if (!styleUpdates) {
              styleUpdates = {};
            }
            styleUpdates[styleName] = nextProp[styleName];
          }
        }
      } else {
        // 如果lastProps中没有style,说明新增的
        // 属性全部可放入updatePayload
        if (!styleUpdates) {
          if (!updatePayload) {
            updatePayload = [];
          }
          updatePayload.push(propKey, styleUpdates);
          // updatePayload: [ style, null ]
        }
        styleUpdates = nextProp;
        // styleUpdates = { color: 'blue' }
      }
    } else if (/*...*/) {
      ...
      // 一些特定种类的propKey的处理
    } else if (registrationNameDependencies.hasOwnProperty(propKey)) {
      if (nextProp != null) {
        // 重新绑定事件
        ensureListeningTo(rootContainerElement, propKey);
      }
      if (!updatePayload && lastProp !== nextProp) {
        // 事件重新绑定后,需要赋值updatePayload,使这个节点得以被更新
        updatePayload = [];
      }
    } else if (
      typeof nextProp === 'object' &&
      nextProp !== null &&
      nextProp.$$typeof === REACT_OPAQUE_ID_TYPE
    ) {
      // 服务端渲染相关
      nextProp.toString();
    } else {
       // 将计算好的属性push到updatePayload
      (updatePayload = updatePayload || []).push(propKey, nextProp);
    }
  }
  if (styleUpdates) {
    // 将style和值push进updatePayload
    (updatePayload = updatePayload || []).push(STYLE, styleUpdates);
  }
  console.log('updatePayload', JSON.stringify(updatePayload));
  // [ 'style', { color: 'blue' }, title, '测试标题' ]
  return updatePayload;
}

DOM节点属性的diff为workInProgress节点挂载了带有新属性的updateQueue,一旦节点的updateQueue不为空,它就会被标记上Update的effectTag,commit阶段会处理updateQueue。

if (updatePayload) {
  markUpdate(workInProgress);
}

effect链的收集

经过beginWork和上面对于DOM的操作,有变化的workInProgress节点已经被打上了effectTag。

一旦workInProgress节点持有了effectTag,说明它需要在commit阶段被处理。每个workInProgress节点都有一个firstEffect和lastEffect,是一个单向链表,来表示它自身以及它的子节点上所有持有effectTag的workInProgress节点。completeWork阶段在向上遍历的过程中也会逐层收集effect链,最终收集到root上,供接下来的commit阶段使用。

实现上相对简单,对于某个workInProgress节点来说,先将它已有的effectList并入到父级节点,再判断它自己有没有effectTag,有的话也并入到父级节点。

 /*
* effectList是一条单向链表,每完成一个工作单元上的任务,
* 都要将它产生的effect链表并入
* 上级工作单元。
* */
// 将当前节点的effectList并入到父节点的effectList
if (returnFiber.firstEffect === null) {
  returnFiber.firstEffect = completedWork.firstEffect;
}
if (completedWork.lastEffect !== null) {
  if (returnFiber.lastEffect !== null) {
    returnFiber.lastEffect.nextEffect = completedWork.firstEffect;
  }
  returnFiber.lastEffect = completedWork.lastEffect;
}

// 将自身添加到effect链,添加时跳过NoWork 和
// PerformedWork的effectTag,因为真正
// 的commit用不到
const effectTag = completedWork.effectTag;

if (effectTag > PerformedWork) {
  if (returnFiber.lastEffect !== null) {
    returnFiber.lastEffect.nextEffect = completedWork;
  } else {
    returnFiber.firstEffect = completedWork;
  }
  returnFiber.lastEffect = completedWork;
}

每个节点都会执行这样的操作,最终当回到root的时候,root上会有一条完整的effectList,包含了所有需要处理的fiber节点。

错误处理

completeUnitWork中的错误处理是错误边界机制的组成部分。

错误边界是一种React组件,一旦类组件中使用了getDerivedStateFromErrorcomponentDidCatch,就可以捕获发生在其子树中的错误,那么它就是错误边界。

回到源码中,节点如果在更新的过程中报错,它就会被打上Incomplete的effectTag,说明节点的更新工作未完成,因此不能执行正常的completeWork,要走另一个判断分支进行处理。

if ((completedWork.effectTag & Incomplete) === NoEffect) {

} else {
  // 有Incomplete的节点会进入到这个判断分支进行错误处理
}

Incomplete从何而来

什么情况下节点会被标记上Incomplete呢?这还要从最外层的工作循环说起。

concurrent模式的渲染函数:renderRootConcurrent之中在构建workInProgress树时,使用了try...catch来包裹执行函数,这对处理报错节点提供了机会。

do {
    try {
      workLoopConcurrent();
      break;
    } catch (thrownValue) {
      handleError(root, thrownValue);
    }
  } while (true);

一旦某个节点执行出错,会进入handleError函数处理。该函数中可以获取到当前出错的workInProgress节点,除此之外我们暂且不关注其他功能,只需清楚它调用了throwException

throwException会为这个出错的workInProgress节点打上Incomplete 的 effectTag,表明未完成,在向上找到可以处理错误的节点(即错误边界),添加上ShouldCapture 的 effectTag。

另外,创建代表错误的update,getDerivedStateFromError放入payload,componentDidCatch放入callback。最后这个update入队节点的updateQueue。

throwException执行完毕,回到出错的workInProgress节点,执行completeUnitOfWork,目的是将错误终止到当前的节点,因为它本身都出错了,再向下渲染没有意义。

function handleError(root, thrownValue):void {
  ...

  // 给当前出错的workInProgress节点添加上 Incomplete 的effectTag
  throwException(
    root,
    erroredWork.return,
    erroredWork,
    thrownValue,
    workInProgressRootRenderLanes,
  );

  // 开始对错误节点执行completeWork阶段
  completeUnitOfWork(erroredWork);

  ...

}

重点:从发生错误的节点往上找到错误边界,做记号,记号就是ShouldCapture 的 effectTag。

错误边界再次更新

当这个错误节点进入completeUnitOfWork时,因为持有了Incomplete,所以不会进入正常的complete流程,而是会进入错误处理的逻辑。

错误处理逻辑做的事情:

  • 对出错节点执行unwindWork
  • 将出错节点的父节点(returnFiber)标记上Incomplete,目的是在父节点执行到completeUnitOfWork的时候,也能被执行unwindWork,进而验证它是否是错误边界。
  • 清空出错节点父节点上的effect链。

这里的重点是unwindWork会验证节点是否是错误边界,来看一下unwindWork的关键代码:

function unwindWork(workInProgress: Fiber, renderLanes: Lanes) {
  switch (workInProgress.tag) {
    case ClassComponent: {

      ...

      const effectTag = workInProgress.effectTag;
      if (effectTag & ShouldCapture) {
        // 删它上面的ShouldCapture,再打上DidCapture
        workInProgress.effectTag = (effectTag & ~ShouldCapture) | DidCapture;

        return workInProgress;
      }
      return null;
    }
    ...
    default:
      return null;
  }
}

unwindWork验证节点是错误边界的依据就是节点上是否有刚刚throwException的时候打上的ShouldCapture的effectTag。如果验证成功,最终会被return出去。return出去之后呢?会被赋值给workInProgress节点,我们往下看一下错误处理的整体逻辑:

if ((completedWork.effectTag & Incomplete) === NoEffect) {

    // 正常流程
    ...

} else {
  // 验证节点是否是错误边界
  const next = unwindWork(completedWork, subtreeRenderLanes);

  if (next !== null) {
    // 如果找到了错误边界,删除与错误处理有关的effectTag,
    // 例如ShouldCapture、Incomplete,
    // 并将workInProgress指针指向next
    next.effectTag &= HostEffectMask;
    workInProgress = next;
    return;
  }

  // ...省略了React性能分析相关的代码

  if (returnFiber !== null) {
    // 将父Fiber的effect list清除,effectTag标记为Incomplete,
    // 便于它的父节点再completeWork的时候被unwindWork
    returnFiber.firstEffect = returnFiber.lastEffect = null;
    returnFiber.effectTag |= Incomplete;
  }
}

...
// 继续向上completeWork的过程
completedWork = returnFiber;

现在我们要有个认知,一旦unwindWork识别当前的workInProgress节点为错误边界,那么现在的workInProgress节点就是这个错误边界。然后会删除掉与错误处理有关的effectTag,DidCapture会被保留下来。

  if (next !== null) {
    next.effectTag &= HostEffectMask;
    workInProgress = next;
    return;
  }

重点:将workInProgress节点指向错误边界,这样可以对错误边界重新走更新流程。

这个时候workInProgress节点有值,并且跳出了completeUnitOfWork,那么继续最外层的工作循环:

function workLoopConcurrent() {
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

此时,workInProgress节点,也就是错误边界,它会再被performUnitOfWork处理,然后进入beginWork、completeWork!

也就是说它会被重新更新一次。为什么说再被更新呢?因为构建workInProgress树的时候,beginWork是从上往下的,当时workInProgress指针指向它的时候,它只执行了beginWork。此时子节点出错导致向上completeUnitOfWork的时候,发现了他是错误边界,workInProgress又指向了它,所以它会再次进行beginWork。不同的是,这次节点上持有了
DidCapture的effectTag。所以流程上是不一样的。

还记得throwException阶段入队错误边界更新队列的表示错误的update吗?它在此次beginWork调用processUpdateQueue的时候,会被处理。
这样保证了getDerivedStateFromErrorcomponentDidCatch的调用,然后产生新的state,这个state表示这次错误的状态。

错误边界是类组件,在beginWork阶段会执行finishClassComponent,如果判断组件有DidCapture,会卸载掉它所有的子节点,然后重新渲染新的子节点,这些子节点有可能是经过错误处理渲染的备用UI。

示例代码来自React错误边界介绍

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    // 更新 state 使下一次渲染能够显示降级后的 UI
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    // 你同样可以将错误日志上报给服务器
    logErrorToMyService(error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // 你可以自定义降级后的 UI 并渲染
      return <h1>Something went wrong.</h1>;
    }

    return this.props.children;
  }
}

对于上述情况来说,一旦ErrorBoundary的子树中有某个节点发生了错误,组件中的getDerivedStateFromErrorcomponentDidCatch就会被触发,
此时的备用UI就是:

<h1>Something went wrong.</h1>

流程梳理

上面的错误处理我们用图来梳理一下,假设<Example/>具有错误处理的能力。

  1              App
                  |
                  |
  2           <Example/>
                /
               /
  3 --->   <List/>--->span
            /
           /
  4       p ----> 'text'
         /
        /
  5    h1

1.如果<List/>更新出错,那么首先throwException会给它打上Incomplete的effectTag,然后以它的父节点为起点向上找到可以处理错误的节点。

2.找到了<Example/>,它可以处理错误,给他打上ShouldCapture的effectTag(做记号),创建错误的update,将getDerivedStateFromError放入payload,componentDidCatch放入callback。
,入队<Example/>的updateQueue。

3.从<List/>开始直接completeUnitOfWork。由于它有Incomplete,所以会走unwindWork,然后给它的父节点<Example/>打上Incomplete,unwindWork发现它不是刚刚做记号的错误边界,
继续向上completeUnitOfWork

4.<Example/>有Incomplete,进入unwindWork,而它恰恰是刚刚做过记号的错误边界节点,去掉ShouldCapture打上DidCapture,将workInProgress的指针指向<Example/>

5.<Example/>重新进入beginWork处理updateQueue,调和子节点(卸载掉原有的子节点,渲染备用UI)。

我们可以看出来,React的错误边界的概念其实是对可以处理错误的组件重新进行更新。错误边界只能捕获它子树的错误,而不能捕获到它自己的错误,自己的错误要靠它上面的错误边界来捕获。我想这是由于出错的组件已经无法再渲染出它的子树,也就意味着它不能渲染出备用UI,所以即使它捕获到了自己的错误也于事无补。

这一点在throwException函数中有体现,是从它的父节点开始向上找错误边界:

// 从当前节点的父节点开始向上找
let workInProgress = returnFiber;

do {
  ...
} while (workInProgress !== null);

回到completeWork,它在整体的错误处理中做的事情就是对错误边界内的节点进行处理:

  • 检查当前节点是否是错误边界,是的话将workInProgress指针指向它,便于它再次走一遍更新。
  • 置空节点上的effectList。

以上我们只是分析了一般场景下的错误处理,实际上在任务挂起(Suspense)时,也会走错误处理的逻辑,因为此时throw的错误值是个thenable对象,具体会在分析suspense时详细解释。

总结

workInProgress节点的completeWork阶段主要做的事情再来回顾一下:

  • 真实DOM节点的创建以及挂载
  • DOM属性的处理
  • effectList的收集
  • 错误处理

虽然用了不少的篇幅去讲错误处理,但是仍然需要重点关注正常节点的处理过程。completeWork阶段处在beginWork之后,commit之前,起到的是一个承上启下的作用。它接收到的是经过diff后的fiber节点,然后他自己要将DOM节点和effectList都准备好。因为commit阶段是不能被打断的,所以充分准备有利于commit阶段做更少的工作。

一旦workInProgress树的所有节点都完成complete,则说明workInProgress树已经构建完成,所有的更新工作已经做完,接下来这棵树会进入commit阶段,从下一篇文章开始,我们会分析commit阶段的各个过程。

欢迎扫码关注公众号,发现更多技术文章


nero
4.4k 声望6.4k 粉丝

前端工程师