requestIdleCallback的认知
开发环境配置
文件夹结构
文件/文件夹 | 描述 |
---|---|
src | 存储源文件 |
dist | 存储客户端代码打包文件 |
build | 存储服务端代码打包文件 |
webpack.config.server.js | 服务端 webpack 配置文件 |
webpack.config.client.js | 客户端 webpack 配置文件 |
babel.config.json | babel 配置文件 |
package.json | 项目工程文件 |
创建 package.json 文件:
npm init -y
安装项目依赖
安装依赖:
# 开发依赖
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/core | JavaScript 代码转换工具 |
@babel/preset-env | babel 预置,转换高级 JavaScript 语法 |
@babel/preset-react | babel 预置,转换 JSX 语法 |
babel-loader | webpack 中的 babel 工具加载器 |
nodemon | 监控服务端文件变化,重启应用 |
npm-run-all | 命令行工具,可以同时执行多个命令 |
express | 基于 node 平台的 web 开发框架 |
开启一个服务端
我们使用 express 开启一个服务端,监听端口为 3000
// 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+ 代码转换成浏览器能够兼容的代码
// babel.config.json
{
"presets": ["@babel/preset-env", "@babel/preset-react"]
}
服务端目标代码为 node 代码,且为开发环境,打包入口是 server.js
,输出文件在 build 文件夹中,还需要配置打包规则,使用工具是 babel-loader,最后配置 externals 这个配置告诉我们不要去打包 node_modules 下的模块
// 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
// 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
// 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
利用浏览器空闲时间执行任务,如果有更高优先级的任务要执行时,当前执行的任务可以被终止,优先执行高级别任务
使用场景:
- 现在我们有一个计算任务要执行,这个计算任务需要花费比较长的时间,执行过程中,浏览器主线程会被一直占用,主线程被占用的时候浏览器是卡住的,并不能够去执行其他的任务
- 如果在这个时候用户想要操作这个页面,"向下滚动查看页面其他内容",此时浏览器是不能响应用户当前操作的。给用户的感觉就是页面卡死了,就会造成非常差的体验
如何解决:
- 我们可以将这个计算任务放入 requestIdleCallback 回调函数中,利用浏览器空闲时间执行它
- 当用户操作页面时,就是优先级高的任务执行了,此时计算任务就会被终止,用户操作就被浏览器响应,用户就不会感觉到页面卡顿了,当高优先级的任务执行完成之后将继续执行 requestIdleCallback 里面的计算任务
requestIdleCallback(function(deadline) {
deadline.timeRemaining() // 获取浏览器的空闲时间
})
浏览器空余时间
浏览器空闲时间到底是什么呢?
- 页面是一帧一帧绘制出来的,当每秒绘制的帧数达到 60 时,页面是流畅的,小于这个值时,用户会感觉到卡顿
- 1s 60帧,每一帧分别的时间是
1000/60 ≈ 16ms
,如果每一帧执行的时间小于 16ms,就说明浏览器有空余时间 - 如果任务在剩余的时间内没有完成则会停止任务执行,继续优先执行主任务,也就是说 requestIdleCallback 总是利用浏览器的空余时间
直接使用 requestIdleCallback
,来感受下真实效果
- 页面中有两个按钮和一个 DIV,点击第一个按钮执行一项昂贵的计算,使其长期占用主线程,当计算任务执行的时候去点击第二个按钮更改页面中 DIV 的背景颜色。我们知道如果主线程长期被占用,浏览器是不会响应用户操作的,也就是 DIV 背景颜色是不能得到更改的
- 使用 requestIdleCallback 就可以完美解决这个卡顿问题
<!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 又是单线程,无法同时执行其他任务,导致任务延迟页面卡顿,用户体验差
解决方案
- 利用浏览器空闲时间执行任务,拒绝长时间占用主线程
- 使用 requestIdleCallback 利用浏览器空闲时间,virtualDOM 的比对不会占用主线程,如果有高优先级的任务要执行就会暂时终止 virtualDOM 比对的过程,先去执行高优先级的任务,高优先级任务执行完成之后,再开始执行 virtualDOM 比对的任务,这样的话就不会出现页面卡顿的现象了
- 放弃递归只采用循环,因为循环可以被中断
- 由于递归需要一层一层进入,一层一层退出,这个过程不能间断,如果要实现 virtualDOM 比对任务可以被终止,就必须放弃递归,采用循环来完成 virtualDOM 比对的过程,因为循环是可以终止的。只要将循环的终止时的条件保存下来,下一次任务再次开启的时候,循环就可以在前一次循环终止的时候继续往后执行
- 任务拆分,将任务拆分成一个个小的任务
- 拆分成一个个小任务,任务的单元就比较小,这样的话即使任务没有执行完就被终止了,重新执行任务的代价就会小很多,所以我们要做任务的拆分,将一个个大的任务拆分成一个个小的任务执行
- 以前我们将整个一个 virtualDOM 的比对看成一个任务,现在我们将树中每一个节点的比对看做出一个任务,这样一个个打的任务就拆分成一个个小任务了
为什么新的 React virtualDOM 比对(diff)算法叫做 Fiber 呢?Fiber 翻译过来就叫做<纤维>,表示限制任务执行的颗粒度很细了,像纤维一样
实现思路
在 Fiber 方案中,为了实现任务的终止再继续,DOM 比对算法被分成了两部分:
VirtualDOM 的比对(也称为构建 Fiber)可中断
DOM 初始渲染:virtualDOM -> Fiber -> Fiber[] -> DOM
真实 DOM 的更新(也称为提交 Commit)不可中断
DOM 更新操作:newFiber vs oldFiber -> 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 比对时使用
}