用自己看得懂的话总结React15到React16的变化

首先要搞懂React16诞生的原因,无非就是之前版本调度、渲染效率不高,新版本引入新机制进行全面优化。

React 框架内部的运作可以分为 3 层:

  • Virtual DOM 层,描述页面长什么样。
  • Reconciler 层,负责调用组件生命周期方法,进行 Diff 运算等。
  • Renderer 层,根据不同的平台,渲染出相应的页面,比较常见的是 ReactDOM 和 ReactNative。

Reconciler 层是调度任务的核心,旧版本的调度方式中,当我们调用setState更新页面的时候,React 会遍历应用的所有节点,diff计算出差异,然后再更新 UI。整个过程是一气呵成,不能被打断的。如果页面元素很多,整个过程占用的时机就可能超过 16 毫秒,就容易出现卡顿掉帧的现象。

16版本中,React 团队也给它起了个新的名字,叫Fiber Reconciler,为了加以区分,以前的 Reconciler 被命名为Stack Reconciler。同时Virtual DOM因为总是引起混乱,不再用于 React 文档,但很多地方仍习惯性地使用这个概念,现在我用React元素树代替,因为它本就表示了一个React组件的树形结构,和DOM差距还是很明显的。React16引入Fiber树的概念,其实阅读源码可以发现,虽然还习惯性叫它树,其实它已经变成一个链表结构了,每个Fiber节点child属性指向它的第一个子节点,sibling指向下一个兄弟节点,每个兄弟节点都有一个return指向父节点,不过本质上还是以链表形式表示树,所以我还是习惯叫它Fiber树。Fiber树由React元素树生成,在第一次渲染之后,React 最终得到一个 Fiber 树,它反映了用于渲染 UI 的应用程序的状态。这棵树通常被称为 current 树(当前树)。当 React 开始处理更新时,会进行两个阶段render和commit,render阶段会构建一个所谓的 workInProgress 树(工作过程树),上面记录了每个节点的副作用(每个Fiber节点持有一个nextEffect属性,指向下一个要更新的节点),即要刷新到屏幕的未来状态,本阶段不会产生页面渲染或者说DOM更新(相当于一个可以中断diff阶段)。commit阶段会遍历nextEffect链表,将副作用更新到页面,该阶段完成后,会将工作过程树设为当前树。

React15中,Stack Reconciler 运作的过程不能被打断,通过递归的方式进行渲染,使用的是 JS 引擎自身的函数调用栈,它会一直执行到栈空为止,源代码中对应的方法是enqueueUpdate(componet);React16实现了自己的组件调用栈,它以链表的形式遍历组件树,可以灵活的暂停、继续和丢弃执行的任务,源代码中对应的方法是enqueueUpdate(fiber,update)和scheduleWork(fiber,expirationTime)。显然enqueueUpdate的改变是重点,可以比较下两个版本的代码:

//15版
function enqueueUpdate(component) {
  // ...

  if (!batchingStrategy.isBatchingUpdates) {
    batchingStrategy.batchedUpdates(enqueueUpdate, component);
    return;
  }

  dirtyComponents.push(component);
}
//16版
enqueueUpdate<State>(fiber: Fiber, update: Update<State>) {
  const updateQueue = fiber.updateQueue;
  if (updateQueue === null) {
    // Only occurs if the fiber has been unmounted.
    return;
  }

  const sharedQueue = updateQueue.shared;
  const pending = sharedQueue.pending;
  if (pending === null) {
    // This is the first update. Create a circular list.
    update.next = update;
  } else {
    update.next = pending.next;
    pending.next = update;
  }
  sharedQueue.pending = update;
}

新版本加入了时间片调度,Fiber Reconciler 每执行一段时间,都会将控制权交回给浏览器,看看有没有更高优先级的任务需要执行,以达到分段执行的效果,

任务的优先级有六种:

  • synchronous,与之前的Stack Reconciler操作一样,同步执行
  • task,在next tick之前执行
  • animation,下一帧之前执行
  • high,在不久的将来立即执行
  • low,稍微延迟执行也没关系
  • offscreen,下一次render时或scroll时才执行

优先级高的任务(如键盘输入)可以打断优先级低的任务(如Diff)的执行,从而更快的生效。在更新的两个阶段中,render阶段可以被随意打断(每个时间片之后),Fiber Reconciler 可以根据可用时间片来处理一个或多个 Fiber 节点,然后停下来暂存已完成的工作,并转而去处理某些事件,接着它再从它停止的地方继续执行。但有时候,它可能需要丢弃完成的工作并再次从顶部开始。由于在此阶段执行的工作不会导致任何用户可见的更改(如 DOM 更新),因此暂停行为才有了意义。而commit阶段始终是同步的,无法被打断。这是因为在此阶段执行的工作会导致用户可见的变化,例如 DOM 更新。这就是为什么 React 需要在一次单一过程中完成这些更新。

而旧版本React虽然更新过程是同步的不能被打断,但也通过batch和Transaction进行批量更新,尽可能达成优化(效果并不好,所以才会有新的Fiber机制,不过旧机制也需要了解,追本溯源)。Transaction 对一个函数进行包装,让 React 有机会在一个函数运行前后执行特定逻辑(前端装饰者模式,面向切面编程),从而完成整个 Batch Update 流程的控制。简单来说,在 Transaction 的 initialize 阶段,一个 update queue 被创建。在 Transaction 中调用 setState 方法时,状态并不会立即应用,而是被推入到 update queue 中。函数执行结束进入 Transaction 的 close 阶段,update queue 会被 flush,这时新的状态会被应用到组件上并开始后续 Virtual DOM 更新等工作。这就是在componentDidMount方法中setState看起来只更新了一次的原因,该经典问题可见这里,因为它处在一个大的render的Transaction中,讲解可见这里,问题也贴一下:

class Example extends React.Component {
  constructor() {
    super();
    this.state = {
      val: 0
    };
  }
  
  componentDidMount() {
    this.setState({val: this.state.val + 1});
    console.log(this.state.val);    // 第 1 次 log

    this.setState({val: this.state.val + 1});
    console.log(this.state.val);    // 第 2 次 log

    setTimeout(() => {
      this.setState({val: this.state.val + 1});
      console.log(this.state.val);  // 第 3 次 log

      this.setState({val: this.state.val + 1});
      console.log(this.state.val);  // 第 4 次 log
    }, 0);
  }

  render() {
    return null;
  }
};

而setTimeOut的异步特性使得内部setState并未在任何Transaction被调用,于是setState自己通过batchupdate方法创建一个batchupdate事务(Transaction),而事务内部执行逻辑是同步的,且在这个事务中没有别的更新操作被压入dirtyComponents中暂存,所以不会表现出之前的“异步性”,最后就是先执行更新,然后console.log,一切按照顺序执行。而且不止setTimeOut,其他调用浏览器层面的异步API,如addEventListener,ajax call的fetch函数,都不在React15的事务管辖范围内,所以不会进行合并更新。这些情况在React16中通过时间分片得到解决,在enqueueSetState方法中给每个Update设置expirationTime,计算expirationTime时通过精心设计的计算公式,将25ms内的Update都设为一个expirationTime,从而达到batchUpdate的效果,有兴趣可以看看expirationTime的生成公式(从代码中化简出来):

((((currentTime - 2 + 5000 / 10) / 25) | 0) + 1) * 25
发布了71 篇原创文章 · 获赞 31 · 访问量 20万+

猜你喜欢

转载自blog.csdn.net/m0_37828249/article/details/103680036