React v16.2 源码阅读笔记之在 onClick 中调用 setState 会发生什么?

发布于 2022-08-02 22:54:58 字数 13943 浏览 203 评论 1

现在版本的 React v16.2 用了 fiber,网上也说的很多,但实质上 React 就是把对树的遍历由递归改成了循环,把数组换成了链表。而所谓的 fiber,也就是所谓的 virtual stack frame 则是把栈帧的组织方式由栈变成了链表而已。递归被撤掉,加上引入的一系列新特性(call,return 甚至 fragment)让 React 源代码显得比较碎片化,以至于只能自己动手去观赏源码。

下面是一个很简单的 React 应用:

import React, { Component } from 'react';
import { render } from 'react-dom';
class Comp1 extends Component {
    constructor(props) {
        super(props);
        this.state = { 
            data: new Array(100).fill(0).map((_, index) => index + 1),
        };
    }
    onClickCb() {
        this.setState(prevState => ({
              data: [...prevState.data.slice(0, 1), ...prevState.data.slice(2)],
        }));
    }
    render() {
         return <div>
             <button onClick={() => this.onClickCb()}>click to remove 1 </button>
             {this.state.data.map(val => <div key={val}>{val}</div>)}
         </div>;
    }
}

const Comp2 = () => <div>A placeholder</div>;

render(<div>
    <Comp1 />
    <Comp2 />
</div>, document.getElementById('container'));

如果点击了 button 会发生什么呢?嘿,肯定是一通操作,是把 Comp1 里面的 div 给更新掉了。下面当然会提那个已经被说到耳朵起茧子的 diff 算法,但不仅仅是说 diff,且 diff 算法本身也是有一些变化的。

首先是大致的流程图,流程较长就分了几张

在 render 阶段以前的调用步骤

在render阶段之前

然后是文字解说,唔。

Step1. click 事件的回调函数式如何触发的

首先,click 事件是绑到哪里的? 我们可以用 Chrome 的工具很容易的看到(查看元素--> Elements面板 --> 右侧 Event Listener 这个tab页),是绑到 document 上的,这是绑定事件的代码, listenTo(registrationName, contentDocumentHandle) 被调用时 contentDocumentHandle 传的是挂载点(root)的 ownerDocument。

其次,事件的回调函数呢?在 document 上绑的回调函数是 dispatchEvent。在这个函数里面,我们可以看到,在冒泡情况下,React 会找到 target(事件有个target,而React创建的DOM节点都有俩property, __reactEventHandlers[随机数] 与 __reactInternalInstance[随机数], 分别用来存传入的 property 以及对应的fiber)所有的祖先fiber(由 React 创建的节点),这样就获得了需要冒泡的节点。

最后,React的事件是支持插件的,所以还要有机会合成事件。也就是说,在冒泡的路径上,每个节点都可能会有多个事件要处理。而每个事件都会去检查DOM节点上存的__reactEventHandlers中是否有对应回调,在这个地方不用fiber本身而舍近求远,是因为在异步模式下,fiber的属性和DOM上挂载的可能不一致,按语义将,事件回调是要依DOM上的。

Step2. setState 会做什么事

首先,在dispatchEvent被调用的时候,调用了batchedUpdates, 而它定义在reconciler中, 简单来说,他接收一个函数fn,以及函数的参数,他会将isBatchingUpdates设置为true,之后他会调用函数fn。isBatchingUpdates会影响到React更新视图的策略,如果它为true, 那么无论如何(不管React是不是使用了同步模式),只有在fn执行完之后才会去更改State、更新视图(也就是所谓“异步”setState)。

其次,setState实际调的是setState实现,也就是:

 enqueueSetState(instance, partialState, callback) {
      const fiber = ReactInstanceMap.get(instance);
      callback = callback === undefined ? null : callback;
      if (__DEV__) {
        warnOnInvalidCallback(callback, 'setState');
      }
     // 计算需要更新的ddl,如果是Sync模式则是Sync/1, Async则按照优先级处理
      const expirationTime = computeExpirationForFiber(fiber);
      ******
      insertUpdateIntoFiber(fiber, update);
      // 实际上做的是标记可能需要更新的节点
      scheduleWork(fiber, expirationTime);
    }

紧接着,执行scheduleWork,主要做的工作是,标记setState所在的节点以及其祖先节点的expiration,也就是标记“可能需要更新”。之后会调用requestWork, 会找出最需要需要更新的root(也就是render时的挂载点), 在batching模式下,requestWork会不做任何事情,非batching模式下则依次开始更新root。

下一步,执行performWorkOnRoot这个函数主要有两个工作,分别是 renderRoot 和 completeRoot,render 对应于构建新的 virtual DOM 树,而 complete 则对应于让真实DOM同步virtual DOM的修改。由于在异步模式下,render可能不会一口气做完,所以renderRoot可能没有完成更新整个 virtual DOM 树的工作,这种情况下便不会调用 complete。异步模式还可能存在render已经完成但不剩时间片的情况,这时候就可能会把complete(commit)工作留到下一个时间片里面做。

