React Fiber 01 - Fiber介绍、requestIdleCallback、开发环境配置

学习内容

本文学习 React Fiber 的相关内容。

React Fiber 是对 React 核心算法的重新实现,也是 React 团队花了两年时间研究的结晶。

React Fiber 是异步渲染 UI 的解决方案。

出现的场景:当页面元素过多,且需要频繁刷新的场景下,浏览器页面会出现卡顿现象,原因是大量的计算任务持续占据着主线程,从而阻塞了 UI 渲染。

Fiber 的解决方案就是将 计算任务 分成一个个小任务,分批完成,在完成一个小任务后,将控制权还给浏览器,让浏览器利用空余时间进行 UI 渲染,

Fiber 实际上是借助 浏览器提供的 window.requestIdleCallback 实现。

相关参考文档:

下面将会具体学习 Fiber。

开发环境配置

目录结构

├─ build										# 存储服务端代码打包文件
├─ dist											# 存储客户端代码打包文件
├─ src											# 存储源文件
├─ babel.config.js				  # babel 配置文件
├─ package.json							 # 项目工程文件
├─ server.js								 # 存储服务器端代码
├─ webpack.config.client.js		# 客户端 webpack 配置文件
└─ webpack.config.server.js		# 服务端 webpack 配置文件

创建目录和文件

# 创建项目目录
mkdir fiber
cd ./fiber
# 创建文件夹:src dist build
# 新建文件:babel.config.js server.js webpack.config.client.js webpack.config.server.js
# 生成 package.json
npm init -y

安装项目依赖

开发依赖:npm i webpack webpack-cli webpack-node-externals @babel/core @babel/preset-env @babel/preset-react babel-loader npm-run-all nodemon -D

项目依赖:npm i express

如果全局已安装 nodemon 则可以忽略。

依赖项描述
webpack模块打包工具
webpack-cli打包命令
webpack-node-externals打包服务器端模块时剔除 node_modules 文件夹中的模块
@babel/coreJavaScript 代码转换工具
@babel/preset-envbabel 预置,转换高级 JavaScript 语法
@babel/preset-reactbabel 预置,转换 JSX 语法
babel-loaderwebpack 中的 babel 工具加载器
npm-run-all命令行工具,可以同时执行多个命令
nodemon命令行工具,监控服务端文件变化,重启应用
express基于 node 平台的 web 开发框架

环境配置

创建 web 服务器

// server.js
import express from 'express'

const app = express()

const template = `
  <html>
    <head>
      <title>React Fiber</title>
    </head>
    <body>
      <div id="root"></div>
    </body>
  </html>
`

app.get('*', (req, res) => {
  res.send(template)
})

app.listen(3000, () => console.log('server is running on http://localhost:3000'))

服务端 webpack 配置

// webpack.config.server.js
const path = require('path')
const nodeExternals = require('webpack-node-externals')
module.exports = {
  // node 环境下运行
  target: 'node',
  // 开发环境模式
  mode: 'development',
  // 入口文件
  entry: './server.js',
  // 打包路径
  output: {
    path: path.resolve(__dirname, 'build'),
    filename: 'server.js'
  },
  module: {
    // 加载器配置
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader'
        }
      }
    ]
  },
  // 打包时剔除 node_modules 下的模块
  externals: [nodeExternals()]
}

babel 配置

// babel.config.js
module.exports = {
  presets: ['@babel/preset-env', '@babel/preset-react']
}

package.json 启动命令

"scripts": {
  // 同时执行dev开头的命令
  "start": "npm-run-all --parallel dev:*",
  // 打包服务端代码
  "dev:server-compile": "webpack --config webpack.config.server.js --watch",
  // 运行打包后的服务端代码
  "dev:server": "nodemon ./build/server.js"
},

运行 npm start,访问 http://localhost:3000

客户端 webpack 配置

// webpack.config.client.js
const path = require('path')
module.exports = {
  // 浏览器环境下运行(target默认为web)
  // target: 'web',
  // 开发环境模式
  mode: 'development',
  // 入口文件
  entry: './src/index.js',
  // 打包路径
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js'
  },
  devtool: 'source-map',
  module: {
    // 加载器配置
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader'
        }
      }
    ]
  }
}

