React学习第八天---React & Fiber算法(核心API”requestIdleCallback“的认知和旧版Stack问题,Fiber认知)(一)

1,826 阅读12分钟

这是我参与更文挑战的第20天,活动详情查看: 更文挑战

大家好我是小村儿,在上节我们结束了TinyReact的实现,接下来我们学习React16中用到的被称为Fiber算法,我们会花几个篇章搞明白什么是Fiber算法!!!

项目代码, 大家可以持续跟进学习,共勉!!!

开发配置环境

我们进行Fiber学习之前,我们首先要搭建一个开发环境。

1. 开发项目文件夹结构

文件/文件夹描述
src存储原文件(我们接下来的Fiber代码都卸载这里面)
dist存储客户端代码打包文件(src经过编译后的代码存储在这里)
build存储服务端代码打包文件
server.js存储服务器端代码
webpack.config.server.js服务端webpack配置文件
webpack.config.client.js客户端webpack配置文件
babel.config.jsonbabel配置文件
package.json项目工程文件

image.png

package.json 由命令行输入:

npm init -y 

生成

2. 安装项目第三方依赖

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

安装命令:

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

// 项目依赖
npm i express

3. 开启一个服务端

我们使用express开启一个服务端,监听端口为3000

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>
    </body>
  </html>
`

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

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

这个服务端代码还是不能运行的,还需要babel对齐进行转化,执行webpack打包之后的代码。所以接下来我们应该需要对babel和webpack进行配置

4. 配置好babel 和 webpack

  • 配置babel,将"@babel/preset-env", "@babel/preset-react"引入,为我们es6+代码转化成浏览器,或express能够兼容的代码
//babel.config.json
{
  "presets": ["@babel/preset-env", "@babel/preset-react"]
}

预设es6+代码 和 react代码的转化配置

  • 设置服务端和客户端webpack打包配置 思路:

服务端目标代码为node代码,且为开发环境,打包入口是server.js,输出文件在build文件夹中,path配置的时候需要一个指定路径所以使用path这个模块进行指定 path.resolve(__dirname, "build").然后指定打包的名字为'server.js'.还需要配置打包规则,我们打包是js,则就设置js打包配置,使用的工具是babel-loader.最后配置externals这个配置告诉我们不要去打包node_modules下的模块。

代码:

const path = require('path')
const nodeExternals = require('webpack-node-externals')

module.exports = {
  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"
      }
    }]
  },
  externals: [nodeExternals()]
}

思路:

浏览器端配置和服务端的差不多,我们先拷贝过来进行修改,target改为web,输入文件为src/index.js,输出位置为dist文件夹下,模块配置不变,但是在客户端不需要nodeExternals,我们将其去掉

代码:

const path = require('path')
module.exports = {
  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文件。在基础配置都设置好之后,我们最后在package.json中配置好启动服务上的相关shell命令

"main": "babel.config.json",
"scripts": {
    "start": "npm-run-all --parallel dev:*", // 使用npm-run-all批量执行命令
    "dev:server-compile": "webpack --config webpack.config.server.js --watch",// 执行服务端的webapck命令
    "dev:server": "nodemon ./build/server.js", //当服务端文件发生变化,重启服务端命令
    "dev:client-compile": "webpack --config webpack.config.client.js --watch" // 执行客户端的webpack命令
},

5. 校验开发环境配置

src/index.js添加测试代码,在服务端模板代码中引入客户端打包后的代码文件bundle.js检查配置是否成功.

// server.js

const template = `
  <html>
    ···
      <div id="root">
      </div>
      <script src="bundle.js"></script> // 引入bundle.js
    ···
  </html>
