我们日常使用App,浏览网页时,有两类场景会制约快速响应:
-
CPU的瓶颈: 当遇到大计算量的操作或者设备性能不足使页面掉帧,导致卡顿
-
IO的瓶颈: 发送网络请求后,由于需要等待数据返回才能进一步操作导致不能快速响应。
CPU的瓶颈
主流浏览器刷新频率为60Hz, 即每16.6ms(1000ms / 60Hz)刷新一次。浏览器在一帧中可以用于执行 JS 的时机如下:
一个task(宏任务) – 队列中全部job(微任务) – requestAnimationFrame – 浏览器重排/重绘 – requestIdleCallback
当JS执行时间过长,超出了16.6ms,这次刷新就没有时间执行样式布局和样式绘制了,因此就会造成页面掉帧,造成卡顿。
react如何解决渲染时间太长导致页面卡顿的问题呢?
在浏览器每一帧的时间中,只预留一些时间给JS线程(在源码中预留的初始时间是5ms)
时间一到, 即使任务还没执行完,React也会将线程控制权交还给浏览器使其有时间渲染UI,React则等待下一帧时间再继续进行上一帧被中断的工作。
长任务通过被拆分到每一帧不同的task中,这样浏览器就有剩余时间执行样式布局和样式绘制,减少掉帧的可能性。
这种将长任务分拆到每一帧中,像蚂蚁搬家一样一次执行一小段任务的操作,被称为时间切片(time slice)
所以,解决CPU瓶颈的关键是实现时间切片,而时间切片的关键是:将同步的更新变为可中断的异步更新, 异步可中断更新可以理解为:更新在执行过程中可能会被打断(浏览器时间分片用尽或有更高优任务插队),当可以继续执行时恢复之前执行的中间状态。
新老react对比
React v15由于不能满足快速响应的理念,以至于被重构升级到v16.
React15
React15架构可以分为两层:
Reconciler(协调器)—— 负责找出变化的组件
Renderer(渲染器)—— 负责将变化的组件渲染到页面上
Reconciler会做如下工作:
- 调用函数组件、或class组件的render方法,将返回的JSX转化为虚拟DOM
- 将虚拟DOM和上次更新时的虚拟DOM对比
- 通过对比找出本次更新中变化的虚拟DOM
- 通知Renderer将变化的虚拟DOM渲染到页面上
React15的缺点:
Reconciler和Renderer是交替工作的.
在Reconciler中,mount的组件会调用mountComponent (opens new window),update的组件会调用updateComponent (opens new window)。这两个方法都会递归更新子组件。递归更新一旦开始,中途就无法中断。当层级很深时,递归更新时间超过了16ms,用户交互就会卡顿。
React16
React16架构可以分为三层:
- 新增: Scheduler(调度器)—— 调度任务的优先级和时间切片
- Reconciler(协调器)—— 负责找出变化的组件
- Renderer(渲染器)—— 负责将变化的组件渲染到页面上
Scheduler其实是模拟 requestIdelCallback 但也弥补了缺点:
- 一帧的执行时间存在偏差,导致留给工作执行的时间不确定(不稳定);
- 浏览器兼容不好,其中 safari 浏览器根本不支持它。
为了能模拟出requestIdleCallback,必须要做到以下两点:
- 可以主动让出线程,让浏览器执行其他任务。
- 在每帧下只执行一次,然后在下一帧中继续请求时间片。
React 为实现暂停 JS 任务执行,将主线程交还给浏览器,让浏览器有机会执行页面渲染,就需要借助事件循环的「宏任务」。因为宏任务会在下次事件循环中执行,不会阻塞本次页面渲染更新。之所以不选择微任务,是因为「微任务是在本次页面更新前会全部执行」,这一点与同步执行无异,不会让出主线程。而在宏任务中首选便是setTimeout。但是由于setTimeout会有4ms的时差,react放弃使用了setTimeout,改用了MessageChannel。(在不兼容Messagechannel的情况下依然使用setTimeout实现)
利用宏任务异步的机制,以高频(短间隔)5ms 的方式去对任务进行切片执行,每隔 5ms 让出执行权给浏览器看它是否有渲染工作要做,浏览器做完工作或没有工作要做时,根据 EventLoop 的运行机制会再次进入到下一个宏任务中,接着上次的任务继续执行。
注意:时间到,未完成的任务会重新放入到任务池中,重新调度,在下一帧中找到当下最高优的执行,如用户操作等更高优的任务, 不一定会执行前面那个没执行完的那个任务。
事实上Scheduler,划分了多个优先级参数作为任务等级:
Immediate -1 需要立刻执行。
UserBlocking 250ms 超时时间250ms,一般指的是用户交互。
Normal 5000ms 超时时间5s,不需要直观立即变化的任务,比如网络请求。
Low 10000ms 超时时间10s,肯定要执行的任务,但是可以放在最后处理。
Idle 一些没有必要的任务,可能不会执行。
那么React16是如何解决中断更新时DOM渲染不完全的问题呢?
在React16中,Reconciler与Renderer不再是交替工作。当Scheduler将任务交给Reconciler后,Reconciler会为变化的虚拟DOM打上代表增/删/更新的标记, 只有当所有组件都完成Reconciler的工作,才会统一交给Renderer。即使反复中断,用户也不会看见更新不完全的DOM.
React Fiber
在React15及以前,Reconciler采用递归的方式创建虚拟DOM,递归过程是不能中断的。如果组件树的层级很深,递归会占用线程很多时间,造成卡顿。
为了解决这个问题,React16将递归的无法中断的更新重构为异步的可中断更新,由于曾经用于递归的虚拟DOM数据结构已经无法满足需要。于是,全新的Fiber架构应运而生。
每个Fiber节点对应一个React节点,保存了该组件的类型,对应的DOM节点等信息,在 Fiber 架构下,每一个虚拟 DOM 都是一个任务执行单元
在React中最多会同时存在两棵Fiber树。当前屏幕上显示内容对应的Fiber树称为current Fiber树,正在内存中构建的Fiber树称为workInProgress Fiber树。
在内存中构建并直接替换的技术叫做双缓存, React使用“双缓存”来完成Fiber树的构建与替换——对应着DOM树的创建与更新。
个人理解: 以前只有一块黑板,写满了之后要全部擦掉才开始重新画,存在白屏的问题,现在多了内存这块黑白,直接在第二块写好了替换第一块
JSX和Fiber的关系
在组件mount时,Reconciler根据JSX描述的组件内容生成组件对应的Fiber节点。
在update时,Reconciler将JSX与Fiber节点保存的数据对比,生成组件对应的Fiber节点,并根据对比结果为Fiber节点打上标记。Fiber比JSX多一些信息比如组件在更新中的优先级
源码解析
MessageChannel有两个属性,
MessageChannel.port1(只读返回channel的port1)
MessageChannel.port2(只读返回channel的port2)
let scheduledHostCallback = null
/* 建立一个消息通道 */
var channel = new MessageChannel();
/* 建立一个port发送消息 */
var port = channel.port2;
channel.port1.onmessage = function(){
/* 执行任务 */
scheduledHostCallback()
/* 执行完毕,清空任务 */
scheduledHostCallback = null
};
/* 向浏览器请求执行更新任务 */
requestHostCallback = function (callback) {
scheduledHostCallback = callback;
port.postMessage(null);
};
const task = function() {
console.log('这是浏览器任务')
}
requestHostCallback(task)
例子中首先实例化了 MessageChannel得到channel。prot1通过onmessage来监听port2传过来的消息。并执行scheduledHostCallback函数。
unstable_scheduleCallback.js
scheduleCallback会将传进来的任务包装成带有优先级调度任务吗,并把他们分别push到对应的timerQueue和taskQueue中:
taskQueue会以任务过期时间为排序标准,有序存放过期任务。
timerQueue会以任务开始时间为排序标准,有序存放未过期的任务
- unstable_scheduleCallback的整体逻辑就是,先通过任务开始事件+任务延迟时间,计算出任务过期时间。
- 新建一个调度任务
- 判断任务是否过期,未过期任务存放入 timerQueue 中,过期任务存放在 taskQueue中。
- 如果任务未过期会通过requestHostTimeout延迟调用( requestHostTimeout的本质就是调用 setTimeout使任务延迟 ),如果任务过期了便会调用 requestHostCallback。
function unstable_scheduleCallback(priorityLevel, callback, options) {
// 计算出过期时间
var expirationTime = startTime + timeout;
// 创建一个调度任务
var newTask = {
id: taskIdCounter++,
callback: callback,
priorityLevel: priorityLevel,
startTime: startTime,
expirationTime: expirationTime,
sortIndex: -1
};
// 如果任务开始时间大于当前时间,说明任务没有过期
if (startTime > currentTime) {
// This is a delayed task.
// 以startTime作为排序标准
newTask.sortIndex = startTime;
// 将任务放入到timerQueue队列中
push(timerQueue, newTask);
requestHostTimeout(handleTimeout, startTime - currentTime);
} else {
// 以expirationTime作为排序标准
newTask.sortIndex = expirationTime;
// 将过期任务放入到taskQueue中
push(taskQueue, newTask);
// wait until the next time we yield.
requestHostCallback(flushWork);
}
return newTask;
}
requestHostCallback.js
在源码中,通过 MessageChannel 创建一个消息通道,当用户执行 requestHostCallback 调度 callback 时,便会通过 postMessage 发起一个宏任务进入 performWorkUntilDeadline 方法。
if (typeof window === 'undefined' || typeof MessageChannel !== 'function') {
// 非浏览器环境,或不支持 MessageChannel,会使用 setTimeout 宏任务来实现
} else {
// 保存 api 引用,防止 polyfill 覆盖它们
const setTimeout = window.setTimeout;
const clearTimeout = window.clearTimeout;
getCurrentTime = () => performance.now(); // 页面加载后开始计算
let isMessageLoopRunning = false; // 标记 MessageChannel 正在运行
let scheduledHostCallback = null; // 要执行的处理函数
let taskTimeoutID = -1; // 用作终止 setTimeout 延迟任务
// 定义每一帧工作时间,默认时间为 5ms,React 会根据浏览器主机环境进行重新计算。
let yieldInterval = 5;
let deadline = 0; // 过期时间,让出主线程
// 让出主线程
shouldYieldToHost = function () {
return getCurrentTime() >= deadline;
};
// (可选方法)默认空闲执行时间是5ms,用户可通过该方法来根据不同用户主机的设备刷新率(FPS)来计算预留时间
forceFrameRate = function (fps) {
if (fps < 0 || fps > 125) {
return;
}
if (fps > 0) {
yieldInterval = Math.floor(1000 / fps);
} else {
yieldInterval = 5;
}
};
// 开启高频短间隔 5ms 执行工作
const performWorkUntilDeadline = () => {
...
};
// 定义宏任务,建立通信
const channel = new MessageChannel();
const port = channel.port2; // 用于发布任务
channel.port1.onmessage = performWorkUntilDeadline; // 处理任务
requestHostCallback = function (callback) {
scheduledHostCallback = callback; // 保存任务
if (!isMessageLoopRunning) {
isMessageLoopRunning = true;
port.postMessage(null); // 发起宏任务
}
};
cancelHostCallback = function () {
scheduledHostCallback = null;
};
requestHostTimeout = function (callback, ms) {
taskTimeoutID = setTimeout(() => {
callback(getCurrentTime());
}, ms);
};
cancelHostTimeout = function () {
clearTimeout(taskTimeoutID);
taskTimeoutID = -1;
};
}
调用 requestHostCallback本质上就是发起了一次 MessageChannel 的调用
在 performWorkUntilDeadline 方法中,基于 yieldInterval 计算得到一个执行过期时间 deadline,也就是「高频短间隔 5ms」。
当用户在 callback 中通过调用 shouldYieldToHost() 发现执行时间过期且还存在未完成的任务,可在 callback 函数中返回 true,performWorkUntilDeadline 会先将主线程交给浏览器,再开启一个宏任务等待执行。
const performWorkUntilDeadline = () => {
if (scheduledHostCallback !== null) {
const currentTime = getCurrentTime(); // 拿到当前时间
// 根据 yieldInterval(5ms)计算剩余时间(任务执行截止时间)。这种方式意味着 port.postMessage 开始后总有剩余时间
deadline = currentTime + yieldInterval;
// 标识还有时间,类似 requestIdleCallback deadline.didTimeout
const hasTimeRemaining = true;
try {
const hasMoreWork = scheduledHostCallback(
hasTimeRemaining,
currentTime,
);
// 执行完成,没有新任务,初始化工作环境
if (!hasMoreWork) {
isMessageLoopRunning = false;
scheduledHostCallback = null;
} else {
// 如果任务截止时间过期(根据 shouldYieldToHost()),还有需要处理的工作,再发起一个异步宏任务
port.postMessage(null);
}
} catch (error) {
port.postMessage(null);
throw error;
}
} else {
isMessageLoopRunning = false;
}
};
scheduledHostCallback最终会调用 workLoop,
workLoopConcurrent就是在执行最小任务单元,也就是我们的fiber节点
在 workLoopConcurrent会通过 shouldYield 来判断当前浏览器是否还有空余时间。
function workLoop(){
var currentTime = initialTime;
advanceTimers(currentTime);
/* 获取任务列表中的第一个 */
currentTask = peek();
while (currentTask !== null){
/* 真正的更新函数 callback */
var callback = currentTask.callback;
if(callback !== null ){
/* 执行更新 */
// 执行真正的回调函数后返回了一个函数,这个函数其实也是performConcurrentWorkOnRoot
var continuationCallback = callback(didUserCallbackTimeout);
// 将返回的函数赋值给任务的callback
currentTask.callback = continuationCallback;
/* 先看一下 timeQueue 中有没有 过期任务。 */
advanceTimers(currentTime);
}
/* 再一次获取任务,循环执行 */
currentTask = peek(taskQueue);
}
}
在 workLoop 中会先检测一遍是否有任务过期,然后取出最先过期的任务执行。到目前为止Scheduler 的整个调度流程就结束了。
参考文档:
https://react.iamkasong.com/process/reconciler.html#%E9%80%92-%E9%98%B6%E6%AE%B5
https://www.geeksforgeeks.org/what-is-react-fiber/
https://juejin.cn/post/7146004454653820935
https://juejin.cn/post/7159768909392904222