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

本系列文章总共三篇:javascript

课前小问题:

  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 来描述它呢?css

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

咱们获取能够经过这样的 js 对象来描述它html

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/repljava

本节解决的问题

  • 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

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

stack reconciler

概念

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

stack-reconciler.png

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

问题

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

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-…