添加入口文件:src/index.js

HTML 模板加载客户端脚本:

// server.js
import express from 'express'

const app = express()

app.use(express.static('dist'))

const template = `
  <html>
    <head>
      <title>React Fiber</title>
    </head>
    <body>
      <div id="root"></div>
      <script src="bundle.js"></script>
    </body>
  </html>
`

app.get('*', (req, res) => {
  res.send(template)
})

app.listen(3000, () => console.log('server is running'))

添加客户端打包脚本:

 "scripts": {
    "start": "npm-run-all --parallel dev:*",
    "dev:server-compile": "webpack --config webpack.config.server.js --watch",
    "dev:server": "nodemon ./build/server.js",
    "dev:client-compile": "webpack --config webpack.config.client.js --watch"
  },

requestIdleCallback API

requestIdleCallback 是浏览器提供的 Web API,它是 React Fiber 中用到的核心 API。

API 介绍

requestIdleCallback 利用浏览器的空余时间执行任务,如果浏览器没有空余时间,可以随时终止这些任务。

这样可以实现如果有更高优先级的任务要执行时,当前执行的任务可以被终止,优先执行高级别的任务。

原理是该方法将 在浏览器的空闲时段内调用的函数 排队。

这样使得开发者能够在主事件循环上 执行后台和低优先级的任务,而不会影响 像动画和用户交互 这些关键的延迟触发的事件

这里的“延迟”指的是大量计算导致运行时间较长。

浏览器空余时间

页面是一帧一帧绘制出来的,当每秒绘制的帧数达到 60 时,页面时流畅的,小于这个值时,用户会感觉到卡顿。

1秒60帧意思是1秒中60张画面在切换。

当帧数低于人眼的捕捉频率(有说24帧或30帧,考虑到视觉残留现象,这个数值可能会更低)时,人脑会识别这是几张图片在切换,也就是静态的。

当帧数高于人眼的捕捉频率,人脑会认为画面是连续的,也就是动态的动画。

帧数越高画面就看起来更流畅。

1秒60帧(大约 1000/60 ≈ 16ms 切换一个画面)差不多是人眼能识别卡顿的分界线。

如果每一帧执行的时间小于 16 ms,就说明浏览器有空余时间。

一帧时间内浏览器要做的事情包括:脚本执行、样式计算、布局、重绘、合成等。

如果某一项内容执行时间过长,浏览器会推迟渲染,造成丢帧卡顿,就没有剩余时间。

应用场景

比如现在有一项计算任务,这项任务需要花费比较长的时间(例如超过16ms)去执行。

在执行任务的过程当中,浏览器的主线程会被一直占用。

在主线程被占用的过程中,浏览器是被阻塞的,并不能执行其他的任务。

如果此时用户想要操作页面,比如向下滑动页面查看其它内容,浏览器是不能响应用户的操作的,给用户的感觉就是页面卡死了,体验非常差。

如何解决呢?

可以将这项任务注册到 requestIdleCallback 中,利用浏览器的空余时间执行它。

当用户操作页面时,就是优先级比较高的任务被执行时,此时计算任务会被终止,优先响应用户的操作,这样用户就不会感觉页面发生卡顿了。

当高优先级的任务执行完成后,再继续执行计算任务。

requestIdleCallback 的作用就是利用浏览器的空余时间执行这些需要大量计算的任务,当空余时间结束,会中断计算任务,执行高优先级的任务,以达到不阻塞主线程任务(例如浏览器 UI 渲染)的目的。

使用方式

var handle = window.requestIdleCallback(callback[, options])
  • callback:一个在空闲时间即将被调用的回调函数
    • 该函数接收一个形参:IdleDeadline,它提供一个方法和一个属性:
      • 方法:timeRemaining()
        • 用于获取浏览器空闲期的剩余时间,也就是空余时间
          • 返回值是毫秒数
          • 如果闲置期结束,则返回 0
        • 根据时间的多少可以来决定是否要执行任务
      • 属性:didTimeout(Boolean,只读)
        • 表示是否是上一次空闲期因为超时而没有执行的回调函数
        • 超时时间由 requestIdleCallback 的参数options.timeout 定义
  • options:可选配置,目前只有一个配置项
    • timeout:超时时间,如果设置了超时时间并超时,回调函数还没有被调用,则会在下一次空闲期强制被调用

