背景
React16采用了fiber架构来动态灵活的管理所有组件的渲染任务,可以随时暂停某一个组件的渲染。所以,对于复杂型应用来说,某个交互动作涉及多个任务,我们是可以对其进行拆解,一步步的做交互反馈,避免在一个页面重绘时间周期内做过多的事情,这样就能减少应用的长任务,最大化提升应用操作性能。所以,React通过Scheduler来进行整个渲染任务的管理,并在16.5版本之后发布了独立的scheduler包。
相关知识
浏览器的渲染机制与事件循环
浏览器采用多进程架构,包含浏览器主进程、渲染进程、插件进程、GPU进程。每开启一个Tab时浏览器就会开启一个渲染进程,该进程里包含多个线程:负责运行JavaScript,DOM和CSS计算和页面渲染的主线程、运行Worker的工作线程等。主线程中解析HTML时,遇到script标签,就会暂停HTML的解析。并开始加载、解析并执行JavaScript代码。为了调度事件、用户交互、渲染、网络请求这些操作,主线程中会通过事件循环(EventLoop)来处理。事件循环的过程为:
事件循环中一个或多个任务(宏任务)队列,首先检查队列是否为空
若任务队列为空则跳过该步骤,否则取出并执行最老的一个任务。
检查微任务队列是否为空。
若微任务队列不为空则执行所有微任务,否则跳过该步骤。
判断是否渲染视图(是否有重排、重绘、渲染间隔是否达到16.7ms等),为真则渲染视图,否则跳至步骤1。页面渲染前调用RAF回调函数。最后判断是否启动空闲时间算法,如果启动就调用requestIdleCallback。
常见的宏任务:事件回调、XHR回调、定时器、I/O、MessageChannel等
常见的微任务:Promise、Generator、Async/Await、MutationObserver。
requestAnimationFrame
计时器一直是JavaScript动画的核心技术。而编写动画循环的关键是要知道延迟时间多长合适,但计时器无法感知显示器的刷新频率,所以需要手动设置间隔,兼容并不友好。(大多数电脑显示器的刷新频率是60Hz,大概相当于每秒钟重绘60次,因此,最平滑动画的最佳循环间隔是1000ms/60,约等于16.6ms,所以在不考虑其他因素的情况下,这样设置动画效果最为丝滑)
var timer;
var dom = document.getElementById('myDiv')
timer = setTimeout(function fn(){
if (parseInt(dom.style.width) < 500) {
dom.style.width = parseInt(dom.style.width) + 5 + 'px';
dom.innerHTML = parseInt(dom.style.width)/5 + '%';
timer = setTimeout(fn,16);
} else {
clearTimeout(timer);
}
}, 16); // 时间间隔为1000ms/屏幕刷新频率,动画效果最优
HTML5新增的requestAnimationFrame采用系统时间间隔,保持最佳绘制效率,不会因为间隔时间过短,造成过度绘制,增加开销;也不会因为间隔时间太长,使用动画卡顿不流畅,让各种网页动画效果能够有一个统一的刷新机制,从而节省系统资源,提高系统性能,改善视觉效果。
var timer;
var dom = document.getElementById('myDiv')
timer = requestAnimationFrame(function fn(){
if (parseInt(dom.style.width) < 500) {
dom.style.width = parseInt(dom.style.width) + 5 + 'px';
dom.innerHTML = parseInt(dom.style.width)/5 + '%';
timer = requestAnimationFrame(fn);
} else {
cancelAnimationFrame(timer);
}
}); // 无需设置时间间隔,兼容不同刷新频率
兼容性
MessageChannel
Channel Messaging API的MessageChannel 接口允许我们创建一个新的消息通道,并通过它的两个MessagePort 属性发送数据。
var channel = new MessageChannel();
var port1 = channel.port1;
var port2 = channel.port2;
port1.onmessage = function(event){
console.log(event.data) // someData
}
port2.postMessage('someData')
onmessage的回调函数的调用时机是在一帧的paint完成之后。react scheduler内部正是利用了这一点来在一帧渲染结束后的剩余时间来执行任务的
兼容性
主要实现
围绕MessageChannel实现任务循环
两个虚线箭头表示引用关系,那么根据代码中的分析现在可以知道,所有的任务调度,都是由 port —— 也就是 channel 的 port2 端口 —— 通过调用 postMessage 方法发起的
getCurrentTime
获取微秒级别的当前时间,并且不受系统时间影响
yieldInterval、forceFrameRate
提供给开发者的接口,可以满足不同场景的调度间隔周期需求
- yieldInterval:执行任务的时间间隔,默认写死的5,Scheduler的时间片。
- forceFrameRate:根据不同刷新率设备设置时间间隔(目前市面显示器的分辨率、帧数差别很大的,1080p、2k、4k、一般显示器、电竞显示器等,所以下面这段逻辑的case肯定需要完善)
var yieldInterval = 5;
exports.unstable_forceFrameRate = function (fps) {
if (fps < 0 || fps > 125) {
// Using console['error'] to evade Babel and ESLint
console['error']('forceFrameRate takes a positive int between 0 and 125, ' + 'forcing frame rates higher than 125 fps is not supported');
return;
}
if (fps > 0) {
yieldInterval = Math.floor(1000 / fps);
} else {
// reset the framerate
yieldInterval = 5;
}
};
requestHostCallback
准备好当下要执行的任务(scheduledHostCallback),开启消息调度循环(isMessageLoopRunning),调用performWorkUntilDeadline(通过postMessage的方式)。
requestHostCallback = function (callback) {
scheduledHostCallback = callback;
if (!isMessageLoopRunning) {
isMessageLoopRunning = true;
port.postMessage(null);
}
};
performWorkUntilDeadline
消息通道监听消息的回调(Scheduler里面MessageChannel的port1被用来做回调,port2被用来专门postMessage),递归处理任务。
var performWorkUntilDeadline = function () {
if (scheduledHostCallback !== null) {
var currentTime = exports.unstable_now();
deadline = currentTime + yieldInterval;
var hasTimeRemaining = true;
try {
var hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);
if (!hasMoreWork) {
isMessageLoopRunning = false;
scheduledHostCallback = null;
} else {
port.postMessage(null);
}
} catch (error) {
port.postMessage(null);
throw error;
}
} else {
isMessageLoopRunning = false;
}
};
任务如何被处理
unstable_scheduleCallback
整个Scheduler的调用入口,按照一定的优先级和其他设置将任务注册进任务队列,并开始任务调度
function unstable_scheduleCallback(priorityLevel, callback, options) {
var currentTime = exports.unstable_now();
var startTime;
// 计算出最终的开始时间,此处省略
var timeout;
// 计算出最终的回调函数定时器,此处省略
var expirationTime = startTime + timeout; // 定义一个过期时间
var newTask = {
id: taskIdCounter++,
callback: callback,
priorityLevel: priorityLevel,
startTime: startTime,
expirationTime: expirationTime,
sortIndex: -1
};
{
newTask.isQueued = false;
}
if (startTime > currentTime) {
// 开始时间大于当前时间,说明是将来要执行的任务,将任务push进这个延期队列
newTask.sortIndex = startTime;
push(timerQueue, newTask);
...
} else {
// 将任务push进任务队列
newTask.sortIndex = expirationTime;
push(taskQueue, newTask);
...
}
return newTask;
}
task长啥样
var newTask = {
id: taskIdCounter++, // Scheduler.js 中全局定义了一个 taskIdCounter 作为 taskId 的生产器
callback, // 任务函数(执行的内容)
priorityLevel, // 任务优先级,有常量定义
startTime, // 开始时间
expirationTime, // 过期时间
sortIndex: -1, // 排序索引,默认为-1,实际会被设置成startTime或expirationTime
};
taskQueue&timerQueue
- taskQueue:任务队列
- timerQueue:延期队列
flushWork&workLoop&advanceTimers
- flushWork:任务循环发起者(让workLoop开始工作)
- workLoop:循环清空任务队列的任务,实际上是执行任务上的callback
- advanceTimers:管理任务队列里面任务的算法API
流程大致是这个样子:flushWork告诉workLoop要开始清空任务,workLoop内部通过advanceTimers循环递归处理taskQueue和timerQueue里面的任务,直到taskQueue没有更多的task位置,本轮循环结束
总结
循环机制
通过 performWorkUntilDeadline 这个方法来实现一个递归的消息发送-接收-处理流程,可以看成是一个轮回,亦或者是一个时间片。
任务处理
接收到的任务被按照一定的优先级规则进行预设,然后通过requestHostCallback来开启循环机制,通过scheduledHostCallback开始处理一系列任务(flushWork),产生一个 while 循环(workLoop)来不断地对队列中的内容进行处理,这期间还会逐步的将被递延任务从 timerQueue 中梳理(advanceTimers)到 taskQueue 中,使得任务能按预设的优先级有序的执行。
展望
1、参考Scheduler基础包,做延伸,实现业务定制的scheduler包
- 时间片
- 不同分辨率、刷新率屏幕的取舍、兼容
- 业务某些重要功能的突出
- 。。。
2、Scheduler后续发展趋势如何
在Scheduler的README里面和源码里面可以看到,目前大多数API都带有unstable_,目前仅仅在React内部使用,至于后续,是否会公开通用甚至开放自定义的一些口子。