react16就是用fiber写的
fiber解决了执行栈不能中断的问题
1.屏幕刷新率
- 目前大多数设备的屏幕刷新率为 60 次/秒
- 浏览器渲染动画或页面的每一帧的速率也需要跟设备屏幕的刷新率保持一致
- 页面是一帧一帧绘制出来的,当每秒绘制的帧数(FPS)达到 60 时,页面是流畅的,小于这个值时,用户会感觉到卡顿
- 每个帧的预算时间是16.66 毫秒 (1秒/60)
- 1s 60帧,所以每一帧分到的时间是 1000/60 ≈ 16 ms。所以我们书写代码时力求不让一帧的工作量超过 16ms
2.帧
- 每个帧的开头包括样式计算、布局和绘制
- JavaScript执行 Javascript引擎和页面渲染引擎在同一个渲染线程,GUI渲染和Javascript执行两者是互斥的
- 如果某个任务执行时间过长,浏览器会推迟渲染
2.1 rAF
requestAnimationFrame 回调函数会在绘制之前执行
window.requestAnimationFrame() 告诉浏览器你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行
即在上图布局、绘制之前的浅蓝色区域执行。
requestIdleCallback就是上图最右侧的绿色区域,如果在这个区域操作DOM就会重新渲染。
1 | <body> |
点击button之后,可以看到打印出的时间差都是16、17秒,基本上在16.7左右
2.2 requestIdleCallback
- 我们希望快速响应用户,让用户觉得够快,不能阻塞用户的交互
- requestIdleCallback使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应
- 正常帧任务完成后没超过16 ms,说明时间有富余,此时就会执行 requestIdleCallback 里注册的任务
上图,绿色的框中就是一帧。
首先,用户代码向浏览器申请时间片,当浏览器执行完之后如果有剩余时间,就分配时间片给用户代码执行,在约定时间内再归还控制器。
1 | // 这是一个全局属性 |
1 | window.requestIdleCallback( |
- callback:回调即空闲时需要执行的任务,该回调函数接收一个IdleDeadline对象作为入参。其中IdleDeadline对象包含:
- didTimeout,布尔值,表示任务是否超时,结合 timeRemaining 使用
- timeRemaining(),表示当前帧剩余的时间,也可理解为留给任务的时间还有多少
- options:目前 options 只有一个参数
- timeout。表示超过这个时间后,如果任务还没执行,则强制执行,不必等待空闲
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43<body>
<script>
function sleep(delay) {
for (var time = Date.now(); Date.now() - time <= delay;);
}
const works = [
() => {
console.log("第1个任务开始");
//sleep(20); // 一帧16.6 因为此任务的执行时间超过了16.6毫秒,所以把控制权交给浏览器
//while(true){}
console.log("第1个任务结束");
},
() => {
console.log("第2个任务开始");
//sleep(20);
console.log("第2个任务结束");
},
() => {
console.log("第3个任务开始");
//sleep(20);
console.log("第3个任务结束");
},
];
requestIdleCallback(workLoop, { timeout: 1000 });
function workLoop(deadline) {
console.log('本帧剩余时间', parseInt(deadline.timeRemaining()));
// 如果此帧的剩余时间超过0 或者 已经超时了(timeout)
while ((deadline.timeRemaining() > 0 || deadline.didTimeout) && works.length > 0) {
performUnitOfWork();
} // 如果说没有剩余时间了,就需要放弃执行任务控制权,执行权交还给浏览器
if (works.length > 0) { // 说明还有未完成的任务
console.log(`只剩下${parseInt(deadline.timeRemaining())}ms,时间片到了等待下次空闲时间的调度`);
requestIdleCallback(workLoop, { timeout: 1000 });
}
}
function performUnitOfWork() {
//console.log("performUnitOfWork:",works.length);
works.shift()();
}
</script>
</body>
- timeout。表示超过这个时间后,如果任务还没执行,则强制执行,不必等待空闲
这里的line29 while,每完成一个任务(works),都会判断是否还有剩余时间,有的话再去执行下一个任务,即line30
如果把上面的sleep(20)
注释都打开,打印如下:
1 | 本帧剩余时间 14 |
如果line10的while(true){}
那执行任务就会卡在这里
如果把sleep都打开,上述任务总共执行时间为60ms+,具体时间值不固定
这里的一帧16.6毫秒只是大多数情况,显示器越强,刷新频率越高,一帧的时间越短。一秒多少帧跟显示器的刷新频率有关。只有当显示器的刷新频率是60HZ的时候,一帧才是16.6毫秒。
fiber是把整个任务分成很多小任务,每次执行一个任务,执行完成后会看看有没有剩余时间,如果有继续下一个任务,如果没有放弃执行,交给浏览器调度。
2.3 MessageChannel
3. 单链表
在fiber中很多地方都用到链表
- 单链表是一种链式存取的数据结构
- 链表中的数据是以节点来表示的,每个节点的构成:元素 + 指针(指示后继元素存储位置),元素就是存储数据的存储单元,指针就是连接每个节点的地址
如上图,firstUpdate 头指针、lastUpdate 尾指针、nextUpdate 指向下一个
1 | class Update { |
打印: { name: ‘test2’, number: 2, age: ‘18’ }
一个虚拟DOM是一个链表节点,也是一个工作单元。一个虚拟DOM就是一个最小单元。
4. fiber历史
- fiber之前是什么样的?为什么需要fiber
- 看一下fiber之后的代码是怎么样的?
4.1 fiber之前的协调
- React 会递归比对VirtualDOM树,找出需要变动的节点,然后同步更新它们。这个过程 React 称为Reconcilation(协调)
- 在Reconcilation期间,React 会一直占用着浏览器资源,一则会导致用户触发的事件得不到响应, 二则会导致掉帧,用户可能会感觉到卡顿
1 | let root = { |
如上数据结构,A1下有B1、B2,B1下有C1、C2
打印:A1 B1 C1 C2 B2 深度优先遍历
这种遍历是递归调用,执行栈会越来越深,不能中断,因为中断后想恢复就非常难了。
1.不能中断 2.执行栈太深
4.2 fiber是什么
- 我们可以通过某些调度策略合理分配CPU资源,从而提高用户的响应速度
- 通过Fiber架构,让自己的Reconcilation过程变成可被中断。 适时地让出CPU执行权,除了可以让浏览器及时地响应用户的交互
4.2.1 fiber是一个执行单位
- Fiber是一个执行单元,每次执行完一个执行单元, React 就会检查现在还剩多少时间,如果没有时间就将控制权让出去
4.2.2 fiber是一种数据结构
- React目前的做法是使用链表, 每个 VirtualDOM 节点内部表示为一个Fiber
1 | type Fiber = { |
5. fiber执行阶段
- 每次渲染有两个阶段:Reconciliation(协调\render阶段)和Commit(提交阶段)
- 协调阶段: 可以认为是 Diff 阶段, 这个阶段可以被中断, 这个阶段会找出所有节点变更,例如节点新增、删除、属性变更等等, 这些变更React 称之为副作用(Effect)
- 提交阶段: 将上一个阶段计算出来的需要处理的副作用(Effects)一次性执行了。这个阶段必须同步执行,不能被打断
5.1 render阶段
render阶段会构建fiber树
element.js
1 | let A1 = { type: 'div', key: 'A1' }; |
render.js 遍历fiber树
- 从顶点开始遍历
- 如果有第一个儿子,先遍历第一个儿子
- 如果没有第一个儿子,标志着此节点遍历完成
- 如果有弟弟遍历弟弟
- 如果有没有下一个弟弟,返回父节点标识完成父节点遍历,如果有叔叔遍历叔叔
- 没有父节点遍历结束
如上图:
遍历顺序:A1
,有儿子,B1
,有儿子,C1
,没有儿子,有弟弟,C2
,没有儿子,没有弟弟,有叔叔,即B1的弟弟,B2
,没儿子,没弟弟,没叔叔,回到A1
。
完成顺序:c1
、C2
、B1
、B2
、A1
。 这里C1
没有儿子,里面完成了,接着是C2
没有儿子,完成,C1、C2都完成了,B1
完成一次类推。
1 | let rootFiber = require('./element'); |
打印:
1 | beginWork A1 |
现在把它和之前的requestIdleCallback
连起来
1 | function workLoop(deadline) { |
相当于用链实现了一个类似于可中断的递归的功能
vue中不是fiber,在vue中是把每个更新任务分割的足够小,它是组件级别的更新,更新范围小。vue3采用了模板标记更新,能很快速的定位到更新的地方进行更新。
react是不管在哪里调setState,都是从根节点(指整个项目的根节点)比较更新的。在react中是任务还是很大,但是分割成多个小任务,可以中断和恢复,不阻塞主进程执行高级优先任务。
5.2 commit阶段
6. 手动实现React
创建一个空项目 create-react-app myApp
6.1 实现虚拟DOM
1】
src/index.js
1 | import React from 'react'; |
将上述line4-ling12放到https://www.babeljs.cn/中编译(勾选左边的react),结果:
1 | // React.createElement(type,props,...children); |
1 | import React from 'react'; |
React.createElement 返回一个元素,也就是虚拟DOM,虚拟DOM就是一个JS对象,以JS对象的方式描述界面上DOM的样子
React.createElement替换jsx格式在页面上显示是一样的,还是以A1为开头的div 的数据格式,console.log()打印出来如下:
新建 src/react.js
1 | /** |
2】
当C1中的内容是文本时,children里面不是虚拟DOM,是文本值
src/index.js
1 | import React from 'react'; |
新建src/constants.js
1 | // 表示这是一个文本元素 |
1 | import {ELEMENT_TEXT} from './constants'; |
调用自己写的react看看:
1 | import React from './react'; |
打印出的虚拟DOM结构如下:
6.2 实现初次渲染
6.2.1 根据虚拟DOM生成fiber树
1】
原生的效果
src/index.js
1 | import React from 'react'; |
src/constants.js
1 | // 表示这是一个文本元素 |
新建 src/react-dom.js
1 | import {TAG_ROOT} from './constants'; |
新建 src/schedule.js
1 | /** |
2】跟节点A1
src/schedule.js
1 | function performUnitOfWork(currentFiber) { |
constants.js
1 | // 副作用 |
3】如果是原生文本节点
新建 src/utils.js
1 | if (/^on/.test(key)) { |
schedule.js
1 | function beginWork(currentFiber) { |
4】如果是原生DOM节点
schedule.js
1 | function beginWork(currentFiber) { |
6.2.2 收集effect list
1 | // 在完成的时候要收集有副作用的fiber,然后组成effect list |
A1:B1,B2
B1:C1,C2
B2:D1,D2
B1.firstEffect->C1
B1.lastEffect->C2
A1.firstEffect->C1
A1.lastEffect->C2
C1.nextEffect ->C2
C2.nextEffect ->B1
A1.lastEffect ->B1
完成顺序:C1,C2,B1,D1,D2,B2,A1
6.2.3 commit阶段
1 | function workLoop(deadline){ |
完整版schedule.js
1 | import {PLACEMENT, ELEMENT_TEXT, TAG_TEXT, TAG_HOST} from './constants'; |
6.3 实现元素的更新
currentRoot 当前根
workInProgressRoot 正在工作的根
public\index.html
1 | <body> |
src/index.js
1 | import React from './react'; |
原理解析
第一次渲染 workInProgressRoot,渲染完成
1 | // schedule.js |
将 workInProgressRoot 置为空
每次更新的时候都拿虚拟DOM节点和上次的fiber节点进行对比
奇数更新
1 | export function scheduleRoot(rootFiber){ |
完整代码schedule.js
1 | import { setProps } from './utils'; |
6.4 实现类组件
src/index.js
1 | import React from './react'; |
src/react.js
1 | import { ELEMENT_TEXT } from './constants'; |
新建src/updateQueue.js 更新队列
1 | export class Update { |
6.5 实现hooks
useState是语法糖,是基于useReducer实现的
6.5.1 useReducer
src/index.js
1 | import React from './react'; |
src/schedule.js
1 | import { setProps, deepEquals } from './utils'; |
每个fiber都有自己的hook,每个hook都有自己的state,updateQueue
这里hook是通过索引hookIndex来取上一个hook的,所以hook的数量和顺序不能改变,这样前后两次比较就会出错
6.5.2 useState
src/index.js
1 | function FunctionCounter() { |
src/schedule.js
1 | +export function useState(initState) { |