功能体验

页面中有两个按钮和一个 DIV,点击第一个按钮执行一项昂贵的计算,使其长期占用主线程,当计算任务执行的时候去点击第二个按钮更改页面中 DIV 的背景颜色。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>requestIdleCallback</title>
    <style>
      #box {
        background: palegoldenrod;
        padding: 20px;
        margin-bottom: 10px;
      }
    </style>
  </head>
  <body>
    <div id="box">playground</div>
    <button id="btn1">执行计算任务</button>
    <button id="btn2">更改背景颜色</button>

    <script>
      var box = document.querySelector('#box')
      var btn1 = document.querySelector('#btn1')
      var btn2 = document.querySelector('#btn2')
      var number = 100000000
      var value = 0

      function calc() {
        while (number > 0) {
          value = Math.random() < 0.5 ? Math.random() : Math.random()
          number--
        }
      }

      btn1.onclick = function () {
        calc()
      }

      btn2.onclick = function () {
        console.log(number) // 0:计算任务执行完
        box.style.background = 'palegreen'
      }
    </script>
  </body>
</html>

在这里插入图片描述

使用 requestIdleCallback可以完美解决这个卡顿问题:

var box = document.querySelector('#box')
var btn1 = document.querySelector('#btn1')
var btn2 = document.querySelector('#btn2')
var number = 100000000
var value = 0

function calc(IdleDeadline) {
  while (number > 0 && IdleDeadline.timeRemaining() > 1) {
    value = Math.random() < 0.5 ? Math.random() : Math.random()
    number--
  }
  if (number > 0) {
    requestIdleCallback(calc)
  } else {
    console.log('计算结束')
  }
}

btn1.onclick = function () {
  requestIdleCallback(calc)
}

btn2.onclick = function () {
  console.log(number) // 显示当前计算中的number
  box.style.background = 'palegreen'
}

在这里插入图片描述

  • 浏览器在空余时间执行 calc 函数
  • 当空余时间小于 1ms 时,跳出while循环
  • calc 根据 number 判断计算任务是否执行完成,如果没有完成,则继续注册新的空闲期的任务
  • btn2 点击事件触发,会等到当前空闲期任务执行完后执行“更改背景颜色”的任务
  • “更改背景颜色”任务执行完成后,继续进入空闲期,执行后面的任务

由此可见,所谓执行优先级更高的任务,是手动将计算任务拆分到浏览器的空闲期,以实现每次进入空闲期之前优先执行主线程的任务。

Fiber 出现的目的

Fiber 其实是新的 DOM 比对算法的名字,旧的 DOM 比对算法的名字是 Stack

React 16之前的版本存在的问题

React 16之前的版本对比更新 VirutalDOM 的过程是采用循环加递归实现的。

这种对比方式有一个问题,就是一旦任务开始进行旧无法中断(由于递归需要一层一层的进入,一层一层的退出,所以过程不能中断)。

如果应用中组件数量庞大,主线程被长期占用,直到整棵 VirtualDOM 树对比更新完成之后主线程才能被释放,主线程才能执行其它任务。

这就会导致一些用户交互、动画等任务无法立即得到执行,页面就会产生卡顿,非常影响用户的体验。

因为递归利用的 JavaScript 自身的执行栈,所以旧版 DOM 比对的算法称为 Stack(堆栈)

核心问题:递归无法中断,执行重任务耗时长,JavaScript 又是单线程的,无法同时执行其它任务,导致在绘制页面的过程当中不能执行其它任务,比如元素动画、用户交互等任务必须延后,给用户的感觉就是页面变得卡顿,用户体验差。

Stack 算法模拟

模拟 React 16 之前将虚拟 DOM 转化成真实 DOM 的递归算法:

// 要渲染的 jsx
const jsx = (
	<div id="a1">
    <div id="b1">
      <div id="c1"></div>
      <div id="c2"></div>
    </div>
    <div id="b2"></div>
  </div>
)

jsx 会被 Babel 转化成 React.createElement() 的调用,最终返回一个虚拟 DOM 对象:

