阅读 633

React@16.8.6原理浅析(概念介绍)

本系列文章总共三篇:

课前小问题:

  1. JSX 是如何实现的
  2. jsx 里面为什么无法写 for 循环
  3. 为什么我们需要在 react 文件顶部引入 react 库
  4. 为什么用户自定义的组件要大写开头
  5. 为什么 class 要写成 className(同理为什么 style 里面的 css 属性要写成驼峰形式)
  6. virtual dom 是什么
  7. diff 算法是什么
  8. 为什么动态生成的列表需要设置key
  9. 为什么不建议在 componentWillMount 里面执行副作用
  10. class component 的生命周期是如何实现的

React 的核心思想

  1. 声明式
  2. 组件化
  3. 一次学习随处编写

要实现这些需要哪些手段

  1. 通过模板的方式操作视图(JSX)
  2. 怎么将模板插入到真实的DOM中,为了性能需要虚拟DOM
  3. 怎么调度更新两种方式:stack reconciler 和 fiber reconciler
  4. 要实现一次学习随处编写需要将各个部分的包抽离出来

JSX 是如何实现的

  1. 为什么需要 jsx:zh-hans.reactjs.org/docs/introd…
  2. 怎么将 js 和 html 写在一起:因为只有 js 是图灵完备的语言,所以要用 js 来描述 html

问题:假如有如下的 html 结构我们如何用 js 来描述它呢?

<div class="parent">
  <span>child1</span>
  <span>child2</span>
</div>
复制代码

我们获取可以通过这样的 js 对象来描述它

const element = {
  type: 'div',
  props: {className: 'parent'},
  children: [
    {
      type: 'span',
      props: null,
      children: ['child1']
    },
    {
      type: 'span',
      props: null,
      children: ['child2']
    }
  ] 
}
复制代码
  1. 显然如果像上面这么写很麻烦,所以 react 使用 jsx 这种语法扩展
  2. 将 jsx => reactElement 是通过 babel 来实现的(babel-preset-react)

在线体验链接:babeljs.io/repl

本节解决的问题

  • JSX 是如何实现的
  • jsx 里面为什么无法写 for 循环
  • 为什么我们需要在 react 文件顶部引入 react 库
  • 为什么用户自定义的组件要大写开头
  • 为什么 class 要写成 className(同理为什么 style 里面的 css 属性要写成驼峰形式)

虚拟DOM是什么

  1. 结论:JS对象模拟DOM树
  2. 为什么需要虚拟DOM:
  • 因为对 dom 的操作是很昂贵的,容易造成性能问题(浏览器的重排和重绘)【这里可以通过devtool演示】
  • 虚拟dom可以结合diff算法实现尽可能少的dom操作
  • 创建跨平台的应用

本节解决的问题

  • virtual dom 是什么

Diff 算法

  1. diff 算法就是找到新老两个虚拟DOM树之间的差异(类似于 git dif)
  2. 如果单纯对两个树进行比较的时间复杂度是 n² 再加上更新就变成了 n³

diff算法时间复杂度.png

  1. react diff 算法

React@16.8.6源码浅析——diff算法演示图.jpg

  1. 前提:
  • 两个相同组件产生类似的 DOM 结构,不同的组件产生不同的 DOM 结构
  • 对于同一层次的一组子节点,它们可以通过唯一的 id 进行区分。

本节解决的问题

  • diff 算法是什么
  • 为什么动态生成的列表需要设置key

协调(Reconciliation)

概念:按照我的理解就是 更新 -> DOM 变化 这之间的流程,它包括了diff 算法。
react有两套协调算法:stack reconciler(react@15.x) 和 fiber reconciler(react@16.x)

注:在 react 中挂载阶段也算是更新

stack reconciler

概念

在 react 16 之前的调度算法被称作 stack reconciler,它的特点是自顶向下的更新方式,比如调用 this.setState() 之后的流程就像这样:
this.setState() => 生成虚拟 DOM => diff 算法比较 => 找到要更新的元素 => 放到更新队列里

