背景

众所周知,JavaScript(简称 JS) 是单线程语言,浏览器只分配给 JS 一个主线程,每次从任务队列中执行一个任务,直到任务队列为空为止。这就必然会导致一些浏览器出现卡顿的问题,例如这次后端反馈,在文件上传的任务中,JS需要切割文件,分片上传给服务器,但遇到大型文件(几百G)的时候,则需分片几十万份,然后把这些分片逐步上传给服务器。我猜测是因为这个过程 JS 处理这些文件片段时间稍长,导致页面渲染 fps 低,所以才出现页面卡顿的现象。所以我就开始想,在面对这种因JS执行时间长,导致页面卡顿的问题,都有哪些方法去解决呢?

解决思路

按我个人对 JS 的了解,面对这种卡顿问题,解决思路如下:

  • 使用 Web Worker 开辟新的线程,通过新的线程处理复杂的长任务,从而避免主线程被阻塞导致的页面卡顿。
  • 通过 generator 函数,将长任务分割成多个宏任务,不要全挤在微任务队列中,影响页面渲染。
  • requestAnimationFrame

长任务

需要JS在某个时间段内,不停的执行的任务。
如下代码,模拟JS执行长过程的情况

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<style>
    @keyframes move {
        from {
            left: 0;
        }

        to {
            left: 100%;
        }
    }

    .move {
        position: absolute;
        animation: move 5s linear infinite;
    }
</style>

<body>
    <div class="move">LHX-FLY</div>
</body>
<script>
    function longFun() {
        let i = 0
        const start = performance.now()
        // 5秒内不停执行 i++ 操作,实现长任务效果
        while (performance.now() - start <= 5000) {
            i++
        }
        return i
    }
    // 1秒后才执行js长任务
    setTimeout(() => {
        longFun()
    }, 1000)

</script>

效果如下:
在这里插入图片描述
我们可以看到,我刷新页面后,1秒之前动画效果还是流畅的,但是在1秒后,因 JS 要处理长任务,导致页面无法渲染,动画也就卡住了。

在这里插入图片描述
从控制台的 performance 中也可以看到,总执行时间为5秒多,且在1秒开始,CPU 主线程基本是被占满,也就是这个时间段界面是停止动画渲染的了。

Web Worker

对于上面的长任务卡顿问题,Web Worker 的解决代码如下:

// 创建线程函数
function createWorker(f) {
    var blob = new Blob(['(' + f.toString() + ')()']);
    var url = window.URL.createObjectURL(blob);
    var worker = new Worker(url);
    return worker;
}
 // 1秒后才执行js长任务
 setTimeout(() => {
     createWorker(longFun); // 用新的线程去执行这个长任务
     // longFun()
}, 1000)   

执行结果如下:在这里插入图片描述
在这里插入图片描述
我们可以看到刷新页面后,整个界面的动画效果依旧是很流畅。虽然总的执行时间不变,还是 5 秒多,但主线程的执行时间仅有 54 ms,新线程的执行表现则归属于 Idle 中去,且 CPU 是不被一直占满的。

generator 函数

在开始用 generator 函数解决问题前, 首先我们要了解JS的执行过程,即事件循环的大致过程,如下图:
在这里插入图片描述
从事件循环过程,我们可以得知,除去特殊情况,页面的渲染会在微任务队列清空后,宏任务执行前。所以我们可以让推入主执行栈的函数执行到一定时间就去休眠,然后在渲染之后的宏任务里面叫醒他,这样渲染或者用户交互都不会卡顿了!
微任务
Promise
process.nextTick
Object.observe
MutaionObserver

宏任务
包括整体代码 script
setTimeout
setIntervcal
I/O
postMessage
MessageChannel

其实 generator 函数无非就是把长任务分成一步一步去走,而它们的每一步之间都会暂停一下(一步就是一次微任务,generator 函数未执行 next 时,不会进行下一步,这时微任务队列清空,直到下一步才执行下一个任务)。将上诉问题转换成 generator 函数形式如下:

// 将原来的长任务改成 generator 函数
function* fnc_() {
    let i = 0
    const start = performance.now()
    while (performance.now() - start <= 5000) {
        yield i++
    }
    return i
}

// 简易时间分片 
function timeSlice (fnc) { 
    if(fnc.constructor.name !== 'GeneratorFunction') return fnc() 
    
    return async function (...args) { 
        const fnc_ = fnc(...args) 
        let data 
        do { 
            data = fnc_.next()
        } while (!data.done) 
        return data.value 
    } 
}
 // 1秒后才执行js长任务
 setTimeout(() => {
     const fnc = timeSlice(fnc_)
     const start = performance.now()
     console.log('开始')
     const num = await fnc()
     console.log('结束', `${(performance.now() - start) / 1000}s`)
     console.log(num)
}, 1000)  

上诉代码从如下表现结果,可以看出和原来的卡顿是一摸一样的,上诉代码只是把长任务变成了 generator 迭代器,整个 fnc_ 函数就相对于一个宏任务,再通过 timeSlice 函数让它不停的 next 执行下一步(每一步都是微任务,但如果没有立即 next 执行下一步,则当前一步的微任务队列执行完毕,等待下一步指示),直至 done 完成,但这样总时间会比原来执行要长。在这里插入图片描述
在这里插入图片描述
但是在 next 下一行中加入下面代码:

do {
    data = fnc_.next()
    // 每执行一步就休眠,注册一个宏任务 setTimeout 来叫醒他
    await new Promise(resolve => setTimeout(resolve))
} while (!data.done)

其实 generator 的 next 是被唤醒后执行的操作,await new Promise(resolve => setTimeout(resolve)) 生成一个 setTimeout 宏任务,是让他下一次执行 next 的时候迟一点(睡眠),在这短暂的睡眠期间,微任务队列可以被认为清空,就会先让其他的任务执行,页面也会渲染,在执行完一个事件循环,再让他执行 next,这样持续执行时间就不会那么长,而是被分成一步一步去执行,就不停的循环执行,直至任务队列执行完毕。
在这里插入图片描述
可以看到动画效果是一直保持流畅
在这里插入图片描述
但上面的时间分片其实就是在每一步的时候都进入睡眠,这样执行效率比较低,可以对时间分片的函数进行优化:

// 精准时间分片 
function timeSlice_(fnc, time = 25) {
    if (fnc.constructor.name !== 'GeneratorFunction') return fnc()
    return function (...args) {
        const fnc_ = fnc(...args)
        function go() {
            const start = performance.now()
            let data
            do {
                data = fnc_.next()
            } while (!data.done && performance.now() - start < time)
            if (data.done) return data.value
            return new Promise((resolve, reject) => {
                setTimeout(() => {
                    try { resolve(go()) } catch (e) { reject(e) }
                })
            })
        }
        return go()
    }
}    

优化后的表现结果如下图(动画效果是流畅的):
在这里插入图片描述
我们对比时间分片函数,优化前后的 performance 表现,可以看出,优化前CPU的执行被分的很细,但优化后是分成一段一段的,除此之外,就没有太多的差别了。
但我们再比较他们两个的打印结果:
优化前:
在这里插入图片描述
优化后:
在这里插入图片描述
从中我们可以看出结果 i 的值差别是很大的,优化前 1037,优化后 6042162,这两个都是在5秒的执行时间内,JS执行次数体现,很明显优化前仅仅执行 1037 次,优化后就能达到 6042162 次
在这里插入图片描述
同时间的执行次数就可以看成效率,优化后的效率是优化前的 5826 倍。

注意
了解了 generator 函数如何通过时间分片对页面渲染进行优化后,我们得关键注意对长任务的 generator 改造(这个不像 web worker 直接执行一段代码,得看个人如何去改造原有代码编写了),yield 的位置非常关键,需要放到耗时执行的地方,比如上面的例子中的长任务就是5秒内不停的 i++。

requestAnimationFrame

该方法主用是解决JS动画卡顿,所以上面的长任务导致浏览器css动画阻塞的问题就不适用了。

简介

requestAnimationFrame 是HTML5中提供的动画API,简称 rAF, 在Web应用中,实现动画效果的方法比较多,Javascript 中可以通过定时器 setTimeout 或 setInterval 来实现,css3 可以使用 transition 和 animation 来实现,html5 中的 canvas 也可以实现。除此之外,html5 还提供一个专门用于请求动画的API,那就是 requestAnimationFrame,顾名思义就是请求动画帧

相关概念

屏幕刷新频率
屏幕的刷新频率可在电脑中“高级显示设置”中查看,一般为60Hz,就是说,屏幕静置情况下,显示器会以每秒60次的频率不断更新屏幕上的图像,因为人的“视觉停留效应”,并感觉不到变化或者抖动,看到的仍是一幅幅连续的画面,其实这中间间隔时间是16.7ms(即1000/60)。

页面可见
当页面被最小化或者被切换成后台标签页时,页面为不可见,浏览器会触发一个 visibilitychange 事件,并设置 document.hidden 属性为true;切换到显示状态时,页面为可见,也同样触发一个 visibilitychange 事件,设置 document.hidden 属性为false。

动画帧请求回调函数列表
每个 Document 都有一个动画帧请求回调函数列表,该列表可以看成是由 <handlerId, callback> 元组组成的集合。其中 handlerId 是一个整数,唯一地标识了元组在列表中的位置;callback是回调函数。

为什么要用 requestAnimationFrame ?