"use strict";

const jsx = /*#__PURE__*/React.createElement("div", {
  id: "a1"
}, /*#__PURE__*/React.createElement("div", {
  id: "b1"
}, /*#__PURE__*/React.createElement("div", {
  id: "c1"
}), /*#__PURE__*/React.createElement("div", {
  id: "c2"
})), /*#__PURE__*/React.createElement("div", {
  id: "b2"
}));

去掉一些属性,打印结果:

const jsx = {
  type: 'div',
  props: {
    id: 'a1',
    children: [
      {
        type: 'div',
        props: {
          id: 'b1',
          children: [
            {
              type: 'div',
              props: {
                id: 'c1'
              }
            },
            {
              type: 'div',
              props: {
                id: 'c2'
              }
            }
          ]
        }
      },
      {
        type: 'div',
        props: {
          id: 'b2'
        }
      }
    ]
  }
}

递归转化真实 DOM:

const jsx = {...}
function render(vdom, container) {
  // 创建元素
  const element = document.createElement(vdom.type)
  // 为元素添加属性
  Object.keys(vdom.props)
    .filter(prop => prop !== 'children')
    .forEach(prop => (element[prop] = vdom.props[prop]))
  // 递归创建子元素
  if (Array.isArray(vdom.props.children)) {
    vdom.props.children.forEach(child => render(child, element))
  }
  // 将元素添加到页面中
  container.appendChild(element)
}

render(jsx, document.getElementById('root'))

DOM 更新就是在上面递归的过程中加入了 Virtual DOM 对比的过程。

可以看到递归是无法中断的。

React 16 解决方案 - Fiber

  1. 利用浏览器空余时间执行任务,拒绝长时间占用主线程
    1. 在新版本的 React 版本中,使用了 requestIdleCallback API
    2. 利用浏览器空余时间执行 VirtualDOM 比对任务,也就表示 VirtualDOM 比对不会长期占用主线程
    3. 如果有高优先级的任务要执行,就会暂时终止 VirtualDOM 的比对过程,先去执行高优先级的任务
    4. 高优先级任务执行完成,再回来继续执行 VirtualDOM 比对任务
    5. 这样页面就不会出现卡顿现象
  2. 放弃递归,只采用循环,因为循环可以被中断
    1. 由于递归必须一层一层进入,一层一层退出,所以过程无法中断
    2. 所以要实现任务的终止再继续,就必须放弃递归,只采用循环的方式执行比对的过程
    3. 因为循环是可以终止的,只需要将循环的条件保存下来,下一次任务就可以从中断的地方执行了
  3. 任务拆分,将任务拆分成一个个的小任务
    1. 如果任务要实现终止再继续,任务的单元就必须要小
    2. 这样任务即使没有执行完就被终止,重新执行任务的代价就会小很多
    3. 所以要进行任务的拆分,将一个大的任务拆分成一个个小的任务
    4. VirtualDOM 比对任务如何拆分?
      1. 以前将整棵 VirtualDOM 树的比对看作一个任务
      2. 现在将树中每一个节点的比对看作一个任务

新版 React 的解决方案核心就是第 1 点,第 2、3 点都是为了实现第 1 点而存在的,

Fiber 翻译过来是“纤维”,意思就是执行任务的颗粒度变得细腻,像纤维一样。

可以通过这个 Demo 查看 Stack 算法Fiber 算法的效果区别。

实现思路

在 Fiber 方案中,为了实现任务的终止再继续,DOM 对比算法被拆分成了两阶段:

  1. render 阶段(可中断)
    • VirtualDOM 的比对,构建 Fiber 对象,构建链表
  2. commit 阶段(不可中断)
    • 根据构建的链表进行 DOM 操作