stack-reconciler.png

想一想:这样实现有什么问题没有?

问题

如果整个应用很大,会导致 js 的执行长期占据主线程,浏览器无法及时响应用户的操作,进而导致页面显示的卡顿。
假设我们有一个大的三角形组件,它由很多的子组件组成,每个子组件中数字会不断改变,与此同时整个三角形会不断的变宽和变窄,接下来让我们看看两个 reconciler 的表现如何:

QQ截图20191226215609.png

stack-example.gif

fiber-example.gif

我们可以发现 stack reconciler 下的渲染是不如 fiber reconciler 来的流程的,这是因为在 stack reconciler 里面更新时同步的,自顶向下的更新方式,只要更新过程开始就会“一条道走到黑”直到所有节点的比对全部完成,这样的方式如果节点数量比较少还好,如果想上面这种情况节点数量很多,假设有 200 个节点,每个节点进行 diff 算法需要 1ms,那么全部比对完就需要 200ms,也就是说在这 200ms 里面浏览器无法处理其它任务,比如无法渲染视图,一般 30 帧及以上会让人感到流畅,那每帧就需要 33.3ms,显然 200ms > 33.3ms,所以相当于因为 JS 的长时间执行导致帧率变得很低,等到 200ms 之后浏览器将之前漏掉的页面渲染一下子呈现的时候你就会感觉到不连贯也就是卡顿了。

这里需要补充一下关于浏览器帧的概念
我们知道要实现流畅的显示效果,刷新频率(FPS)就不能太低,现代浏览器一般的刷新频率是 60FPS,所以每一帧分到的时间是 1000/60 ≈ 16 ms,在这 16 ms 中浏览器会安排如下事项

frame.png

  • 处理用户的交互
  • JS 解析执行
  • 帧开始。窗口尺寸变更,页面滚去等的处理
  • rAF(requestAnimationFrame)
  • 布局
  • 绘制

可以看到浏览器这一帧里面要做的事情着实不少,如果 js 引擎的执行占用的时间过长那势必导致其它任务的执行(比如响应用户交互)要延后,这也就是导致卡顿的原因。

Fiber reconciler

概念

react 16 版本对以前的 stack reconciler 进行了一次重写,就是 fiber reconciler,它的目的是为了解决 stack reconciler 中固有的问题,同时解决一些历史遗留问题。
我们期望的是能够实现如下目标:

  • 能够把可中断的任务切片处理
  • 能够调整优先级,重置并复用任务
  • 能够在父元素与子元素之间交错处理,以支持 React 中的布局
  • 能够在 render() 中返回多个元素
  • 更好地支持错误边界

要实现这些特性一个很重要的点就是:切片,把一个很大的任务拆分成很多小任务,每个小任务做完之后看看是否需要让位于其它优先级更高的任务,这样就能保证这个唯一的线程不会被一直占用,我们把每一个切片(小任务)就叫做一个 fiber。

fiber-reconciler.png
**
在何时进行切片?
react 的整个执行流程分为两个大的阶段:

  • Phase1: render/reconciliation
  • Phase2: commit

第一个阶段负责产生 Virtual DOM -> diff 算法 ->  产生要执行的 update,第二个阶段负责将更新渲染到 DOM Tree 上,如果我们在第二个阶段进行分片肯能会导致页面显示的不连贯,会影响用户体验,所以将其放在第一个阶段去分片更为合理。
切出来的是什么呢?
切片出来就是 fiber 对象,fiber 翻译为纤维,在计算机科学中叫协程或纤程,一种比线程耕细粒度的任务单元,虽然 JS 原生并没有该机制(大概),但是我想 react 大概是想借用该思想来实现更精细的操控任务的执行吧。
如何调度任务执行?
React 通过两个 JS 底层 api 来实现:

  • requestIdleCallback 该方法接收一个 callback,这个回调函数将在浏览器空闲的时候调用

