渲染优化(JavaScript执行优化)

JavaScript 执行优化

本节侧重优化JavaScript 的执行来改善用户在渲染方面的性能体验。

一、实现动画效果

​ 前端实现动画效果的方法有很多,比如在CSS中可以通过transition和animation来实现,在HTML中可以通过canvas 来实现,而利用JavaScript 通常最容易想到的方式是利用定时器seTimeout或setInterval来实现,即通过设置个间隔时间来不断地改变目标图像的位置来达到视觉变化的效果。

​ 实践经验告诉我们,使用定时器实现的动画会在一些低端 机器上出现抖动或者卡顿的现象,这主要是因为浏览器无法确定定时器的回调函数的执行时机。以setInterval为例,其创建后回调任务会被放入异步队列,只有当主线程上的任务执行完成后,浏览器才会去检查队列中是否有等待需要执行的任务,如果有就从任务队列中取出执行,这样会使任务的实际执行时机比所设定的延迟时间要晚一些。

其改屏幕分辨率和尺寸也会影响刷新频率,不同设备的屏幕绘制频率可能会有所不同,而setInterval只能设置某个固定的时间间隔,这个间隔时间不一定与所有屏幕的刷新时间同步,那么导致动画出现随机丢帧也在所难免,如图所示。

定时器触发阻塞渲染帧

​ 为了避免这种动画实现方案中因丢帧而造成的卡顿现象,我们推荐使用window中的requestAnimationFrame方法。与setInterval方法相比,其最大的优势是将回调函数的执行时机交由系统来决定,即如果屏幕刷新频率是60Hz,则它的回调函数大约会每16.7ms执行一次, 如果屏幕的刷新频率是75Hz,则它回调函数大约会每13.3ms执行一次,就是说requestAnimationFrame方法的执行时机会与系统的刷新频率同步。这样就能保证回调函数在屏幕的每次刷新间隔中只被执行一次,从而避免因随机丢帧而造成的卡顿现象。

​ 其使用方法也十分简单,仅接受一个回调函数作为入参,即下次重绘之前更新动画帧所调用的函数。返回值为一个long型整数,作为回调任务队列中的唯标识, 可将该值传给window.cancelAnimationFrame来取消回调,以某个目标元素的平移动画为例:

let start;
//定义目标动画元素
const element = document.getElementById('MyAnimate');
element.style.position = 'absolute'
//定义动画回调函数
function updatedScreen(timestamp) {
    if(!start) start = timestamp;
    //根据时间戳计算每次动画位移
    const progress = timestamp - start;
    element.style.left = `${Math.min(progress / 10,200)}px`
    if(progress < 2000) window.requestAnimationFrame(updatedScreen)
}
//启动动画回调函数
window.requestAnimationFrame(updatedScreen)

​ 除了通过让回调函数的触发时机与系统刷新频率同步来消除动画的丢帧卡顿,requestAnimationFrame方法还能通过节流不必要的函数执行,来帮助CPU的节能。

​ 具体而言,对于CPU节能方面,考虑当浏览器页面最小化或者被隐藏起来时,动画对用户来说是不可见的,那么刷新动画所带来的页面渲染就是对CPU资源的浪费,完全没有意义。

​ 当创建setInterval定时器后,除非显式地调用clearInterval去销毁该定时器,不然在后台的动画任务会被不断执行,而requestAnimationFrame方法则完全不同,当页面未被激活时,屏幕刷新任务会被系统暂停,只有当页面被激活时,动画任务才会被激活并从上次暂停的地方继续执行,所以能有效地节省CPU开销。

​ 在页面的一些高频事件中,比如页面滚动的scroll、页面尺寸更改的resize,需要防止在一个刷新时间间隔内发生多次函数执行,也就是所谓的函数节流。对60Hz的显示器来说,差不多每16.7ms 刷新一次, 多次绘制并不会在屏幕上体现出来,所以requestAnimationFrame方法仅在每个刷新周期中执行一次函数调用, 既能保证动画的流畅性又能很好地节省函数执行的冗余开销 。

二、恰当使用Web Worker

​ 众所周知JavaScript 是单线程执行的,所有任务放在一个线程上执行,只有当前一个任务执行完才能处理后 一个任务,不然后面的任务只能等待,这就限制了多核计算机充分发挥它的计算能力。同时在浏览器上, JavaScript 的执行通常位于主线程,这恰好与样式计算、页面布局及绘制一起,如果JavaScript运行时间过长,必然就会导致其他工作任务的阻塞而造成丢帧。

