7分钟教你用时间切片让页面看起来更流畅

时间:2020-10-25 作者:admin

一、前置知识

首先,在开始介绍时间切片前,很有必要先把浏览器的渲染流程梳理一下,这里面涉及的知识有event loop渲染帧等知识,下面会简单地介绍。

event loop

事件循环,这个严格来说其实并不是js语言本身的特性,而是在浏览器这个宿主环境下提供的机制,(因为在node环境下又是另一种事件循环机制了),浏览器虽然是多进程,但是本身每个Tag页就是一个子进程,而每个子进程的js都是以单线程执行的,按道理来说就是代码从上往下执行,中间如果有东西卡住了,那么整个js的执行就会阻塞掉。那么诸如setTimeoutsetInterval之类的异步又是怎么回事呢?其实它们并不在js的线程上,而是浏览器交给额外的线程去执行的,执行完触发条件后才会推进任务队列中,供js引擎拿来运行.

如上所示,当js开始运行时,把任务压入执行栈,然后一个个按顺序执行,当遇到异步任务时,其实会先注册到event table中,(可以理解为一个用来记录异步任务的队列),然后继续执行下一个同步任务。与此同时(因为异步任务是独立于js线程外的线程执行的,不存在阻塞关系),记录在event table里的异步任务会先执行它的前置条件,比如setTimeout设置了多少秒,那么就会开始计时多少秒,达成条件后才会推进去任务队列中排队。当整个js执行栈执行完并清空后,就会去遍历任务队列,拿出里面的宏任务推入到js执行栈中重复上面的步骤。

渲染帧

页面是一帧一帧绘制出来的,当每秒绘制的帧数(FPS)达到 60 时(每秒60帧画面),页面是流畅的,小于这个值时,用户会感觉到卡顿,所以每一帧分到的时间是 1000/60 ≈ 16 ms,那么一帧里浏览器到底干了哪些事呢?请看下图:

这里解释一下每个阶段的含义:

  • events: 点击事件、键盘事件、滚动事件等
  • macro: 宏任务,如 setTimeout
  • micro: 微任务,如 Promise
  • rAF: requestAnimationFrame, 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。
  • Layout: CSS 计算,页面布局
  • Paint: 页面绘制
  • rIC: requestIdleCallback,将在浏览器的空闲时段内调用的函数排队调用。

也就是说,event loop也只是一帧里的一小部分,浏览器除了执行js的任务,还会经历rAFlayoutpaint等阶段,当js的执行时间过长,加上绘制等阶段超过了16ms,那么用户就会感觉到明显卡顿。

二、时间切片核心

在理解了event loop 和浏览器每一帧发生的事情之后,我们基本可以知道,页面交互卡顿大多数是因为js线程执行过久,也就是出现了长任务。

而时间切片的核心原理,就是把一段原本要执行100ms的任务,分成10个10ms的任务,分散到每一帧中执行,留足时间给layoutpaint阶段,这样就可以保证肉眼可见的流畅了。需要注意的是,把一个长任务分成多个小任务执行的前提,必须结合event loop的机制来操作,很明显的是,如果多个小的同步任务依然在同一次事件循环中执行,那么依然会阻塞页面的渲染,因此必须合理地把它们一个个安排到下一轮事件循环中。

下面先给出对比效果:

假设不使用时间切片,同步地渲染10万条数据。

可以看到,直接暴力渲染10万条以上数据时,会卡顿2、3秒,并且直到渲染完成之前,页面上所有元素包括input框都无法交互。

而使用时间切片后,流畅度会有显著提升:

加载的速度明显提升,并且交互没有阻塞

需要说明的是,在使用了时间切片的例子中,其实列表并没有加载完的,而是动态地一帧加载一点这样子,留足了时间给浏览器渲染,所以才会看起来前1000条渲染完,但是滚动一下页面会发现,其实后面依然在加载,只是没有阻塞而已。

三、如何使用时间切片