16016a05af97b05b.png

  • requestAnimationFrame 该方法接收一个 callback,这个回调函数会在下次浏览器重绘之前调用

它俩的区别是 requestIdleCallback 是需要等待浏览器空闲的时候才会执行而 requestAnimationFrame 是每帧都会执行,所以高优先级的任务交给 requestAnimationFrame 低优先级的任务交给 requestIdleCallback 去处理,但是为了避免因为浏览器一直被占用导致低优先级任务一直无法执行,requestIdleCallback 还提供了一个 timeout 参数指定超过该事件就强制执行回调。

实现

接下来我们来通过一个例子看看 fiber reconciler 是如何实现的,不过在此之前我们先来认识一些 react 源码中的名词。

名词解释

  • Fiber

上面我们提到了 Fiber,它表示 react 中最小的一个工作单元, 在 react 中 ClassComponent,FunctionComponent,普通 DOM 节点,文本节点都对应一个 Fiber 对象,Fiber 对象的本质其实就是一个 Javascript 对象。

  • child

child 是 Fiber 对象上的属性,指向的是它的子节点(Fiber)

  • sibling

sibling 是 Fiber 对象上的属性,指向的是它的兄弟节点(Fiber)

  • return

return 是 Fiber 对象上的属性,指向的是它的父节点(Fiber)

  • stateNode

stateNode 是 Fiber 对象上的属性,表示的是 Fiber 对象对应的实例对象,比如 Class 实例、DOM 节点等

  • current

current 表示已经完成更新的 Fiber 对象

  • workInProgress

workInProgress 表示正在更新的 Fiber 对象

  • alternate

alternate 用来指向 current 或 workInProgress,current 的 alternate 指向 workInProgress,而 workInProgress 的 alternate 指向 current

  • FiberRoot

FiberRoot 表示整个应用的起点,它内部保存着 container 信息,对应的 fiber 对象(RootFiber)等

  • RootFiber (HostRoot)

RootFiber 表示整个 fiber tree 的根节点,它内部的 stateNode 指向 FiberRoot,它的 return 为  null

Fiber tree

1625d95bc781908d.png

我们这里要注意的是 fiber tree 不同于传统的 Virtual DOM 是树形结构,fiber 的 child 只指向第一个 子节点,但是可以通过 sibling 找到其兄弟节点,所以整个结构看起来更像是一个链表结构。

举个例子

我们有这样一个 App 组件,它包含一个 button 和一个 List 组件,当点击 button 的时候 List 组件内的数字会进行平方运算,另外在 App 组件外还有一个 button,它不是用 react 创建的,它的作用是点击时会放大字体。

挂载阶段

第一次渲染阶段也就是挂载阶段,react 会自上而下的创建整个 fiber tree,创建顺序是同 FiberRoot 开始,通过一个叫做 work loop 的循环来不断创建 fiber 节点,循环会先遍历子节点(child),当没有子节点再遍历兄弟节点(sibling),最终达到全部创建的目的,以我们的 demo 为例,fiber 的创建顺序如下图箭头所示。

React@16.8.6源码浅析——初次挂载流程.jpg

构建好的 fiber tree 如下图所示

QQ截图20191229100032.png

更新阶段

这时,我们通过点击【^2】按钮来产生一个更新,产生的更新会放入 List 组件的更新队列里(update queue),在 fiber reconciler 中异步的更新并不会立即处理,它会执行调度(schedule)程序,让调度程序判断什么时候让它执行,调度程序就是通过上面所说的 requestIdleCallback 来判断何时处理更新。

QQ截图20191229102006.png

QQ截图20191229102335.png

当主进程把控制权交给我们的时候我们就可以开始执行更新了,我们把这个阶段叫做 work loop,work loop 是一个循环,它循环的去执行下一个任务,在执行之前要先判断剩余的时间是否够用,所以对于 work loop 它要追踪两个东西:下一个工作单元和剩余时间。
QQ截图20191229102849.png