​ 为此可将一些纯计算 的工作迁移到Web Worker上处理,它为JavaScript的执行提供了多线程环境,主线程通过创建出Worker子线程,可以分担一部分 自己的任务执行压力。在Worker子线程上执行的任务不会干扰主线程,待其上的任务执行完成后,会把结果返回给主线程,这样的好处是让主线程可以更专注地处理UI交互保证页面的使用体验流程。需要注意的是,Worker 子线程一旦创建成功就会始终执行,不会被主线程上的事件所打断,这就意味着Worker会比较耗费资源,所以不应当过度使用,一旦任务执行完毕就应及时关闭。 除此之外,在使用中还有以下几点应当注意。

  • DOM限制: Worker 无法读取主线程所处理网页的DOM对象,也就无法使用document、window和parent等对象,只能访问navigator和location对象。
  • 文件读取限制: Worker 子线程无法访问本地文件系统,这就要求所加载的脚本来自网络。
  • 通信限制:主线程和Worker子线程不在同一个上下文内,所以它们无法直接进行通信,只能通过消息来完成。
  • 脚本执行限制:虽然Worker可以通过XMLHTTPRequest对象发起ajax请求,但不能使用alert()方法和confirm()方法在页面弹出提示。
  • 同源限制: Worker子线程执行的代码文件需要与主线程的代码文件同源。

​ Web Worker的使用方法非常简单,在主线程中通过new Worker()万方法来创建一个Worker子线程,构造函数的入参是子线程执行的脚本路径,由于代码文件必须来自网络,所以如果代码文件没能下载成功,Worker 就会失败。代码示例如下:

//创建子线程
const worker = new Worker('demo_worker.js');
//主线程向子线程发送消息
const dataToWorker = {/* 要传给子线程的数据 */};
worker.postMessage(dataToWorker);
//接下来主线程就可以继续其他工作,只需通过监听子线程返回的消息再进行相应处理
worker.addEventListener('message',(event) => {
    //子线程处理后的数据
    const workerData = event.data;
    //数据更新到屏幕上
})

​ 在子线程处理完相关任务后,需要及时关闭Worker子线程以节省系统资源,关闭的方式有两种:在主线程中通过调用worker.terminate()方法来关闭; 在子线程中通过调用自身全局对象中的self.close()方法来关闭。

​ 考虑到上述关于Web Worker使用中的限制,并非所有任务都适合采用这种方式来提升性能。如果所要处理的任务必须要放在主线程上完成,则应当考虑将一个大型任务拆分为多个微任务,每个微任务处理的耗时最好在几毫秒之内,能在每帧的requestAnimationFrame更新方法中处理完成,代码示例如下:

//将一个大型任务拆成分为多个微任务
const taskList = splitTask(BigTask);
//微任务处理逻辑,入参为每次任务的起始戳
function processTaskList(taskStartTime){
    let taskFinishTime;
    do {
        //从任务堆栈中推出下一个任务
        const nextTask = taskList.pop();
        //处理下一个任务
        processTask(nextTask);
        //获取任务执行完成的时间,如果时间够 3ms 就继续执行
        taskFinishTime = window.performance.now();
    } while(taskFinishTime - taskStartTime < 3);
    //如果任务堆栈不为空则继续
    if(taskList.length > 0){
        requestAnimationFrame(processTaskList);
    }
}
requestAnimationFrame(processTaskList);

三、事件节流和事件防抖

​ 本章所介绍的动画触发方式就用到了事件节流的思想,即当用户在与Web应用发生交互的过程中,势必有一些操作会 被频繁触发,如滚动页面触发的scroll事件,页面缩放触发的resize事件,鼠标涉及的mousemove、mouscover等事件,以及键盘涉及的keyup、keydown 等事件。

​ 频繁地触发这些事件会 导致相应回调函数的大量计算,进而引发页面抖动甚至卡顿,为了控制相关事件的触发频率,就有了接下来要介绍的事件节流与事件防抖操作。所谓事件节流,简单来说就是在某段时间内,无论触发多少次回调,在计时结束后都只响应第一次的触发。 以scroll事件为例,当用户滚动页面触发了一次scroll 事件后,就为这个触发操作开启一个固定时间的计时器。在这个计时器持续时间内,限制后续发生的所有scroll 事件对回调函数的触发,当计时器计时结束后,响应执行第一次触发scroll 事件的回调函数。代码示例如下:

/**
* 事件节流回调函数
* @params: time事件节流时间间隔
* @params: callback事件回调函数
**/
function throttle(time,callback){
    //上次触发回调的时间
    let last = 0;
    //事件节流操作的闭包返回
    return (params) => {
        //记录本次回调触发的时间
        let now = Number(new Date())
        //判断事件触发事件是否超出节流时间间隔
        if(now - last >= time){
            //如果超出节流时间间隔,则触发响应回调函数
            callback(params)
        }
    }
}
//通过事件节流优化的事件回调函数
const throttle_scroll = throttle(1000,() => console.log('页面滚动'));
//绑定事件
document.addEventListener('scroll',throttle_scroll);

