renaesop / blog

个人博客
83 stars 8 forks source link

React(16.2)源码阅读笔记 #23

Open renaesop opened 6 years ago

renaesop commented 6 years ago

副标题:在onClick中调用setState会发生什么?

现在版本的React(16.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算法本身也是有一些变化的。

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

  1. 在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这个函数主要有两个工作,分别是renderRootcompleteRoot,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组件会出现了。后续的代码也就是遍历树而已。

renaesop commented 6 years ago

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