【React 16】模拟实现1

React | 2020-06-01 23:50:48 740次 11次

模拟实现 react16 fiber hooks 架构,分析简要其设计原理,方便后续阅读 react 源码。react16 中采用了全新的 fiber 架构模式,即所有任务进行分片渲染,采用链表结构,划分任务优先级。在之前的架构模式中如果这是一个很大并且层级很深的组件,react 渲染它需要几十甚至几百毫秒,期间它会一直占用浏览器主线程,任何其他的操作(包括用户的点击,鼠标移动等操作)都无法执行。fiber 的出现,其实并不是为了减少组件的渲染时间,事实上也并不会减少,最重要的是现在可以使得一些更高优先级的任务,如用户的操作能够优先执行,提高用户的体验,因为 react 本身就是一个纯粹的渲染框架。


一、概念介绍

屏幕刷新频率:屏幕每秒出现图像的次数。普通笔记本为60Hz。1000 / 60 ≈16.67 ,所以计算机每16.7ms刷新一次,由于人眼的视觉停留,所以看起来是流畅的移动。即每一帧的时间为 16.7ms,那么意味着如果我们的代码运行时间超过了这个时间,就会产生视觉卡顿。

渲染帧:指浏览器一次完整绘制过程,帧之间的时间间隔是 DOM 视图更新的最小间隔。 每一次渲染都要在 16ms 内页面才够流畅不会有卡顿感。 这段时间内浏览器需要完成事件如下图所示:

1.png

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人赞

分享到: