React 16 渲染过程

Date published: 17-Apr-2020
1 min read / 210 words

React

本文中出现的 React 源码版本:16.9.0

浏览器时钟

  • 刷新频率:代表了屏幕在一秒内刷新的次数,这取决于硬件的固定参数,例如60Hz。
  • 帧率:代表了GPU在一秒内绘制操作的帧数,例如30fps,60fps。
  • 整个渲染过程(js 执行 -> 渲染)也就是一帧
  • GPU会获取图形数据进行渲染,然后硬件负责把渲染后的内容呈现到屏幕上

刷新频率和帧率并不是保持一致的,帧率小于刷新频率就会带来卡顿; 对应的,最优情况就是:浏览器渲染出一帧画面的间隔就是硬件的每一帧图像的时间间隔

requestAnimationFrame

requestAnimationFrame((time) => {})
  • requestAnimationFrame 回调参数是回调被调用的时间,和 performance.now() 返回结果一致
  • 回调函数的时机在回调注册完成后的下一帧渲染周期的起点开始执行

可以基于此,在每帧开始时执行一些任务,实现简单的调度。

const tasks = Array.from({length: 100}, () => () => console.log('task run'));
const doTasks = (fromIndex = 0) => {
const start = Date.now();
let i = fromIndex;
let end;
do {
tasks[i++]();
end = Date.now();
} while (i < tasks.length && end - start < 20);
console.log('tasks remain: ', 1000 - i);
if (i < tasks.length) {
requestAnimationFrame(doTasks.bind(null, i));
}
};
requestAnimationFrame(doTasks.bind(null, 0));

这种形式存在的问题:

  • 20 ms 的时间是如何确定的,如果实际任务耗时小于 20ms,多出的时间就浪费了。
  • requestAnimationFrame 在帧首(重绘之前)执行

requestIdleCallback

requestIdleCallback((IdleDeadline) => {}, options)
  • requestIdleCallback 接受一个回调和一个可以设置超时时间的 options,如果在超时中未调用回调,会在下一次空闲时强制执行回调。
  • requestIdleCallback 回调调用时机是在回调注册完成的上一帧渲染到下一帧渲染之间的空闲时间执行

浏览器的空闲时间:在离散型的交互中,上一帧的渲染到下一帧的渲染时间属于系统空闲时间。 Input 输入最快的单字符输入时间平均是 33ms(通过持续按同一个键来触发),相当于上一帧到下一帧中间会存在大于 33-16.6ms 的空闲时间。 也就是说对于离散型交互,最小的系统空闲时间也有 16.4 ms,离散型交互的最短帧长一般是 33ms 这里推荐去看看 Google 的 RAIL 模型

改造一下上面的 requestAnimationFrame 调度方式: 用 IdleDeadline.timeRemaining() 获取当前剩余时间进行任务的调度。

const tasks = Array.from({length: 100}, () => () => console.log('task run'));
const doTasks = (fromIndex = 0, idleDeadline) => {
let i = fromIndex;
console.log('time remains: ', idleDeadline.timeRemaining());
do {
tasks[i++]();
} while (i < tasks.length && idleDeadline.timeRemaining() > 0);
console.log('tasks remain: ', 1000 - i);
if (i < tasks.length) {
requestIdleCallback(doTasks.bind(null, i));
}
};
requestIdleCallback(doTasks.bind(null, 0));

requestIdleCallback 的问题

requestIdleCallback 有兼容性问题,所以 React Scheduler 中自己实现了一种调度方式。

messageChannel

const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
port.postMessage(undefined);

React 使用 messageChannel 创建的 microTask 进行异步调度。 messageChannel 创建一个新的消息通道,并通过它的两个 MessagePort 属性发送数据。 MessageChannel 在重绘后执行,在这段空闲时间中进行一些调度。

为什么不使用 setTimeout 去执行任务

setTimeout(callback, 0) 有 4ms 的延迟


React 调度方式的实现

核心思路

利用 requestAnimationFrame 计算每帧的剩余时间,通过 postMessage 在帧末执行任务

每帧时间的计算公式

当前时间(rafTime) - 上一帧的时间(frameDeadline) + 活跃帧的时间(frameLength),其中 frameLength 是会动态调整的。

每帧时间的动态推算

调整策略:当连续两帧的执行时间都小于预设的 frameLength ,frameLength 会调整为前两帧中较大的一帧的时间

// packages/scheduler/src/forks/SchedulerHostConfig.default.js
const onAnimationFrame = rAFTime => {
if (rAFInterval < frameLength && prevRAFInterval < frameLength) {
frameLength =
rAFInterval < prevRAFInterval ? prevRAFInterval : rAFInterval;
}
}

setTimeout 做 fallback

requestAnimationFrame 只在页面激活状态下执行,React 使用 setTimeout 当前帧长的3倍时间做 fallback 在后台继续做调度。