Step3. renderRoot 会做什么事

在Fiber架构中,有三种树(不太严格的“树”),分别是ReactElement,fiber,instance/DOM树,对应者主要的三种对象。我们常说的virtual DOM树应该指的是ReactElement树,但现在来说可能fiber树可能更贴切,三者的节点之间接近一一对应。我们所写的JSX对应的就是ReactElement,比如说<Component1 />就相当于{$$typeof: 'xxxxx', type: Component1, props: ..., key: ..., children: [...],...},一般而言ReactElement由render方法(class组件)或者函数(函数式组件)返回,最终在diff时会转化成(或者更新已有的)fiber对象,既然fiber是任务单位自然也会记录要做的更新,这些更新会在commit阶段的时候被消化掉。

renderRoot除了会做一些簿记工作和错误处理以外,主要还是调用workLoop函数

  function workLoop(expirationTime: ExpirationTime) {
    if (capturedErrors !== null) {
      ******
      return;
    }
    if (nextRenderExpirationTime === NoWork || nextRenderExpirationTime > expirationTime) {
      return;
    }
    // 可能会被打断,所以
    if (nextRenderExpirationTime <= mostRecentCurrentTime) {
      // Flush all expired work.
      while (nextUnitOfWork !== null) {
        nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
      }
    } else {
      // Flush asynchronous work until the deadline runs out of time.
      while (nextUnitOfWork !== null && !shouldYield()) {
        nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
      }
    }
  }

这个workLoop,实际上是reactor模式的标配,比如说node的workLoop。reactor模式下需要做的事情先丢给队列,在这里就是个链表(nextUnitOfWork链),然后让workLoop决定是否处理或者何时处理。这个函数的nextUnitOfWork实际上就是一个fiber,而如果是同步模式下,相当于是遍历树,一边遍历一遍更新。

接下来就是performUnitOfWork:

  function performUnitOfWork(workInProgress: Fiber): Fiber | null {
    const current = workInProgress.alternate;
   //  ** some dev code**
    let next = beginWork(current, workInProgress, nextRenderExpirationTime);
     // **some dev code**
    if (next === null) {
      next = completeUnitOfWork(workInProgress);
    }
   // **some bookkeeping code**
    return next;
  }

这个函数很有意思,会调用beginWorkcompleteUnitOfWork,这俩函数的名字很令人迷糊。什么叫开始和完成?在深度优先遍历树的时,分先序和后序遍历,或者用我们更熟悉的话说就是捕获和冒泡,也就是说树的每个节点有两次调用函数的机会,在这里,beginWork是在捕获阶段执行一些工作,而completeUnitOfWork则是在冒泡阶段做一些工作(其实这里这么说并不准确,对于call&return组件而言略有差异)。从返回值来说,beginWork会返回节点的child,而completeUnitOfWork往往返回的是sibling节点(也可能返回child,在call&return组件的情况下),这样树就能被完全遍历了。

接着看beginWork(ReactFiberBeginWork.js)

function beginWork(
    current: Fiber | null,
    workInProgress: Fiber,
    renderExpirationTime: ExpirationTime,
  ): Fiber | null {
    if (workInProgress.expirationTime === NoWork ||workInProgress.expirationTime > renderExpirationTime) {
      return bailoutOnLowPriority(current, workInProgress);
    }
    switch (workInProgress.tag) {
      // ** some other cases**
      case ClassComponent:
        return updateClassComponent(
          current,
          workInProgress,
          renderExpirationTime,
        );
      // ** some other cases**
    }
  }

这个函数首先判断这个节点是否需要render,而这个expirationTime这个标记呢是在前面scheduleWork的时候做的,如果没有标记那么他的整个子树也都跳过更新了。接下来是根据fiber(element)的所属类型选择更新的策略,由于最典型最复杂的是class组件,这里就把他拿出来做例子。

updateClassComponent

function updateClassComponent( current: Fiber | null, workInProgress: Fiber,
     renderExpirationTime: ExpirationTime) {
   // class component 可能有context,以栈形式组织
    const hasContext = pushContextProvider(workInProgress);

    let shouldUpdate;
    if (current === null) {
      if (!workInProgress.stateNode) {
        constructClassInstance(workInProgress, workInProgress.pendingProps);
        mountClassInstance(workInProgress, renderExpirationTime);
        shouldUpdate = true;
      } else {
        invariant(false, 'Resuming work not yet implemented.');
      }
    } else {
      shouldUpdate = updateClassInstance(
        current,
        workInProgress,
        renderExpirationTime,
      );
    }
    return finishClassComponent(
      current,
      workInProgress,
      shouldUpdate,
      hasContext,
    );
  }

