Skip to content

requestIdleCallback的认知

开发环境配置

文件夹结构

文件/文件夹描述
src存储源文件
dist存储客户端代码打包文件
build存储服务端代码打包文件
webpack.config.server.js服务端 webpack 配置文件
webpack.config.client.js客户端 webpack 配置文件
babel.config.jsonbabel 配置文件
package.json项目工程文件

创建 package.json 文件:

bash
npm init -y

安装项目依赖

安装依赖:

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

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

开启一个服务端

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

js
// 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'))

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

配置好 babel 和 webpack

配置好 babel,将 @babel/preset-env@babel/preset-react 引入,可以将 ES6+ 代码转换成浏览器能够兼容的代码

json
// babel.config.json
{
  "presets": ["@babel/preset-env", "@babel/preset-react"]
}

服务端目标代码为 node 代码,且为开发环境,打包入口是 server.js,输出文件在 build 文件夹中,还需要配置打包规则,使用工具是 babel-loader,最后配置 externals 这个配置告诉我们不要去打包 node_modules 下的模块

js
// webpack.config.server.js
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()]
}

浏览器配置和服务端的差不多,需要将目标代码改为 web,输入文件为 src/index.js,输出位置是 dist 文件夹中,其他配置不变,但是客户端不需要 nodeExternals

js
// webpack.config.client.js
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'
        }
      }
    ]
  }
}

最后我们在 package.json

json
// package.json
{
  "main": "babel.config.js",
  "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

Fiber 核心 API

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

img

使用场景:

  • 现在我们有一个计算任务要执行,这个计算任务需要花费比较长的时间,执行过程中,浏览器主线程会被一直占用,主线程被占用的时候浏览器是卡住的,并不能够去执行其他的任务
  • 如果在这个时候用户想要操作这个页面,"向下滚动查看页面其他内容",此时浏览器是不能响应用户当前操作的。给用户的感觉就是页面卡死了,就会造成非常差的体验

如何解决:

  • 我们可以将这个计算任务放入 requestIdleCallback 回调函数中,利用浏览器空闲时间执行它
  • 当用户操作页面时,就是优先级高的任务执行了,此时计算任务就会被终止,用户操作就被浏览器响应,用户就不会感觉到页面卡顿了,当高优先级的任务执行完成之后将继续执行 requestIdleCallback 里面的计算任务
js
requestIdleCallback(function(deadline) { 
  deadline.timeRemaining() // 获取浏览器的空闲时间
})

浏览器空余时间

浏览器空闲时间到底是什么呢?

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

直接使用 requestIdleCallback,来感受下真实效果

  • 页面中有两个按钮和一个 DIV,点击第一个按钮执行一项昂贵的计算,使其长期占用主线程,当计算任务执行的时候去点击第二个按钮更改页面中 DIV 的背景颜色。我们知道如果主线程长期被占用,浏览器是不会响应用户操作的,也就是 DIV 背景颜色是不能得到更改的
  • 使用 requestIdleCallback 就可以完美解决这个卡顿问题
html
<!DOCTYPE html>
<html lang="en">
  <head>
    <style>
      #box {
        padding: 20px;
        background: palegoldenrod;
      }
    </style>
  </head>
  <body>
    <div id="box"></div>
    <button id="btn1">执行计算任务</button>
    <button id="btn2">更改背景颜色</button>
    <script>
      var box = document.getElementById('box')
      var btn1 = document.getElementById('btn1')
      var btn2 = document.getElementById('btn2')
      var number = 99999
      var value = 0

      function calc(deadline) {
        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)
      }

      btn2.onclick = function () {
        box.style.background = 'green'
      }
    </script>
  </body>
</html>

Fiber 算法

React16 之前的版本比对更新 VitrualDOM 的过程是采用循环加速递归实现的,这种比对方法有一个问题,就是一旦任务开始进行就无法中断

  • 如果应用中逐渐数量庞大,主线程被长期占用,直到整颗 VitrualDOM 树比对更新完成之后主线程才能被释放,主线程才能执行其他任务
  • 这就会导致一些用户交互、动画等任务无法立即得到执行,页面就会产生卡顿,非常影响用户体验

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

解决方案

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

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

实现思路

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

  1. VirtualDOM 的比对(也称为构建 Fiber)可中断

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

  2. 真实 DOM 的更新(也称为提交 Commit)不可中断

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

Fiber 对象

js
{
  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 比对时使用
}

常备不懈,才能有备无患