目前我的剩余时间是 13ms,下一个要执行的任务是 HostRoot,这里需要注意通过 this.setState 产生的更新也会先从根节点开始遍历,react 会通过产生更新的那个 fiber 对象(List)向上找到对应的 HostRoot。
QQ截图20191229103242.png

react 会保留之前生成的 fiber tree 我们管它叫 current fiber tree,然后新生成一个 workInProgress tree,它用来计算出产生的变化,执行 reconciliation 的过程,HostRoot 可以直接通过克隆旧的 HostRoot 产生,此时新的 HostRoot 的 child 还指向老的 List 节点。
QQ截图20191229103524.png

当 HostRoot 处理完成之后就会向下寻找它的子节点也就是 List,所以下一个任务就是 List,我们同样可以克隆之前的 List 节点,克隆好之后 react 会判断一下是否还有剩余的时间,发现还有剩余的时间,那么开始执行 List 节点的更新任务。
QQ截图20191229110851.png

QQ截图20191229111409.png

当我们执行 List 的更新时我们发现 List 的 update queue 里面有 update,所以 react 要处理该更新
QQ截图20191229111633.png

当我们处理完 update queue 之后会判断 List 节点上面是否有 effect 要处理,比如 componentDidUpdate ,getSnapshotBeforeUpdate。
QQ截图20191229112326.png

因为 List 产生了新的 state,所以 react 会调用它的 render 方法返回新的 VDOM,接下来就用到了 diff 算法了,根据新旧节点的类型来判断是否可以复用,可以复用的话就直接复制旧的节点,否则就删除掉旧的节点创建新的节点,对于我们的例子来说新旧节点类型一致可以复用。
QQ截图20191229113843.png

下一个要处理的任务就是 List 的第一个 child:button,我们还是在处理之前先检查一下是否还有剩余的时间,接下来的事情我想你大概也能猜到了。
QQ截图20191229113944.png

为了显示出 fiber 的作用我们此时假设用户点击了放大字体的按钮,这个逻辑和 react 无关,完全由原生 JS 实现,此时一个 callback 生成需要等待主线程去处理,但是此时主线程并不会立即处理,因为此时距离下一帧还有剩余的时间,主线程还是会先处理 react 相关的任务。
QQ截图20191229114501.png

QQ截图20191229114625.png

对于 button 节点它没有任何更新,而且也没有子节点(文本节点不算),所以我们可以执行完成逻辑(completeUnitOfWork)这里 react 会比对新旧节点属性的变化,记录在 fiber 对象的 upddateQueue 里,接着 react 会找 button 的兄弟节点(sibling)也就是第一个 Item。
QQ截图20191229121536.png

接着 work loop 去查看是否有剩余的时间,发现还有剩余时间,那接下来的执行过程其实和 List 是类似的。
QQ截图20191229121654.png

如果 Item 组件里面有 shouldComponentUpdate,那么 react 会调用它,返回的结果就是 shouldUpdate,react 用它来判断是否需要更新 DOM 节点,对于第一个 Item 来说它之前的 props 是 1 平方之后还是 1,所以 props 没有发生变化,shouldComponentUpdate 返回 false,react 就不会给它标记任何 effect。
QQ截图20191229122138.png

接着我们继续遍历 Item 的兄弟节点(sibling)也就是第二个 Item,此时第二个 Item 的 props 发生了变化从 2 => 4,所以 shouldComponentUpdate 返回了 true,我们给 Item 标记一个 effect (Placement)。
image.png

接下来我们来处理第二个 Item 下的 div,此时我们还有一点剩余时间,所以我们还是可以继续处理它。
image.png

对于 div 来说它的文本内容从 2 => 4 发生了变化,所以它也需要被标记一个 effect(Placement)。
image.png