第一个判断处理的是初次渲染以及更新时新建组件的状况,此时,首先需要构建instance并与fiber挂钩,之后执行mount(也就是willMount, didMount以及的update那套,因为willMount可能会调用setState之类的)。而else分支中的updateClassInstance则是负责去调用componentWillReceivePropsshouldComponentUpdatecomponentWillUpdate这些生命周期函数,以及应用setState存入队列的更新。很多文章已经强调过,在render阶段调用的生命周期方法可能会在commit之前调用多次,所以不应该有副作用,而如果细看相关代码也可以发现React有很多帮助检测副作用的工作(搜索debugRenderPhaseSideEffects)。总的来说前面就是判断组件是否需要更新以及让组件有机会做一些数据的处理工作,最后的finishClassComponent则会真正做“计算和标记更新”的工作。

finishClassComponent

 function finishClassComponent(current: Fiber | null, workInProgress: Fiber,
    shouldUpdate: boolean, hasContext: boolean) {
    // Refs should update even if shouldComponentUpdate returns false
    markRef(current, workInProgress);
    if (!shouldUpdate) {
      // **ctx code**
      return bailoutOnAlreadyFinishedWork(current, workInProgress);
    }
    const instance = workInProgress.stateNode;
    let nextChildren;
    // **some dev code**
     nextChildren = instance.render();
    reconcileChildren(current, workInProgress, nextChildren);
    memoizeState(workInProgress, instance.state);
    memoizeProps(workInProgress, instance.props);
    // **ctx code**
    return workInProgress.child;
  }

函数首先去标定需要更新ref,这里为什么跟shouldUpdate无关呢?考虑如下情况,

<Container>
   {data.map((_, index) => <Child ref={'_' + index} />)}
</Container>

也就是说以index为ref,如果Child实现了shouldComponentUpdate,当对Child更改排顺序的时候,实际上只需要做移动操作。从语义上讲,这时候ref是需要更改的,但是shouldUpdate却是false,因此不应把
shouldUpdate作为ref更新的判据。接下来是调用组件的render方法,获取新的element,再传入reconcileChildren做我们常提起的diff操作。diff操作完之后的两步memoize应该是方便打断render之后的恢复操作,最后返回第一个child交给workLoop

由于diff相关的代码比较繁杂,在此先跳回之后(不严格地说“冒泡阶段”)会调用的completeUnitOfWork

function completeUnitOfWork(workInProgress: Fiber): Fiber | null {
    while (true) {
      const current = workInProgress.alternate;
      const next = completeWork(
        current,
        workInProgress,
        nextRenderExpirationTime,
      );
      // ** dev code **
      const returnFiber = workInProgress.return;
      const siblingFiber = workInProgress.sibling;
      if (next !== null) {
        return next;
      }
      if (returnFiber !== null) {
        // **将子树的effect(commit要做的事情)链表并入上层链表**
      }
      if (siblingFiber !== null) {
        return siblingFiber;
      } else if (returnFiber !== null) {
        workInProgress = returnFiber;
        continue;
      } else {
        const root: FiberRoot = workInProgress.stateNode;
        root.isReadyForCommit = true;
        return null;
      }
    }
  }

我们可以先观察一下用循环遍历树的代码:

let node = root;
label: while(true) {
   fnEnter(node);
   if (node.child) {
      node = node.child;
      continue;
   }
   while (!node.sibling) {
      fnExit(node);
      node = node.parent;
      if (!node) break label;
   }
   node = node.sibling;
}

大致结构是先进入子节点,当到达叶子节点时退回最有邻居节点的祖先节点,然后再做循环,如此便能将整个树遍历完。

completeUnitOfWork做的便是 while (!node.sibling) 及此行以下的工作。首先会调用 completeWork,对于 HostComponent(div 啥的)会去计算更新需要做的工作然后存入 effect,这些工作在beginWork阶段也是能做的,放到这里的缘故应该是考虑到render阶段中可能有嵌套的更新,做的工作可能会“浪费”,所以越晚做浪费的可能性就越小;对于自定义的组件(class、functional),基本上什么都不做;而对于Call而言,会在这一步 render 出 element,然后……去做 diff 工作,所以返回值不为 null 的情况也就是 Call 组件会出现了。后续的代码也就是遍历树而已。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。

评论(1

紫﹏色ふ单纯 2022-05-04 05:13:24

为什么React会选择先“标记”调用更新方法(setState, forceUpdate等)的节点及其祖先节点,然后再从root开始遍历呢?
首先,考虑在一次batching中(也就是浏览器触发一次回调),在冒泡的过程中可能多个节点都绑了事件,那么如果不用标记法,而去即时处理的话就会多次重复更新造成很大浪费,另外,由于冒泡是从子到父而更新是父及子更加会加重浪费;其次,在事件回调函数中,可能会调用dispatchEvent而造成嵌套调用,与同一个事件触发多个回调的效果类似;最后,就算实际上只有一个节点及其子需要更新,造成的浪费也非常微乎其微,只有节点自身及其祖先的邻居节点会稍微遍历一下(如果是一个有很多tr的table,一个tr更新会导致其他tr都被遍历)。

~没有更多了~

关于作者

柠栀

暂无简介

0 文章
0 评论
25 人气
更多

推荐作者

书间行客

文章 0 评论 0

神妖

文章 0 评论 0

undefined

文章 0 评论 0

38169838

文章 0 评论 0

彡翼

文章 0 评论 0

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文