​ 事件防抖的实现方式与事件节流类似,只是所响应的触发事件是最后一次事件。具体来说,首先设定一个事件防抖的时间间隔, 当事件触发开始后启动计时器,若在定时器结束计时之前又有相同的事件被触发,则更新计时器但不响应回调函数的执行,只有当计时器完整计时结束后,才去响应执行最后一次事件触发的回调函数。 代码示例如下:

/**
* 事件防抖回调函数
* @params: time事件防抖时间延迟
* @params: callback事件回调函数
**/
function debounce(time,callback){
    //设置定时器
    let timer = null;
    //事件防抖操作的闭包返回
    return (params) => {
        //每当事件被触发时,清除旧定时器
        if(timer) clearTimeout(timer);
        //设置新的定时器
        timer = setTimeout(() => callback(params),time);
    }
}
//通过事件防抖优化事件回调函数
const debounce_scroll = debounce(1000,() => console.log('页面滚动'));
//绑定事件
document.addEventListener('scroll',debounce_scroll);

​ 虽然通过上述事件防抖操作,可以有效地避免在规定的时间间隔内频繁地触发事件回调函数,但是由于防抖机制颇具“耐心”,如果用户操作过于频繁,每次在防抖定时器计时结束之前就进行了下一次操作,那么同一事件所要触发的回调函数将会被无限延迟。频繁延迟会让用户操作迟迟得不到响应,同样也会造成页面卡顿的使用体验,这样的优化就属于弄巧成拙。

​ 因此我们需要为事件防抖设置一条 延迟等待的时间底线,即在延迟时间内可以重新生成定时器,但只要延迟时间到了就必须对用户之前的操作做出响应。这样便可结合事件节流的思想提供一个升级版的实现方式,代码示例如下:

function throttle_pro(time,callback){
    let last = 0,timer = null;
    return (params) => {
        //记录本次回调触发的时间
        let now = Number(new Date());
        //判断事件触发事件是否超出节流时间间隔
        if(now - last < time){
            //若在设置的延迟时间间隔内,则重新设置防抖定时器
            clearTimeout(timer);
            timer = setTimeout(() => {
                last = now;
                callback(params);
            },time)
        }else{
            //若超出延迟时间,则直接响应用户操作,不用等待
            last  = now;
            callback(params);
        }
    }
}
//结合节流和防抖优化后的事件回调函数
const scroll_pro = throttle_pro(1000,() => console.log('页面滚动'));
//绑定事件
document.addEventListener('scroll',scroll_pro);

​ 事件节流与事件防抖的实质都是以闭包的形式包裹回调函数的,通过自由变量缓存计时器信息,最后用setTimeout控制事件触发的频率来实现。通过在项目中恰当地运用节流与防抖机制,能够带来投入产出比很高的性能提升。

四、恰当的JavaScript优化

​ 通过优化执行JavaScript能够带来的性能优化,除上述几点之外,通常是有限的。很少能优化出一个函数的执行时间比之前的版本快几百倍的情况,除非是原有代码中存在明显的BUG.即使像计算当前无素的offsetTop值会比执行getBoundingClientRect()方法要快,但每帧对该属性或方 法的调用次数也非常有限。

​ 若花费大量精力进行这类微优化,可能只会带来零点几毫秒的性能提升,当然如果基于游戏或大量计算的前端应用,则另当别论。所以对于渲染层面的JavaScript优化,我们首先应当定位出导致性能问题的瓶颈点,然后有针对性地去优化具体的执行函数,而避免投入产出比过低的微优化。

那么如何进行JavaScript 脚本执行过程中的性能定位呢?这里推荐使用Chrome浏览器开发者工具中的Performance页签,使用它可让我们逐帧评估JavaScript代码的运行开销,可通过Settings>更多工具>开发者工具>Performance 打开其工具界面,如图所示。

​ 在工具的顶部有控制JavaScript 采样的分析器复选框Disable JavaScriptsamples,由于这种分析方式会产生许多开销,建议仅在发现有较长时间运行的JavaScript脚本时,以及需要深入了解其运行特性时才去使用。除此之外,在可开发者工具的Setting > More tools中单独调出JavaScript 分析器针对每个方法的运行时间及嵌套调用关系进行分析,并可将分析结果导出为.cpuprofile文件保存分享,工具界面如图所示。

​ 该功能将帮助我们获得更多有关JavaScript 调用执行的相关信息,据此可进一步评估出JavaScript 对应用性能的具体影响,并找出哪些函数的运行时间过长。然后使用优化手段进行精准优化。比如尽量移除或拆分长时间运行的JavaScript 脚本,如果无法拆分或移除,则尝试将其迁移到Web Worker中进行处理,让浏览器的主线程继续执行其他任务。

Donate
  • Copyright: Copyright is owned by the author. For commercial reprints, please contact the author for authorization. For non-commercial reprints, please indicate the source.
  • Copyrights © 2022-2023 alan_mf
  • Visitors: | Views:

请我喝杯咖啡吧~

支付宝
微信