过程就是:

  1. 在使用 React 编写用户界面的时候仍然使用 JSX 语法
  2. Babel 会将 JSX 语法转换成 React.createElement() 方法的调用
  3. React.createElement() 方法调用后会返回 VirtualDOM 对象
  4. 接下来就可以执行第一个阶段了:构建 Fiber 对象
    1. 采用循环的方式从 VirtualDOM 对象中,找到每一个内部的 VirtualDOM 对象
    2. 为每一个 VirtualDOM 对象构建 Fiber 对象
    3. Fiber 对象也是 JavaScript 对象,它是从 VirtualDOM 对象衍化来的,它除了 typepropschildren以外还存储了更多节点的信息,其中包含的一个核心信息是:当前节点要进行的操作,例如删除、更新、新增
    4. 在构建 Fiber 的过程中还要构建链表
  5. 接着进行第二阶段的操作:执行 DOM 操作
    1. 在循环链表的过程中,根据当前节点存储的要执行的操作的类型,将这个操作应用到真实 DOM 中

总结:

  • DOM 初始渲染:根据 VirtualDOM --> 创建 Fiber 对象 及 构建链表 --> 将 Fiber 对象存储的操作应用到真实 DOM 中
  • DOM 更新操作:newFiber(重新获取所有 Fiber 对象) --> newFiber vs oldFiber(获取旧的 Fiber 对象,进行比对) 将差异操作追加到链表 --> 将 Fiber 对象应用到真实 DOM 中

什么是 Fiber

Fiber 有两层含义:

  • Fiber 是一个执行单元
  • Fiber 是一种数据结构

执行单元

在 React 16 之前,将 Virtual DOM 树整体看成一个任务进行递归处理,任务整体庞大执行耗时且不能中断。

在 React 16,将整个任务拆分成一个个小的任务进行处理,每个小的任务指的就是一个 Fiber 节点的构建。

任务会在浏览器的空闲时间被执行,每个单元执行完成后,React 都会检查是否还有空余时间,如果有就交还主线程的控制权。

在这里插入图片描述

数据结构

Fiber 是一种数据机构,支撑 Fiber 构建任务的运转。

Fiber 其实就是 JavaScript 对象,对象中存储了当前节点的父节点、第一个子节点、下一个兄弟节点,以便在构建链表和执行 DOM 操作的时候知道它们的关系。

在 render 阶段的时候,React 会从上(root)向下,再从下向上构建所有节点对应的 Fiber 对象,在从下向上的同时还会构建链表,最后将链头存储到 Root Fiber。

  • 从上向下
    • 从 Root 节点开始构建,优先构建子节点
  • 从下向上
    • 如果当前节点没有子节点,就会构建下一个兄弟节点
    • 如果当前节点没有子节点,也没有下一个兄弟节点,就会返回父节点,构建父节点的兄弟节点
    • 如果父节点的下一个兄弟节点有子节点,就继续向下构建
    • 如果父节点没有下一个兄弟节点,就继续向上查找

在第二阶段的时候,通过链表结构的属性(child、sibling、parent)准确构建出完整的 DOM 节点树,从而才能将 DOM 对象追加到页面当中。

// Fiber 对象
{
  type				节点类型(元素、文本、组件)(具体的类型)
  props				节点属性(props中包含children属性,标识当前节点的子级 VirtualDOM)
  stateNode		节点的真实 DOM 对象 | 类组件实例对象 | 函数组件的定义方法
  tag					节点标记(对具体类型的分类 host_root[顶级节点root] || host_component[普通DOM节点] || class_component[类组件]
  || function_component[函数组件])
  effectTag		当前 Fiber 在 commit 阶段需要被执行的副作用类型/操作(新增、删除、修改)
  nextEffect	单链表用来快速查找下一个 sideEffect
  lastEffect	存储最新副作用,用于构建链表的 nextEffect
  firstEffect	存储第一个要执行的副作用,用于向 root 传递第一个要操作的 DOM
  parent			当前 Fiber 的父级 Fiber(React 中是 `return`)
  child				当前 Fiber 的第一个子级 Fiber
  sibling			当前 Fiber 的下一个兄弟 Fiber
  alternate		当前节点对应的旧 Fiber 的备份,用于新旧 Fiber 比对
}

以上面的示例为例:

<div id="a1">
  <div id="b1">
    <div id="c1"></div>
    <div id="c2"></div>
  </div>
  <div id="b2"></div>
</div>

在这里插入图片描述

// B1 的 Fiber 对象包含这几个属性:
{
  child: C1_Fiber,
  sibling: B2_Fiber,
  parent: A1_Fiber
}
  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值