React | 2020-06-01 23:50:48 970次 11次
模拟实现 react16 的 fiber 和 hooks 架构,分析简要其设计原理,方便后续阅读 react 源码。react16 中采用了全新的 fiber 架构模式,即所有任务进行分片渲染,采用链表结构,划分任务优先级。在之前的架构模式中如果这是一个很大并且层级很深的组件,react 渲染它需要几十甚至几百毫秒,期间它会一直占用浏览器主线程,任何其他的操作(包括用户的点击,鼠标移动等操作)都无法执行。fiber 的出现,其实并不是为了减少组件的渲染时间,事实上也并不会减少,最重要的是现在可以使得一些更高优先级的任务,如用户的操作能够优先执行,提高用户的体验,因为 react 本身就是一个纯粹的渲染框架。
一、概念介绍
屏幕刷新频率:屏幕每秒出现图像的次数。普通笔记本为60Hz。1000 / 60 ≈16.67 ,所以计算机每16.7ms刷新一次,由于人眼的视觉停留,所以看起来是流畅的移动。即每一帧的时间为 16.7ms,那么意味着如果我们的代码运行时间超过了这个时间,就会产生视觉卡顿。
渲染帧:指浏览器一次完整绘制过程,帧之间的时间间隔是 DOM 视图更新的最小间隔。 每一次渲染都要在 16ms 内页面才够流畅不会有卡顿感。 这段时间内浏览器需要完成事件如下图所示:
Composite 负责计算旋转、缩放等,并没有真正合成,同时使用 css3D 会变成合成层元素,进入GPU进程渲染,提升效率。
栅格化是指将图转化为位图。绘制列表只是用来记录绘制顺序和绘制指令的列表,而实际绘制操作是由渲染引擎中的合成线程来完成的。当图层对应的绘制列表准备好之后,主线程会将绘制列表提交给合成线程。 合成线程会根据用户所能见的窗口范围划分,将一些大的图层化分为图块,根据图块来优先生成位图,生成位图的操作是由栅格化来执行。渲染进程维护栅格化的线程池,所有的图块栅格化操作都会在此执行。最终将位图和参数composite合并渲染到屏幕。
栅格化会使用GPU进程中的GPU来进行加速,使用GPU进程生成位图的过程叫快速栅格化,通过这个方式生成的位图会被保存在GPU内存中,这样当渲染进程的主线程发生阻塞的时候,合成线程以及GPU进程不会受其影响,可正常运行。
二、requestAnimationFrame
requestAnimationFrame 会在每一帧开始时候被执行,可用来实现一些动画效果,和 setTimeout 定时器相比,它的性能会更好,不会丢帧,并且它会与屏幕刷新同步执行,即屏幕页面处于未激活的状态下,该页面的屏幕刷新任务会被系统暂停,被激活时动画从上次停留的地方继续执行,节约 CPU 开销。
定时器的缺点就很明显了,第一它的时间无法确保,要等到当前脚本的所有同步任务(无法确保运行时间)执行完才会被执行回调。第二是刷新频率受屏幕分辨率和屏幕尺寸影响,不同设备的屏幕刷新率可能不同,setTimeout 只能设置固定的时间间隔,这个时间和屏幕刷新间隔可能不同。
通过如下函数证明 requestAnimationFrame 的优势在于渲染时能事先通知正确的渲染时机:
let startTime = Date.now(); function test(){ let current = Date.now(); //打印的是开始准备执行的时候到真正执行的时间的时间差 console.log(current - startTime); startTime = current; requestAnimationFrame(test); } test()
三、requestIdleCallback
requestIdleCallback 的作用是是在浏览器一帧的剩余空闲时间内执行调用函数队列,如果浏览器当前帧没有空闲时间则下一帧渲染时候再调用(可通过 timeRemaining() 判断当前帧剩余时间),如果指定了 timeout 超时时间,就算当前帧没有空闲时间也必须调用传入的函数队列。下面创建一些耗时任务队列来看下这个 api 的使用:
//睡眠 function sleep(delay) { for (var start = Date.now(); Date.now() - start <= delay;) { } }
const works = [ () => { console.log('第1个任务开始'); // 如果本帧中此函数没有渲染完 则会阻塞渲染 必须执行完此函数才会进入下一帧 //一帧16.6 此任务中严重超时,下面两个任务肯定会在下一帧去执行了 sleep(2000); console.log('第1个任务结束 '); }, () => { console.log('第2个任务开始'); sleep(21); console.log('第2个任务结束 '); }, () => { console.log('第3个任务开始'); sleep(20); console.log('第3个任务结束 '); } ] //指定timeout超时时间,告诉 浏览器在1000毫秒后,即使没有空闲时间也必须执行 requestIdleCallback(workLoop, { timeout: 1000 }); function workLoop(deadline) { console.log(`本帧的剩余时间为${parseInt(deadline.timeRemaining())}`); //如果此帧的剩余时间超过0,或者此已经超时了 并且当前任务队列中还有待执行任务 / while ((deadline.timeRemaining() > 1 || deadline.didTimeout) && works.length > 0) { performUnitOfWork(); } /如果没有剩余时间了,就需要放弃执行任务控制权,执行权交还给浏览器 //还有未完成的任务则继续执行此方法 if (works.length > 0) { window.requestIdleCallback(workLoop, { timeout: 1000 }); } } //执行单个任务 切分为一个单元 function performUnitOfWork() { //shift取出数组中的第1个元素 works.shift()(); }
到这里,react的最基本的思想已经形成了,就是任务分片,然后根据浏览器当前帧是否有空闲时间来执行我们的任务,保证如动画或输入响应等优先级高的任务先执行,尽可能使界面不发生卡顿。
但 requestIdleCallback 的支持性仅仅谷歌浏览器支持,另外它的 FPS 只有 20, 这远远低于页面流畅度的要求!(一般 FPS 为 60 时对用户来说是感觉流程的, 即一帧时间为 16.7 ms), 所以这个需要对这个方法兼容处理。
四、requestIdleCallback模拟实现
① 如果有优先级更高的任务, 则通过 postMessage 触发步骤四, 否则如果 requestAnimationFrame 在当前帧没有安排任务, 则开始一个帧的流程;
② 在一个帧的流程中调用 requestAnimationFrameWithTimeout 函数, 该函数调用了 requestAnimationFrame, 并对执行时间超过 100ms 的任务用 setTimeout 放到下一个事件队列中处理;
③ 执行 requestAnimationFrame 中的回调函数 animationTick, 在该回调函数中得到当前帧的截止时间 frameDeadline, 并通过 postMessage 触发步骤四;
④ 通过 onmessage 接受 postMessage 指令, 触发消息事件的执行。在 onmessage 函数中根据 frameDeadline - currentTime <= 0 判断任务是否可以在当前帧执行,如果可以的话执行该任务, 否则进入下一帧的调用。
let requestHostCallback; let cancelHostCallback; let shouldYieldToHost; //代表当前帧过期了 let getCurrentTime; const ANIMATION_FRAME_TIMEOUT = 100; let rAFID; let rAFTimeoutID; // ② 调用 requestAnimationFrame, 并对执行时间超过 100 ms 的任务用 setTimeout 进行处理 const requestAnimationFrameWithTimeout = function (callback) { rAFID = requestAnimationFrame(function (timestamp) { clearTimeout(rAFTimeoutID); // animationTick 并传入当前开始执行时间(系统默认给出) callback(timestamp); }); // 这是一个兜底处理,和上面的方法相互竞争(谁先执行则就取消对方的话语权) //如果在一帧中某个任务执行时间超过 100 ms 则终止该帧的执行并将该任务放入下一个事件队列中 rAFTimeoutID = setTimeout(function () { //取消动画执行帧 cancelAnimationFrame(rAFID); callback(getCurrentTime()); }, ANIMATION_FRAME_TIMEOUT); }; getCurrentTime = function () { //返回值表示为从time origin(当前文档生命周期的开始)之后到当前调用时经过的时间 return performance.now(); }; let scheduledHostCallback = null; // 每一个任务 let isMessageEventScheduled = false; // 消息事件是否执行 let timeoutTime = -1; //超时时间 let isAnimationFrameScheduled = false; let isFlushingHostCallback = false; let frameDeadline = 0; // 当前帧的截止时间 // 假设最开始的 FPS(feet per seconds) 为 30, 但这个值会随着动画帧调用的频率而动态变化 let previousFrameTime = 33; // 一帧的时间: 1000 / 30 ≈ 33 //会被动态计算 let activeFrameTime = 33; shouldYieldToHost = function () { // 当前帧的截止时间比当前时间小则为true,代表当前帧过期了 return frameDeadline <= getCurrentTime(); }; const channel = new MessageChannel(); const port = channel.port2; // ④ 接受 `postMessage` 指令, 触发消息事件的执行。在其中判断任务是否在当前帧执行,如果在的话执行该任务 //onmessage的回调函数的调用时机是在一帧的paint完成之后,所以适合做一些重型任务,也能保证页面流畅不卡顿 // 1、如果当前帧没过期,说明当前帧有富余时间,可以执行任务 // 2、如果当前帧过期了,说明当前帧没有时间了,这里再看一下当前任务firstCallbackNode是否过期,如果过期了也要执行任务;如果当前任务没过期,说明不着急,那就先不执行去下一帧再说。 channel.port1.onmessage = function (event) { isMessageEventScheduled = false; const prevScheduledCallback = scheduledHostCallback; const prevTimeoutTime = timeoutTime; scheduledHostCallback = null; timeoutTime = -1; const currentTime = getCurrentTime(); let didTimeout = false; // 是否过期 // 如果当前帧已经没有时间剩余, 检查是否有 timeout 参数,如果有的话是否已经超过这个时间 // 帧start ==================================== frameDeadline帧end // 帧start ==== currentTime 不能超过 =========== frameDeadline帧end if (frameDeadline - currentTime <= 0) { if (prevTimeoutTime !== -1 && prevTimeoutTime <= currentTime) { // didTimeout 为 true 后, 在当前帧中执行(针对优先级较高的任务) didTimeout = true; } else { // 在下一帧中执行 if (!isAnimationFrameScheduled) { isAnimationFrameScheduled = true; requestAnimationFrameWithTimeout(animationTick); } scheduledHostCallback = prevScheduledCallback; timeoutTime = prevTimeoutTime; return; } } //进行执行任务 if (prevScheduledCallback !== null) { isFlushingHostCallback = true; try { prevScheduledCallback(didTimeout); } finally { isFlushingHostCallback = false; } } }; // 计算每一帧的截止时间 const animationTick = function (rafTime) { // 有任务再进行递归,在每一帧的开头处开始工作,没任务的话不需要工作 if (scheduledHostCallback !== null) { requestAnimationFrameWithTimeout(animationTick); } else { //没有任务了,退出 isAnimationFrameScheduled = false; return; } // 当前帧开始调用动画的时间 - 上一帧调用动画的截止时间 + 当前帧执行的时间,用来计算下一帧有多少时间是留给react 去执行调度的 let nextFrameTime = rafTime - frameDeadline + activeFrameTime; //如果调度执行时间没有超过一帧时间 if (nextFrameTime < activeFrameTime && previousFrameTime < activeFrameTime) { if (nextFrameTime < 8) { //React 不支持每一帧比 8ms 还要短,即 120 帧 nextFrameTime = 8; } // 将 activeFrameTime 的值减小相当于调高 FPS // 如果上个帧里的调度回调结束得早的话,那么就有多的时间给下个帧的调度时间 activeFrameTime = nextFrameTime < previousFrameTime ? previousFrameTime : nextFrameTime; } else { previousFrameTime = nextFrameTime; } frameDeadline = rafTime + activeFrameTime; // 当前帧的截止时间 if (!isMessageEventScheduled) { isMessageEventScheduled = true; port.postMessage(undefined); // 最后进入第④步, 通过 postMessage 触发消息事件。 } }; // DOM 环境下 requestIdleCallback 的实现 requestHostCallback = function (callback, absoluteTimeout) { //当前待执行任务 scheduledHostCallback = callback; //指定最大等待执行时间,这里接收一个Number 原生api是Object {timeout: 1000} timeoutTime = absoluteTimeout; if (isFlushingHostCallback || absoluteTimeout < 0) { // 针对优先级较高的任务不等下一个帧,在当前帧通过 postMessage 尽快执行 port.postMessage(undefined); } else if (!isAnimationFrameScheduled) { // ① 如果 rAF 在当前帧没有安排任务, 则开始一个帧的流程 isAnimationFrameScheduled = true; requestAnimationFrameWithTimeout(animationTick); } }; cancelHostCallback = function () { scheduledHostCallback = null; isMessageEventScheduled = false; timeoutTime = -1; };
使用:
... requestHostCallback(workLoop); function workLoop(deadline) { console.log(deadline) if(deadline){ //当前帧没有剩余时间了,任务过期,需要立即执行 //react中会 //获取当前时间 //判断如果队首任务时间比当前时间小,说明过期了 //执行队首任务,把队首任务从链表移除,并把第二个任务置为队首任务。 //执行任务可能产生新的任务,再把新任务插入到任务链表 //强制执行过期任务 performUnitOfWork(); }else{ //当前帧还有时间则继续执行 do{ performUnitOfWork(); //这里需要手动判断是否过期 } while (!shouldYieldToHost() && works.length > 0) } if (works.length > 0) {//说明还有未完成的任务 requestHostCallback(workLoop); } } ...
这个模拟是实现的过程中,最最重要和难以理解的点是 animationTick 中关于 frameDeadline 的计算,它是整个算法的核心。
下一篇开始 react 模拟实现第二部分。
11人赞