`1. 开发环境配置
1.1 文件夹结构
文件 / 文件夹 | 描述 |
---|---|
src | 存储源文件 |
dist | 存储客户端代码打包文件 |
build | 存储服务端代码打包文件 |
server.js | 存储服务器端代码 |
webpack.config.server.js | 服务端 webpack 配置文件 |
webpack.config.client.js | 客户端 webpack 配置文件 |
babel.config.json | babel 配置文件 |
package.json | 项目工程文件 |
创建 package.json 文件:npm init -y
1.2 安装项目依赖
开发依赖:npm install webpack webpack-cli webpack-node-externals @babel/core @babel/preset-env @babel/preset-react babel-loader nodemon npm-run-all -D
项目依赖:npm install 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 开发框架 |
1.3 环境配置
1.3.1 创建 web 服务器
// 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"))
1.3.2 服务端 webpack 配置
// webpack.config.server.js
const path = require("path")
const nodeExternals = require("webpack-node-externals")
module.exports = {
target: "node",
mode: "development",
entry: "./server.js",
output: {
filename: "server.js",
path: path.resolve(__dirname, "build")
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: "babel-loader"
}
}
]
},
externals: [nodeExternals()]
}
1.3.3 babel 配置
babel.config.json
{
"presets": ["@babel/preset-env", "@babel/preset-react"]
}
1.3.4 客户端 webpack 配置
const path = require("path")
module.exports = {
target: "web",
mode: "development",
entry: "./src/index.js",
output: {
filename: "bundle.js",
path: path.resolve(__dirname, "dist")
},
devtool: "source-map",
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: "babel-loader"
}
}
]
}
}
1.3.5 启动命令
"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"
},
2. requestIdleCallback
2.1 核心 API 功能介绍
requestIdleCallback作用:利用浏览器的空余时间执行任务,如果有更高优先级的任务要执行时,当前执行的任务可以被终止,优先执行高级别任务。
requestIdleCallback作用
:利用浏览器的空余时间执行任务,也就是说:如果浏览器有空余时间,就去执行requestIdleCallback中的任务,如果浏览器没有空余时间,这个正在执行的任务是可以随时被终止的。
它的应用场景是
:举个例子:现在我们有个计算任务要去执行,这个计算任务要花费比较长的时间去执行,在执行任务的过程中,浏览器的主线程会一直被占用,在主线程被占用的过程中,浏览器是卡在那里的,并不能去执行其他的任务,如果此时用户想要去操作这个页面,比如:向下滚动页面去看其他的内容,其实此时浏览器是不能响应用户的操作的,给用户的感觉就是这个页面卡死了,这个体验是非常糟糕的。怎么样才能去解决这个问题呢?
我们可以将这项计算任务注册到requestIdleCallback中,利用浏览器的空余时间去执行它,当用户操作这个页面时,就是优先级高的任务要执行,此时计算任务会被终止,优先响应用户的操作,这样用户就不会感觉到页面的卡顿了。当高优先级的任务要执行后,经继续执行计算任务。这样这个问题就被解决了。
它的使用
:
requestIdleCallback是window对象下面的一个方法,因此可以直接使用它。在调用requestIdleCallback的时候,需要传递一个参数,这个参数是一个函数,就是说:浏览器有空余时间就去执行这个函数,也就是说我们的任务要被写在这个函数中。
deadline:形参。形参有一个方法,timeRemaining,可以获取浏览器的空余时间。根据 【获取浏览器的空余时间的多少来决定要不要执行这个任务。】返回的时间是毫秒。
requestIdleCallback(function(deadline) {
// deadline.timeRemaining() 获取浏览器的空余时间
})
浏览器空余时间到底是什么?
这个页面是一帧一帧绘制出来的,如果每一秒能够绘制60帧,那么用户看到的页面就是流畅的,如果绘制不到60帧,那么用户会感觉到卡顿。1s 60帧,每一帧分到的时间是 1000/60 ≈ 16 ms。如果每一帧执行的时间小于16ms,就说明浏览器有空余时间。那么这个时候是可以执行requestIdleCallback中的任务。如果任务在剩余的时间内没有完成则会停止任务执行,继续优先执行主任务,也就是说 requestIdleCallback 总是利用浏览器的空余时间执行任务
2.2 浏览器空余时间
页面是一帧一帧绘制出来的,当每秒绘制的帧数达到 60 时,页面是流畅的,小于这个值时, 用户会感觉到卡顿
1s 60帧,每一帧分到的时间是 1000/60 ≈ 16 ms,如果每一帧执行的时间小于16ms,就说明浏览器有空余时间
如果任务在剩余的时间内没有完成则会停止任务执行,继续优先执行主任务,也就是说 requestIdleCallback 总是利用浏览器的空余时间执行任务
2.3 API 功能体验
页面中有两个按钮和一个DIV,点击第一个按钮执行一项昂贵的计算,使其长期占用主线程,当计算任务执行的时候去点击第二个按钮更改页面中 DIV 的背景颜色。
使用 requestIdleCallback 就可以完美解决这个卡顿问题。
<div class="playground" id="play">playground</div>
<button id="work">start work</button>
<button id="interaction">handle some user interaction</button>
<style>
.playground {
background: palevioletred;
padding: 20px;
margin-bottom: 10px;
}
</style>
var play = document.getElementById("play")
var workBtn = document.getElementById("work")
var interactionBtn = document.getElementById("interaction")
var iterationCount = 100000000
var value = 0
var expensiveCalculation = function (IdleDeadline) {
while (iterationCount > 0 && IdleDeadline.timeRemaining() > 1) {
value =
Math.random() < 0.5 ? value + Math.random() : value + Math.random()
iterationCount = iterationCount - 1
}
requestIdleCallback(expensiveCalculation)
}
workBtn.addEventListener("click", function () {
requestIdleCallback(expensiveCalculation)
})
interactionBtn.addEventListener("click", function () {
play.style.background = "palegreen"
})
3 Fiber
3.1 问题
旧版本中react存在的问题和新版本的解决方案:
旧版本中react存在的问题:
什么是fiber呢?其实fiber就是dom比对的新的一种算法。fiber就是这种算法的名字,以前dom对比的算法名字叫做stack。
为什么react官方要重写react比对算法呢?
这是因为react16之前的版本比对更新虚拟dom的过程采用循环加递归的方式实现的,这种比对方式有一个问题,就是递归需要一层一层的进入,一层一层的退出,过程不能中断,如果过程中组件的数量非常庞大,那么主线程就会被长期占用,直到整颗虚拟dom树更新完成之后,主线程才能被释放,主线程才能去执行其他的任务,就会导致一些用户交互,例如:动画,任务无法立即得到执行,页面就会卡顿,非常影响用户的体验。
就是因为递归利用的是javascirp的自身执行栈,所以旧版虚拟dom比对的算法叫做stack(堆栈)。
React 16 之前的版本比对更新 VirtualDOM 的过程是采用循环加递归实现的,这种比对方式有一个问题,就是一旦任务开始进行就无法中断,如果应用中组件数量庞大,主线程被长期占用,直到整棵 VirtualDOM 树比对更新完成之后主线程才能被释放,主线程才能执行其他任务。这就会导致一些用户交互,动画等任务无法立即得到执行,页面就会产生卡顿, 非常的影响用户体验。
总结:核心问题
:递归无法中断,长期占用主线程,JavaScript 又是单线程,无法同时执行其他任务,导致在绘制页面的过程中不能执行其他的任务,比如:元素动画,用户交互等任务,必须要延后,页面就会产生卡顿,用户体验差。
3.2 解决方案
react16中的解决方案:
- 用了requestIdleCallback这个api。利用浏览器空余时间来完成这个比对的过程,也就是说:虚拟dom的比对不会长期占用主线程了,如果有高优先级的任务要执行,就会暂时终止虚拟dom比对的过程,先去执行高优先级的任务要。高优先级的任务执行完成,再去执行虚拟dom比对的任务。这样的话页面就不会卡顿了。
- 由于采用了递归递,一层一层的进入,一层一层的退出,过程不能中断。那么就必须放弃递归,只采用循环来执行比对的过程,循环可以终止。只要将循环的条件保存下来,下一次任务就可以继续从中断的地方执行。
- 如果任务要实现终止再继续,那么任务的单元必须要小,这样的话,即使任务没有执行完就被终止了,重新执行任务的代价就会小很多,所以我们要做任务的拆分,将一个大的任务,拆分成一个个小的任务来执行。以前将整颗树的比对看成是一个任务,现在将每个节点看成一个任务,这样话,一个大的任务就会被拆成一个个小的任务了。为什么新版比对的过程叫做fiber?fiber中文翻译:纤维。意思就是:执行的任务颗粒度变细腻了,像一个个纤维一样。
解决方案总结:
- 利用浏览器空闲时间执行任务,拒绝长时间占用主线程
- 放弃递归只采用循环,因为循环可以被中断
- 任务拆分,将任务拆分成一个个的小任务
3.3 实现思路
Fiber具体实现:
在Fiber中为了实现任务的终止再继续,它将dom比对的过程拆分成2个部分:1.虚拟dom的比对 2.真实dom的更新。其中虚拟dom的比对是可以终止的。真实dom的更新是不能终止的。
它的具体过程是这样的:
在使用react编写用户界面的时候,我们仍然使用jxs语法,babel会将jsx语法转换成Raect.createElement()方法的调用,Raect.createElement()方法在调用后会返回虚拟dom对象,接下来就可以执行第一个阶段了,第一个阶段就是构建fiber对象,我们要采用循环的方式,从这个虚拟dom对象中,找到每一个内部的虚拟dom对象,我们要为每一个虚拟dom对象构建fiber对象,fiber对象也是javascript对象,它是从虚拟dom对象演化来的。Fiber 对象中存储了更多的信息。其中一个重要的信息是:对当前节点的操作,例如 (新增, 删除, 修改)。当所有的fiber对象构建完成之后,将fiber存储在一个数组中。接下来就可以执行第2个阶段的操作了。就是循环fiber数组。在循环的过程中,根据fiber对象中存储的【当前节点要操作的类型将这个操作应用在真实的dom对象当中】。这就是fiber算法的一个流程。
总结:
如果是初始化渲染
:通过虚拟dom对象,构建Fiber对象,再将Fiber对象存储在一个数组中,然后将Fiber对象要执行的操作应用在真实dom对象中。
如果是状态更新
:我们就重新构建所有的Fiber对象,再获取到旧的Fiber对象进行比对,然后形成Fiber数组,再将Fiber对象应用到真实dom对象中。
在执行第2阶段的时候,所有节点的Fiber对象都被存储在一个数组中,原本的dom节点,不论以前是是父级,是子级。现在都变成了数组中的第n个元素,这相当于dom节点的关系被抹平了。但是在第2阶段我们需要构建完整的dom节点树,才能将dom元素追加到页面中。也就是我们需要每个元素之间的关系例如:谁是谁的父级,谁是谁的子级,同级,这样我们才能准确的构建dom节点树。因此在Fiber对象中还需要存储当前节点的父级,当前节点的子级,当前节点的同级,方便我们在循环Fiber数组的时候可以知道他们之间的关系。从而构建dom节点树。
- 构建 Fiber (可中断)
- 提交 Commit (不可中断)
DOM 初始渲染: virtualDOM -> Fiber -> Fiber[] -> DOM
DOM 更新操作: newFiber vs oldFiber -> Fiber[] -> DOM
3.4 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 比对时使用
}
3.5接下来实现Fiber算法:
创建任务队列并添加任务
在这里插入代码片
import React from './react'
const jsx = (<div>hello world</div>)