当 div 完成更新之后,发现它没有子节点也没有兄弟节点,这时候会对父节点执行完成操作(completeUnitOfWork),在这个阶段会将子节点产生的 effect 合并到 父节点的 effect 链上。
QQ截图20191229132352.png

接着 react 会找第二个 Item 的兄弟节点(sibling)也就是第三个 Item,此时 work loop 进行 deadline 判断的时候发现已经没有剩余时间了,此时 react 会将执行权交还给主进程,但是 react 还有剩余的任务没有执行完,所以它会在之前结束的地方等待主进程空闲时继续完成剩余工作。
image.png

下面就是主进程处理放大字体的任务,此时 react 的内容并没有发生改变,尽管 react 知道第二个节点的值变成了 4。
image.png

当主进程处理完任务之后就会回来继续执行 react 的剩余任务
image.png

接下来就是最后两个任务了,和第二个 Item 执行过程类似,最终会产生两个 effect tag,被挂载到之前的 effect 之后,最终节点的 effect tag 会和合并到父节点的 effect 链上。
image.png

image.png

当已经完成对整个 fiber tree 的最后一个节点更新后,react 会开始不断向上寻找父节点执行完成工作(completeUnitOfWork),如果父节点是普通 DOM 节点会比对属性的变化放在 update queue 上,同时将子节点产生的 effect 链挂载在自己的 effect 链上。
React@16.8.6源码浅析——初次挂载流程.jpg

image.png

当 react 遍历完最后一个 fiber 节点也就是 HostRoot,我们的第一个阶段(Reconciliation phase)就完成了,我们将把这个 HostRoot 交给第二个阶段(Commit phase)进行处理。
image.png

在执行 commit 阶段之前我们还会判断一下时间是否够用
image.png

接下来开始第二个阶段(Commit phase),react 将会从第一个 effect 开始遍历更新,对于 DOM 元素就执行对应的正删改的操作,对于 Class 组件会将执行对应的声明周期函数:componentDidMount、componentDidUpdate、componentWillUnmount,解除 ref 的绑定等。
image.png

image.png

commit 阶段执行完成后 DOM 已经更新完成,这个时候 workInProgress tree 就是当前 App 的最新状态了,所以此时 react 将会把 current 指向 workInProgress tree。
image.png

上面的整个流程可以让浏览器的任务不被一直打断,但是还有一个问题没有解决,如果 react 当前处理的任务耗时过长导致后面更紧急的任务无法快速响应,那该怎么办呢?
QQ截图20191229174446.png

react 的做法是设置优先级,通过调度算法(schedule)来找到高优先级的任务让它先执行,也就是说高优先级的任务会打断低优先级的任务,等到高优先级的任务执行完成之后再去执行低优先级的任务,注意是从头开始执行。
image.png

对我们的影响

fiber reconciler 将一个更新分成两个阶段(Phase):Reconciliation Phase 和 Commit Phase,第一个阶段也就是我们上面所说的协调的过程,它的作用是找出哪些 dom 是需要更新的,这个阶段是可以被打断的;第二个阶段就是将找出的那些 dom 渲染出来的过程,这个阶段是不能被打断的。
对我们有影响的就是这两个阶段会调用的生命周期函数,以 render 函数为界,第一个阶段会调用以下生命周期函数:

  • componentWillMount
  • componentWillReceiveProps
  • shouldComponentUpdate
  • componentWillUpdate
  • render

第二个阶段会调用的生命周期函数:

  • componentDidMount
  • componentDidUpdate
  • componentWillUnmount

因为 fiber reconciler 会导致第一个阶段被多次执行所以我们需要注意在第一阶段的生命周期函数里不要执行那些只能调用一次的操作。

本节解决的问题

  • 为什么不建议在 componentWillMount 里面执行副作用
  • class component 的生命周期是如何实现的

参考资料

Github

包含带注释的源码、demos和流程图
github.com/kwzm/learn-…