`
//src/index.js
console.log('form client js')

验证:

image.png

成功输出客户端代码。配置成功!!!!哈哈哈哈

认识requestIdleCallback

1. Fiber核心API功能介绍

利用浏览器的空余时间执行任务,如果有更高优先级的任务要执行时,当前的任务可以被终止,优先执行高级别的任务

使用场景:
现在我们有一个计算任务要执行,这个计算任务需要花费比较长的时间,执行任务过程当中,浏览器主线程会被一直占用,主线程被占用的时候浏览器是卡住的,并不能够去执行其他的任务。如果在这个时候用户想要操作这个页面,”向下滚动查看页面其他内容“,此时浏览器是不能响应用户当前操作的。给用户的感觉就是页面卡死了,就会造成非常差的体验。怎么去解决这件事情呢,正好我们可以将这个计算任务放入requestIdleCallback回调函数中,利用浏览器空闲时间执行他,当用户操作页面时,就是优先级高的任务执行了,此事计算任务就会被终止,用户操作就被浏览器响应,用户就不会感觉到页面卡顿了,当高优先级的任务执行完成之后将继续执行requestIdleCallback里面的计算任务。就解决了计算任务时间长占用主线程导致卡顿的问题。

API功能使用
requestIdleCallback接收一个参数,为回调函数,这个回调函数接收一个参数,就是浏览器的空闲时间(deadline),我们可以根据这个空闲时间deadline去判断执行那个计算任务。

requestIdleCallback(function(dealine) {
    // deadline.timeRemaining() 获取浏览器的空闲时间
})

2. 浏览器空余时间

我们上面反复提到浏览器空闲时间,那浏览器空闲时间到底是什么呢?

页面时一帧一帧绘制出来的,当每秒绘制的帧数达到60时,页面时流畅的,小于这个值,用户会感觉到卡顿;1s 60帧,每一帧分到的时间是 1000/60≈16ms,如果每一帧执行的时间小于16ms,就说明浏览器有空余时间; 如果任务在剩余的时间内内没有完成则会停止任务执行,继续优先执行主任务,也就是说requestIdleCallback总是利用浏览器的空余时间执行任务

3. API功能体验

光想总是体感不会那么真切,我们敲代码直接使用requestIdleCallback,来感受下真实效果

  • 思路:

页面中有两个按钮和一个DIV,点击第一个按钮执行一项昂贵的计算,使其长期占用主线程,当计算任务执行的时候去点击第二个按钮更改页面中DIV的背景颜色。我们知道如果主线程长期被占用,浏览器是不会响应用户操作的,也就是div背景颜色是不能得到更改的。

使用requestIdleCallback就可以完美解决这个卡顿的问题。

// html
<div id="box"></div>
<button id="btn1">执行计算任务</button>
<button id="btn2">更改背景颜色</button>
// style 
<style>
#box{
  padding: 20px;
  background: palegoldenrod;
}
</style>
// js
<script>
    var box = document.getElementById("box")
    var btn1 = document.getElementById("btn1")
    var btn2 = document.getElementById("btn2")

    var number = 99999
    var value = 0

    function calc() {
      while (number > 0) {
        value = Math.random() < 0.5 ? Math.random() : Math.random();
        console.log(value)
        number--
      }
    }
    btn1.onclick = function () {
      calc()
    }
    btn2.onclick = function () {
      box.style.background = "green"
    }
  </script>

效果:

使用上面代码会长时间卡顿之后,再响应变色。算是已经很卡了

image.png 优化:

当点击第一个按钮的时候我们把昂贵的计算任务放到requestIdleCallback这个函数的回调函数里面去执行。 注意: 如果高级任务执行,循环被终止,我们等高级任务被执行完,应该再次调用requestIdleCallback(calc)执行循环

function calc(deadline) {
      // 当空闲时间大于1ms的时候执行该循环
      while (number > 0 && deadline.timeRemaining() > 1) {
        value = Math.random() < 0.5 ? Math.random() : Math.random();
        console.log(value)
        number--
      }
      // 在这里应该再次执行计算任务
      requestIdleCallback(calc)
    }
btn1.onclick = function () {
      requestIdleCallback(calc)
}

效果: 这样就可以点击第二个按钮立马给盒子换上了颜色

image.png

旧版Stack算法问题

以前DOM比对的算法名字叫做Stack。在React16之前的版本比对更新VirtualDOM的过程是采用循环加递归实现的,这种比对方式有一个问题,就是一旦任务开始进行就无法中断,如果应用中组件见数量庞大,主线程被长期占用,知道整棵VirtualDOM树比对更新完成之后主线程才能被释放,主线程才能执行其他任务。这就会导致一些用户交互,动画等任务无法立即得到执行,页面就会产生卡顿,非常的影响用户体验。

核心问题: 递归无法中断,执行重任务耗时长。JavaScript又是单线程,无法同时执行其他任务,导致任务延迟页面卡顿,用户体验差。

Fiber 算法

在React16当中官方对React代码进行了大量的重写,其中Fiber就是很重要的一部分,什么是React & Fiber呢?其实Fiber就是一种DOM比对的新的算法,Fiber就是这种算法的名字。

1. Fiber解决方案

  1. 利用浏览器空闲时间执行任务,拒绝长时间占用主线程
    • 使用requestIdleCallback利用浏览器空闲时间,virtualDOM的比对不会占用主线程,如果有高优先级的任务要执行就会暂时中止VirtualDOM比对的过程,先去执行高优先级的任务,高优先级任务执行完成之后,在又开始执行VirtualDOM比对的任务,这样的话就不会出现页面卡顿的现象了。
  2. 放弃递归只采用循环,因为循环可以被中断
    • 由于递归需要一层一层进入,一层一层退出,这个过程不能间断,如果要实现VirtualDOM比对任务可以被终止,就必须放弃递归,采用循环来完成VirtualDOM比对的过程,因为循环是可以终止的。只要将循环的终止时的条件保存下来,下一次任务再次开启的时候,循环就可以在前一次循环终止的时刻继续往后执行。
  3. 任务拆分, 将任务拆分成一个个小任务
    • 拆分成一个个小任务,任务的单元就比较小,这样的话即使任务没有执行完就被终止了,重新执行任务的代价就会小很多,所以我们要做任务的拆分,将一个个大的任务拆分成一个个小任务执行。是怎么进行拆分的呢?
      以前我们将整个一个VirtualDOM的比对看成一个任务,现在我们将树种每一个节点的比对看成一个任务,这样一个大的任务就拆分成一个个小任务了。

为什么新的React VirtualDOM比对(diff)算法叫做Fiber呢?Fiber翻译过来就叫做<纤维>,表示限制任务执行的颗粒度很细了,像纤维一样。

2. 实现思路

在Fiber方案中,为了实现任务的终止再继续,DOM比对算法被拆分了两个部分:第一部分就是VirtualDOM的比对(也称为构建Fiber),第二部分就是真实DOM的更新(也称为提交Commit)。其中VirtualDOM的比对过程是可以终止的,真实DOM的更新更新时不可以被终止的。

  1. 构建Fiber

在使用React编写用户界面的时候我们还是使用js语法,babel会将JSX转回为React.createElement方法的调用,React.createElement被调用会返回virtualDOM,接下来就可以执行第一个阶段,第一阶段就是去构建Fiber对象,我们要采用循环的方式,从这个VirtualDOM对象当中找到内部的VirtualDOM对象,我们要为每个内部的VirtualDOM对象构建Fiber对象,Fiber对象也是JavaScript对象,是从VitualDOM对象演化而来,Fiber对象中除了有type, props,children属性以外,还存储了更多的关于节点的信息。其中 有一个很重要的信息呢就是记录当前节点要执行的操作,比如是想删除这个节点还是像更新这个节点,还是新增这个节点。当所有fiber对象构建完之后,还要将这些fiber对象存储在一个数组中。接下来就进行第二阶段操作

DOM初始渲染:virtualDOM->Fiber->Fiber[]->DOM

  1. 提交Commit

在循环FIber操作中,比对newFiber和oldFiber,更新节点操作类型,根据Fiber对象当中存储节点要实现操作的类型,将这个操作应用真实DOM对象当中。

DOM更新操作:newFiber vs oldFiber -> Fiber[] -> DOM

注意: 在执行第二阶段的时候,所有节点的Fiber对象,存储在一个数组中,原本的DOM节点不论是谁的子级,父级,兄弟关系,现在都变成了数组的第n个元素。也就是说DOM与DOM节点的关系被抹平了。但是在执行第二阶段的时候,我们需要构建出完整的DOM树,才能渲染到页面当中。也就是我们必须要知道谁是谁的自己谁是谁的父级谁是谁的兄弟,这样的我们才能准确的构建出我们DOM树。

3. Fiber对象

所以在根据上面所说的问题我们不止要存储当前fiber的对象需要的更新操作(effectTag),我们还要存储当前节点的子集(child)存储当前节点的父级(parent),存储当前节点的同级(sibling),以便我们在循环Fiber数组的时候,方便知道他们之间的关系,从而构建出一个完整DOM节点树。知道这些我们就可以写一个大概的Fiber对象了

{
    type        节点类型(元素,文本,组件)(具体的类型)
    props       加点属性
    stateNode   节点DOM对象 | 组件实例对象
    tag         节点标记(对具体类型的分类 hostRoot || hostComponent || classComponent || functionComponent)
    effects     数组,存储需要更改的fiber对象
    effectTag   当前Fiber要被执行的操作(新增,删除,修改)
    parent      当前Fiber的下一个父级Fiber
    child       当前Fiber的下一个子级Fiber
    sibling     当前Fiber的下一个兄弟Fiber
    alternate   Fiber 备份 fiber比对时使用
}

总结

以上,就是今天学习的内容。最开始我们先将我们的以后学习的代码开发环境进行配置,webpack的服务端和浏览器端的配置,server.js中启动一个express服务端任务,在使用babel和webpack打包出浏览器和服务器端可执行代码。在服务端webpack结合babel打包出node可执行代码,在客户端结合webapck和babel将前端reactJSX和js代码打包构建成浏览器可执行代码,完成开发环境基本搭建。

然后我们再对Fiber算法核心API requestIdleCallback有一个基本的认知,可以让一段js代码在代码空闲时间执行,并且使用一段代码进行真实感受,requestIdleCallback可以解决当有一段重任务执行时阻塞主线程导致页面卡顿的问题

我们学习了旧版Stack算法的问题,采用的是递归VirtualDOM比对,由于递归不可中断,当我们页面中有很多组件的时候,回导致页面出现卡顿。只有放弃递归比对使用requestIdleCallbackApi才能解决此问题 这就是Fiber算法要解决的问题:

  1. 利用浏览器空闲时间执行任务,拒绝长时间占用主线程
  2. 放弃递归只采用循环,因为循环可以被中断
  3. 任务拆分, 将任务拆分成一个个小任务

这是fiber的解决方案,通过解决方案和思路最后设计出了fiber对象的基本模型:

{
    type        节点类型(元素,文本,组件)(具体的类型)
    props       加点属性
    stateNode   节点DOM对象 | 组件实例对象
    tag         节点标记(对具体类型的分类 hostRoot || hostComponent || classComponent || functionComponent)
    effects     数组,存储需要更改的fiber对象
    effectTag   当前Fiber要被执行的操作(新增,删除,修改)
    parent      当前Fiber的下一个父级Fiber
    child       当前Fiber的下一个子级Fiber
    sibling     当前Fiber的下一个兄弟Fiber
    alternate   Fiber 备份 fiber比对时使用
}

今天希望大家对我们要做的事情的开发基本配置有个基本认知,并且需要知道以前Stack算法的问题,和Fiber的基本原理和思想,核心APIrequestIdleCallback的体感认知,接下来就是Fiber算法的实现,敬请期待!!!

如果看到了这里,希望能点赞,关注,评论(指出你的疑问),谢谢

源码https://github.com/zelixag/Fiber/tree/main

参考教程:
拉钩教程

React官方文档

MDN requestIdleCallback