// packages/scheduler/src/forks/SchedulerHostConfig.default.js
requestAnimationFrame(nextRAFTime => {
clearTimeout(rAFTimeoutID);
onAnimationFrame(nextRAFTime);
});
// requestAnimationFrame is throttled when the tab is backgrounded. We
// don't want to stop working entirely. So we'll fallback to a timeout loop.
// TODO: Need a better heuristic for backgrounded work.
const onTimeout = () => {
frameDeadline = getCurrentTime() + frameLength / 2;
performWorkUntilDeadline();
rAFTimeoutID = setTimeout(onTimeout, frameLength * 3);
};
rAFTimeoutID = setTimeout(onTimeout, frameLength * 3);

这部分的执行流程图: onAnimationFrame :对帧长的计算

Scheduler 的调度过程

任务优先级

所有任务在一个调度生命周期内都有一个过期时间和调度优先级,调度优先级会被换算成过期时间。过期时间越短表示任务优先级越高。 任务调度优先级分为:

  • 立即执行优先级,立即过期
  • 用户阻塞型优先级,250毫秒后过期
  • 空闲优先级,永不过期,可以在任意空闲时间内执行
  • 普通优先级,5秒后过期
// packages/scheduler/src/Scheduler.js
// Max 31 bit integer. The max integer size in V8 for 32-bit systems.
// Math.pow(2, 30) - 1
// 0b111111111111111111111111111111
var maxSigned31BitInt = 1073741823;
// Times out immediately
var IMMEDIATE_PRIORITY_TIMEOUT = -1;
// Eventually times out
var USER_BLOCKING_PRIORITY = 250;
var NORMAL_PRIORITY_TIMEOUT = 5000;
var LOW_PRIORITY_TIMEOUT = 10000;
// Never times out
var IDLE_PRIORITY = maxSigned31BitInt;

调度过程

调度前

注册任务队列(环状链表),按照过期时间从小到大排列

// packages/scheduler/src/Scheduler.js
// priorityLevel为任务优先级,callback 为一个任务,options 可以指定一个超时时间
function unstable_scheduleCallback(priorityLevel, callback, options) {
var expirationTime = startTime + timeout;
var newTask = {
id: ++taskIdCounter,
callback, // 入参,任务的具体内容
priorityLevel, // 优先级
startTime, // 开始时间
expirationTime, // 过期时间
sortIndex: -1,
};
// 上面创建了任务节点,现在放入任务链表中
newTask.sortIndex = startTime;
push(timerQueue, newTask);
if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
// All tasks are delayed, and this is the task with the earliest delay.
if (isHostTimeoutScheduled) {
// Cancel an existing timeout.
cancelHostTimeout();
} else {
isHostTimeoutScheduled = true;
}
// Schedule a timeout.
requestHostTimeout(handleTimeout, startTime - currentTime);
}
return newTask;
}

调度准备/预处理

  • 如果当前任务的开始时间不超过当前时间,进入「任务调度」
  • 如果已经超过当前帧的截止时间,但没有过期,进入下一帧,并更新计算帧截止时间,重新判断时间(轮询判断),直到没有任何过期超时或者超时才进入「任务调度」
  • 如果没有超过当前帧的截止时间,但是过期了,进入「过期调度」
if (startTime > currentTime) {
// This is a delayed task.
newTask.sortIndex = startTime;
push(timerQueue, newTask);
if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
// All tasks are delayed, and this is the task with the earliest delay.
if (isHostTimeoutScheduled) {
// Cancel an existing timeout.
cancelHostTimeout();
} else {
isHostTimeoutScheduled = true;
}
// Schedule a timeout.
requestHostTimeout(handleTimeout, startTime - currentTime);
} else {
newTask.sortIndex = expirationTime;
push(taskQueue, newTask);
if (enableProfiling) {
markTaskStart(newTask, currentTime);
newTask.isQueued = true;
}
// 当前任务在调度中,则停止调度
// 当前任务在调度中但是任务并没有执行,说明有更高优先级的任务,此时中断原先任务
if (!isHostCallbackScheduled && !isPerformingWork) {
isHostCallbackScheduled = true;
requestHostCallback(flushWork, currentTime);
}
}
}

正式调度

任务调度

当前任务进入调度时没有超时,此时调度尽可能多的任务

过期调度

当前任务进入调度时已经超时,说明这个任务已经等待执行很久了,此时直接批量执行过期任务,任务执行完毕后移除 currentTask,如果 callback 仍然是一个函数,则加入队列

// function flushWork () {}
while (
currentTask !== null &&
!(enableSchedulerDebugging && isSchedulerPaused)
) {
if (
// 任务过期
currentTask.expirationTime > currentTime &&
(!hasTimeRemaining || shouldYieldToHost())
) {
// This currentTask hasn't expired, and we've reached the deadline.
break;
}
// callback 就是当前任务的内容
const callback = currentTask.callback;
const didUserCallbackTimeout =
currentTask.expirationTime <= currentTime;
markTaskRun(currentTask, currentTime);
const continuationCallback = callback(didUserCallbackTimeout);
currentTime = getCurrentTime();
}

完成调度

当该次调度执行完毕(不管是任务执行完或者因为中断暂停执行),在任务执行完毕后为下一次的任务调度做准备。

这部分的执行流程图: |