在本文之前说了 react15的缺点,这里来说下16版本是怎么修复的。

我之前的一篇文章 写过mini-react-fiber版本,那是一个简易版。

我们这里分析先做总结,然后根据总结的流程来看源码,这里主要就是串通主流程。

大架构:

首先从react源码层面执行,宏观角度来看,它实际上分为了两部分。

  1. render阶段:主要就是用来生成新的fiber树并diff出有变化的节点。
  2. commit阶段:获取到render阶段中diff出来的发生了变化的节点的fiber,通过原生api更新页面。

也就是说,在render阶段中,只会做一些复杂的运算,并不会真正的操作页面(在内存中做 新旧 fiber对象比对,找出更新的fiber节点,或者是首次加载时 生成组装html片段),这一阶段是可以被打断的(初始渲染时不会被打断,因为要让用户尽快看到界面),也就是说在react里的时间分片的概念,分的就是复杂运算的部分也就是这里,在这里这个render阶段也就是说可能会被高优先级的任务(例如界面事件)打断。

直到commit阶段才会去通过js的原生api去修改dom,commit阶段是不可以被打断的,因为commit阶段是渲染阶段,它如果也是可以被打断的话,不一次性更新出来的话,就会出现 类似网速过慢时 图片 缓慢加载的效果,和我们的要求不符,所以commit阶段是同步的。

实质上我原来那篇mini-react-fiber文章也是遵循了react的两大阶段。

小架构:

现在我们来仔细说下react内部的架构划分:
react内部架构实际可以分成三层:

  1. 调度层Scheduler:调度任务的优先级,高优任务优先进入协调器
  2. 协调层Reconciler:构建 Fiber 数据结构,比对 Fiber 对象找出差异, 记录 Fiber 对象要进行的 DOM 操作(初始加载的时候,负责组装html片段)
  3. 渲染层Renderer:负责将发生变化的部分渲染到页面上

Scheduler

我前面对比15-16的时候讲过 16为了解决 vnode过大stack递归堆栈问题。引入了任务优先级 和 任务可中断的概念。 我那个简易版本是通过window 对象中 requestIdleCallback API实现的,它可以利用浏览器的空闲时间执行任务,但是它自身也存在一些问题,比如说并不是所有的浏览器都支持它,而且它的触发频率也不是很稳定,所以 React 最终放弃了 requestIdleCallback 的使用。

在 React 中,官方实现了自己的任务调度库,这个库就叫做 Scheduler。它也可以实现在浏览器空闲时执行任务,而且还可以设置任务的优先级,高优先级任务先执行,低优先级任务后执行。

Scheduler 存储在它源码的 packages/scheduler 文件夹中。

Reconciler

在 React 15 的版本中,协调器和渲染器交替执行,即找到了差异就直接更新差异。在 React 16 的版本中,这种情况发生了变化,协调器和渲染器不再交替执行。协调器负责找出差异,在所有差异找出之后,统一交给渲染器进行 DOM 的更新。也就是说协调器的主要任务就是找出差异部分,并为差异打上标记。

Renderer

渲染器根据协调器为 Fiber 节点打的标记,同步执行对应的DOM操作。

既然比对的过程从递归变成了可以中断的循环,那么 React 是如何解决中断更新时 DOM 渲染不完全的问题呢?

其实根本就不存在这个问题,因为在整个过程中,调度器和协调器的工作是在内存中完成的是可以被打断的,渲染器的工作被设定成不可以被打断,所以不存在DOM 渲染不完全的问题。

这样和我们前面说的两个大阶段对比的话,Scheduler和Reconciler都可以归属到前面的render阶段,Scheduler负责任务优先级调度 Reconciler负责根据进入的任务来组装对比fiber结构,这个过程里高优先级可以打断低优先级,协调器 生产对比fiber也是可以被打断的, 也就验证了render可以被打断这一说法。

Renderer阶段对应大阶段就是我们说的commit阶段,这个阶段渲染器 工作是不可以被打断的,它负责渲染更新界面。

然后我们知道了这个过程之后,大体来捋一下加载 和更新的流程,后续再贴代码 具体的位置。