但有些动画效果是通过JS去实现的时候,例如配合 setTimeout,setTimeout其实就是通过一个时间间隔来不断更新图像形成的动画效果。但setTimeout在某些机型或复杂应用中会出现卡顿现象,也就是常说的“丢帧”。这事因为setTimeout只能设置固定的时间间隔,而不同的屏幕、机型会有不同的分辨率,而且setTimeout任务是被放进异步队列中的,所以实际执行时间会比设定时间晚一点。这些原因就导致了setTimeout动画的卡顿现象。
知道了setTimeout的缺点,rAF的出现就顺理成章了。rAF的回调函数执行时机由系统决定,也就是说,系统每次绘制前都会主动调用rAF中的回调函数,如果系统绘制频率是60Hz,那回调函数就是16.7ms被执行一次,如果系统绘制频率是75Hz,那么这个时间间隔就是1000/75=13.3ms,这样就保证回调函数在每次绘制中间都能执行一次,就不会出现丢帧的现象,也不会有卡顿的问题。
除此之外,requestAnimationFrame 还有如下两点优势:

  • CPU节能:使用setTimeout实现的动画,当页面被隐藏或最小化时,setTimeout 仍然在后台执行动画任务,由于此时页面处于不可见或不可用状态,刷新动画是没有意义的,完全是浪费CPU资源。而requestAnimationFrame则完全不同,当页面处理未激活的状态下,该页面的屏幕刷新任务也会被系统暂停,因此跟着系统步伐走的requestAnimationFrame也会停止渲染,当页面被激活时,动画就从上次停留的地方继续执行,有效节省了CPU开销。
  • 函数节流:在高频率事件(resize,scroll等)中,为了防止在一个刷新间隔内发生多次函数执行,使用requestAnimationFrame可保证每个刷新间隔内,函数只被执行一次,这样既能保证流畅性,也能更好的节省函数执行的开销。一个刷新间隔内函数执行多次时没有意义的,因为显示器每16.7ms刷新一次,多次绘制并不会在屏幕上体现出来。
使用

网上最常见的简单例子(rAF不一定是用于动画,去解决一些频繁执行的JS代码也是甚好):

var progress = 0;
//回调函数
function render() {
  progress += 1; //修改图像的位置
  if (progress < 100) {
    //在动画没有结束前,递归渲染
    window.requestAnimationFrame(render);
  }
}
//第一帧渲染
window.requestAnimationFrame(render);

该方法是异步的,传入的函数在动画重绘之前调用。

// 传入一个callback函数,即动画函数;
// 返回值handlerId为浏览器定义的、大于0的整数,唯一标识了该回调函数在列表中位置。
handlerId = requestAnimationFrame(callback)

执行过程:
(1) 首先要判断 document.hidden 属性是否为 true,即页面处于可见状态下才会执行;
(2) 浏览器清空上一轮的动画函数;
(3) 这个方法返回的 handlerId 值会和动画函数callback,以<handlerId , callback> 进入到动画帧请求回调函数列;
(4) 浏览器会遍历动画帧请求回调函数列表,根据 handlerId 的值大小,依次去执行相应的动画函数。

具体实例
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<style>
    .move {
        position: absolute;
    }
</style>

<body>
    <div class="move" id="box">LHX-FLY</div>
</body>
<script>
    var box = document.getElementById('box')
    var flag = false
    var left = 0
    function render() {
        if (flag) {
            if (left >= 100) {
                flag = false
            }
            box.style.left = `${left++}px`
        } else {
            if (left <= 0) {
                flag = true
            }
            box.style.left = `${left--}px`
        }
        window.requestAnimationFrame(render)
    }
    render()
</ script>    

代码执行效果如图:
在这里插入图片描述

其实从上诉实例代码中,在render里面调用render,方法递归,却没有加任何判断的写法是很危险的,通常情况下浏览器都会报错,但是用 window.requestAnimationFrame 回调执行却不会(但不建议这么写)
那如何取消 requestAnimationFrame 中的回调一直执行下去呢?
调用 cancelAnimationFrame 方法即可:

 var rAFId = null;
 function render() {
    if (flag) {
        if (left >= 100) {
            flag = false
        }
        box.style.left = `${left++}px`
    } else {
        if (left <= 0) {
            flag = true
        }
        box.style.left = `${left--}px`
    }
    rAFId =  window.requestAnimationFrame(render)
    // 5 秒后取消动画效果
    setTimeout(function () {
        cancelAnimationFrame(rAFId)
    }, 5000)
}

效果如图:
在这里插入图片描述
我们可以看到5秒后,动画效果确确实实的取消了。

优雅降级

由于requestAnimationFrame目前还存在兼容性问题,而且不同的浏览器还需要带不同的前缀。因此需要通过优雅降级的方式对requestAnimationFrame进行封装,优先使用高级特性,然后再根据不同浏览器的情况进行回退,直至只能使用setTimeout的情况。

;(function () {
	var lastTime = 0
	var vendors = ['ms', 'moz', 'webkit', 'o']
	for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) {
		window.requestAnimationFrame = window[vendors[x] + 'RequestAnimationFrame']
		window.cancelAnimationFrame =
			window[vendors[x] + 'CancelAnimationFrame'] || window[vendors[x] + 'CancelRequestAnimationFrame']
	}

	if (!window.requestAnimationFrame)
		window.requestAnimationFrame = function (callback, element) {
			var currTime = new Date().getTime()
			var timeToCall = Math.max(0, 16 - (currTime - lastTime))
			var id = window.setTimeout(function () {
				callback(currTime + timeToCall)
			}, timeToCall)
			lastTime = currTime + timeToCall
			return id
		}

	if (!window.cancelAnimationFrame)
		window.cancelAnimationFrame = function (id) {
			clearTimeout(id)
		}
})()

参考文章:
时间分片技术(解决 js 长任务导致的页面卡顿)
requestAnimationFrame用法

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