看了以上的对比之后,相信各位都知道时间切片的优势了。但是具体怎么用呢?其实上面已经把思路说得非常直白了,就是把本来需要执行很长时间的js逻辑,分割成一段段小的逻辑,然后一个接一个地推到下一帧里。

手动切片

//假设有个任务要插入10W条数据,大概耗时10s
var listDom = document.getElementById("list");

function bigInsert(){
    let i = 0;
    while(i<100000){
        let item = document.createElement("li");
        item.innerText = `第${i++}条数据`;
        listDom.appendChild(item)
    }
}

// 如果使用时间切片,那么应该这样分割
function bigInsert(){
       let i = 0;
        return setTimeout(()=>{
            while(i<25000){
                let item = document.createElement("li");
                item.innerText = `第${i++}条数据`;
                listDom.appendChild(item)
            };
            return setTimeout(()=>{
                while(i<50000){
                    let item = document.createElement("li");
                    item.innerText = `第${i++}条数据`;
                    listDom.appendChild(item)
                };
                return setTimeout(()=>{
                    while(i<75000){
                        let item = document.createElement("li");
                        item.innerText = `第${i++}条数据`;
                        listDom.appendChild(item)
                    };
                    return setTimeout(()=>{
                        while(i<100000){
                            let item = document.createElement("li");
                            item.innerText = `第${i++}条数据`;
                            listDom.appendChild(item)
                        };
                    },16)
                },16)
            },16)
    },16)
}

可以看到,基本就是把任务尽可能分割,然后插入到每一帧中执行(这里使用了setTimeout只是为了体现推入下一轮渲染的行为而已,千万不要以为把不同的函数放到setTimeout就等于分散到每一帧中了,实际上浏览器会智能地合并一些宏任务,并不一定每一个宏任务都伴随着重绘,详情可以参考晨曦大佬的深入解析你不知道的 EventLoop 和浏览器渲染、帧动画、空闲回调(动图演示),实际上我更推荐用requestIdleCallback

自动切片

可以发现,如果切片的粒度不大,那么手动自己改造函数其实也能接受,但是如果需要切割成粒度非常小的逻辑,那么使用generator函数特性,会更加方便。(不熟悉generator的,请细看es6之generator

//首先我们封装一个时间切片执行器
function timeSlice(gen) {
    if (typeof gen !== "function")
        throw new Error("TypeError: the param expect a generator function");
    var g = gen();
    if (!g || typeof g.next !== "function")
        return;
    return function next() {
        var start = performance.now();
        var res = null;
        do {
            res = g.next();
        } while (res.done !== true && performance.now() - start < 25);
        if (res.done)
            return;
        window.requestIdleCallback(next);
    };
}

//然后把长任务变成generator函数,交由时间切片执行器来控制执行
const add = function(i){
            let item = document.createElement("li");
                item.innerText = `第${i++}条`;
                listDom.appendChild(item);
        }
function* gen(){
    let i=0;
    while(i<100000){
        yield add(i);
        i++
    }
}
//使用时间切片来插入10W条数据
function bigInsert(){
    timeSlice(gen)()
}

利用generator的特性。把每一次yield都放在requestIdleCallback里执行,直到全部执行完毕,就可以轻松达到时间切片的效果了。

四、总结

时间切片不是什么高级的api,而是一种根据浏览器渲染特性衍生出的优化方案,是一种优化思想,把计算量过大,容易阻塞渲染的逻辑切割成一个个小的任务来执行,留给浏览器渲染的时间来达到肉眼可见的流畅,本质上并没有优化什么js的计算性能,有些算法的逻辑该优化还是需要从算法的思想上去优化。

以上如果有任何表述不对,烦请各位大佬一一指出。

声明:本文内容由互联网用户自发贡献自行上传,本网站不拥有所有权,未作人工编辑处理,也不承担相关法律责任。如果您发现有涉嫌版权的内容,欢迎进行举报,并提供相关证据,工作人员会在5个工作日内联系你,一经查实,本站将立刻删除涉嫌侵权内容。