加载

  1. 首先是react中有一个函数createElement 创建生成 虚拟dom,后续会根据这个 vNode来生成对应的fiber结构。
  2. 进入流程 我们调用render方法来进行首次渲染,首先react内部有一个reactRoot对象,然后 root对象内部内部有一个_internalRoot 里面存储的 fiberRoot,这个reactRoot对象是整个应用的根,每次react开始调度,不管是初始渲染还是setState都是从根开始的。
  3. 首次渲染会已最高级优先级来执行,因为要尽快让大家看到界面,filberRoot上会同时创建一个未初始化的Fiber对象,也就是uninitialFiber对象(rootFiber对象),这里的rootFiber指的就是我们要挂载的结点,例如#root dom节点,在 React 应用中 FiberRoot 只有一个,而 rootFiber 可以有多个,因为 render 方法是可以调用多次的,这里有一个互相指向的关系。

    1. 在 fiberRoot 对象中有一个 current 属性,存储 rootFiber
    2. 在 rootFiber 对象中有一个 stateNode 属性,指向 fiberRoot
  4. 其实每次React更新都是要对比新旧的Fiber,初次渲染的时候也是要对比新旧Fiber的,但是又因为初次渲染时根本没有上一次的Fiber,所以React才会在一开始就自己创建出一个未初始化也就是啥状态都没有uninitialFiber来假装有上一次的状态,之后才会为本次渲染真正创建一个属于初次渲染的RootFiber,之后用这个RootFiber和刚才那uninitialFiber作对比,到这儿为止都是React自己干的事儿,和我们用React的人传进来的参数一点关系都没有。
  5. 当Root和uninitialFiber以及RootFiber都创建好了,才会真正开始初次的渲染调度,开始渲染后,会从ReactDOM.render传进去的第一个组件(组组件被转换成了树形结构的vnode)开始循环调度,为根组件下以及根组件等每一个节点不管是原生dom节点,还是函数组件或是类组件甚至React内部提供的组件都创建一个自己对应的fiber对象,在这个过程中所创建的fiber,叫做workInProgress,每个workInProgress都连接着一个保存着当前节点更新前状态的fiber,这个前一个状态的fiber叫做current,不过由于是初次渲染,所以只有RootFiber有current也就是uninitialFiber,剩下的所有节点的current都是null(因为初始渲染 那个uninitialFiber对象是个未初始化的fiber对象,是空的)。

扩展重要:这里的current树也就是 目前界面上已渲染的对应的fiber树 而workInProgress 可以被称之为 即将渲染到界面上的树 也就是react中所说的双缓存技术,致力于dom的快速更新,我们渲染时通过比对 两颗fiber树 在内存中形成的完整的dom或筛除的更新节点,这个内存中构建 后续替换的技术被称为双缓存技术, 在我们的workInProgress生成后 会用他来替换 current树,也就是说整体更新结束了之后会把 workInProgress变成 current树

这里放两个图
image.png
图一
image.png
图二
图一和图二意思其实是一样的,因为可以看图二的左边节点都是null,这是首次加载的时候 左边的current也就是我们上面说的uninitialFiber,创建一个空的fiber假装有值,它下面其实是空的, 然后会创建workInProgress 也就是我们的右边的 树,然后在对比时 首次加载 左边没有 就会直接用右边的覆盖,最后生成完 workInProgress 树之后 替代current 树就变成了这样
image.png


  1. 创建workInProgress的过程是一个对 vnode树遍历 深度优先的过程,所以会优先给传进来的react元素的一侧创建workInProgress,一侧创建完了再找他的父节点,才去给父节点以及父节点的兄弟节点去创建fiber,初始渲染时 会把fiber节点的stateNode创建出来,里面存储了类组件的实例或者时函数组件或者是当前fiber的真实dom。
  2. 而当某一侧的子节点都创建好了之后,(归的一个过程往父级返的时候,初始渲染时会把当前dom添加到父级fiber的dom中)会 来判断对比刚刚创建好的这侧的节点,看看是否有更新,对于有更新的节点会被记录到父节点上,这样一层一层地往父节点上记录有更新的子节点,最终就会将全部有更新的节点挂到RootFiber上,(初始渲染时RootFiber下的首个 child 的stateNode会收集到所有的dom结构)。形成一条要更新的fiber链表。(创建完成的workInProgress最后会放到fiberRoot的finishedWork上,方便我们commit提交的时候使用
  3. 这个循环创建(比对新旧fiber 获取初始渲染dom或有更新的fiber节点)fiber的过程叫做render阶段,当render阶段结束说明所有的子节点都有了对应的fiber(拿到了需要更新的节点),形成了一颗fiber树,然后就可以进入提交阶段,也就是commit阶段。
  4. 接下来就是commit的提交渲染阶段了,commit阶段会找到workInProgress树 然后找到我们生成的 更新的fiber链表,然后循环它进行对应的增删改查操作,初始渲染时因为我们,之前在归的过程中以及 把所有的dom结构生成了,存到 RootFiber下的首个 child 中了,所以初始加载循环fiber更新链表时,到rootFiber时,直接把它的child的stateNode 所有的dom片段 添加到界面接可以了, 然后更新操作时循环的就是我们存入的一个一个需要更新的fiber节点了(依次执行对应的fiber的dom操作),然后循环结束,把创建的RootFiber所领头的那颗workInProgress树则变成了current树,这也就验证了我们上面的那个图所说的。

总结下更新流程就是:先创建新状态的workInProgress树,然后把有更新的fiber做成链表挂到RootFiber上,之后进行链表循环的dom更新操作,更新完毕后再把FiberRoot的current指向新的RootFiber之上。

这样业务逻辑上的更新,下面 就是进入源代码来看下 和我们说的对应的代码位置的更新流程。
首先
npx create-react-app react-test
npm run eject
下载react源码,到src

git clone --branch v16.13.1 --depth=1 https://github.com/facebook/react.git src/react

然后修改项目中的包地址 链到我们 下载的react源码中

// 文件位置: react-test/config/webpack.config.js
resolve: {
  alias: {
    "react-native": "react-native-web",
    "react": path.resolve(__dirname, "../src/react/packages/react"),
    "react-dom": path.resolve(__dirname, "../src/react/packages/react-dom"),
    "shared": path.resolve(__dirname, "../src/react/packages/shared"),
    "react-reconciler": path.resolve(__dirname, "../src/react/packages/react-reconciler"),
    "legacy-events": path.resolve(__dirname, "../src/react/packages/legacy-events")
  }
}

修改环境变量

// 文件位置: react-test/config/env.js
const stringified = {
    "process.env": Object.keys(raw).reduce((env, key) => {
       env[key] = JSON.stringify(raw[key])
      return env
   }, {}),
   __DEV__: true,
   SharedArrayBuffer: true,
   spyOnDev: true,
   spyOnDevAndProd: true,
   spyOnProd: true,
   __PROFILE__: true,
   __UMD__: true,
   __EXPERIMENTAL__: true,
   __VARIANT__: true,
   gate: true,
   trustedTypes: true
 }

告诉 babel 在转换代码时忽略类型检查

npm install @babel/plugin-transform-flow-strip-types -D
// 文件位置: react-test/config/webpack.config.js [babel-loader]
plugins: [
  require.resolve("@babel/plugin-transform-flow-strip-types"),
]

导出 HostConfig

// 文件位置: /react/packages/react-reconciler/src/ReactFiberHostConfig.js
+ export * from './forks/ReactFiberHostConfig.dom';
- invariant(false, 'This module must be shimmed by a specific renderer.');

修改 ReactSharedInternals.js 文件

// 文件位置: /react/packages/shared/ReactSharedInternals.js
- import * as React from 'react';
- const ReactSharedInternals = React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;
+ import ReactSharedInternals from '../react/src/ReactSharedInternals';

关闭eslint

// 文件位置: react/.eslingrc.js [module.exports]
// 删除 extends
extends: [
  'fbjs',
  'prettier'
]

禁止 invariant 报错

// 文件位置: /react/packages/shared/invariant.js
export default function invariant(condition, format, a, b, c, d, e, f) {
  if (condition) return;
  throw new Error(
    'Internal React error: invariant() is meant to be replaced at compile ' +
      'time. There is no runtime version.',
  );
}

在 react 源码文件夹中新建 .eslintrc.json 并添加如下配置

{
  "extends": "react-app",
  "globals": {
    "SharedArrayBuffer": true,
    "spyOnDev": true,
    "spyOnDevAndProd": true,
    "spyOnProd": true,
    "__PROFILE__": true,
    "__UMD__": true,
    "__EXPERIMENTAL__": true,
    "__VARIANT__": true,
    "gate": true,
    "trustedTypes": true
  }
}

修改 react react-dom 引入方式

import * as React from "react"
import * as ReactDOM from "react-dom"

解决 vsCode 中 flow 报错 设置
"javascript.validate.enable": false

如果你的 vscode 编辑器安装了 prettier 插件并且在保存 react 源码文件时错误
npm i prettier -g
配置 prettier path

Settings > Extensions > Prettier > Prettier path

启动__DEV__ 报错,删除 node_modules 文件夹,重新npm install

好下一篇我们正式进入源码分析阶段


Charon
57 声望16 粉丝

世界核平