长任务的优化处理

阅读本文之前,你应该了解JS事件循环的基本原理以及如何查看主线程上的活动情况,如果对这些内容不了解,可以移步至下面两份资料自行了解。

什么是长任务

任务就是一系列的步骤,指定了要完成的工作。例如事件处理程序,解析 HTML,改变 DOM 结构,样式计算,重新绘制页面。

任何连续的不间断的 且阻塞主线程 50 毫秒 及以上的任务都认为是长任务,一般有以下常见场景:

  1. 长耗时的事件回调
  2. 代价高昂的重绘与重排
  3. 耗时的计算任务
  4. 大体积的静态资源

长任务的危害

交互延迟

页面代码:

html 复制代码
<!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>
	<body>
            <button id="start">开始循环</button>
            <button id="open">打开弹窗</button>
            <script>
                    const startLoop = document.querySelector("#start");
                    const open = document.querySelector("#open");
                    function incrment() {
                            let i = 0;
                            while (i < 100000) {
                                    console.log(i);
                                    i++;
                            }
                    }
                    startLoop.addEventListener("click", () => {
                            incrment();
                    });
                    open.addEventListener("click", () => {
                            alert("Hello Xiaomi");
                    });
            </script>
	</body>
</html>

在这个页面中,点击开始循环之后会开始从 0 到 100000 的空循环。点击打开弹窗会打开一个 alert 框。

操作:在点击开始循环按钮之后,立刻点击打开弹窗按钮 结果:会发现弹窗并不会立即出现。

从下面的 performance 截图中可以看到由于循环操作非常耗时,独占主线程有 2 秒之久,导致弹窗相关的代码被推迟执行,所以出现上述情况,这说明长任务会导致响应时间的增加

下文会围绕这个例子做优化。

页面卡顿,掉帧

通过分析谷歌提供的样本页面,未优化版本下,发现长任务长时间独占主线程,导致渲染被推迟,从而引发大量掉帧,用户明显感觉到不流畅、卡顿。 分析思路:首先定位卡顿的时间区间,然后打开 performance 进行性能录制,然后分析主线程上的活动记录,可以结合其他信息一起来看,比如每一帧的截图,CUP 的负载情况。

在优化版本中,基本每一帧都按时渲染了,而且不存在长任务。在这个例子中的优化手段是避免了大量重排(上图红框所标注)。

有关这个页面的详细分析可以查看这篇文章:Analyze runtime performance

长任务的优化手段

了解到长任务对用户响应,页面流畅度都会造成影响。在开发时

  • 应该避免单个耗时任务
  • 对于确实必要的长任务,对其干预分解
  • 必要时可以使用 web worker

通过观察可以发现,长任务的特点就是耗时,独占主线程,导致无法处理其他任务,从而出现种种性能问题,如果可以将长任务划分成若干个短小的子任务,依次执行子任务,让浏览器有空闲时间执行其他任务,问题应该能够得到缓解,下文都是围绕着JavaScript层面的长任务展开的。

就如上面两张图所示,在任务被分解成若干个子任务后,原本只能在任务结束之后响应的事件,现在可以穿插在子任务之间的空隙时间响应。

如何分解

对于连续的,高度重复的计算任务可以采取保存上下文和递归的方式来分割任务,比如现在有一个从 1 加到 1000000 的一个累加任务,就可以采取每次累加 1000 个数,以此类推。

js 复制代码
let i = 0;
let sum = 0;
const MAX = 1000000

function task() {
    if (i >= MAX) return

    const end = i + 1000
    while(i <= Math.min(end, MAX)) {
      sum += i
      i++
    }

    task()
}

task()
console.log('sum: ', sum)

这样就通过递归来分解了这个长任务,每次只累加1000个数,这里只是举例说明一下分解过程,递归深度过大可能会导致调用栈溢出。

对于无重复性的业务代码只能通过具体情况来分析将操作细化,封装成多个函数,然后对这多个函数进行合理的调度。

使用 setTimeout 来推迟子任务的执行

对于若干个子任务,如果是一次性执行完所有的子任务那么和不分解之前是没有任何区别的,所以就必须让每个子任务之间留有一些时间间隙(空闲时间),这样浏览器就有时间去响应其他任务或交互。

具体做法就是用 setTimeout 将子任务推迟到下一次事件循环中执行,改进第一个跑100000次空循环的例子。

js 复制代码
function incrment() {
    let i = 0;
    const MAX = 1234567;
    function task() {
        if (i >= MAX) return;
        const deadline = i + 1000;
        // 任务代码
        while (i <= Math.min(deadline, MAX)) {
            console.log(i);
            i++;
        }
        // 任务代码

        // 执行下一段任务
        setTimeout(task);
    }

    task();
}

这段代码通过每次循环 1000 次来分割任务,每一次任务执行完毕后,都使用 setTimeout 执行下一段任务。

从上图中直观地看到,任务被分割成了若干个子任务执行并且每个任务之间存在时间空隙。

在发生点击按钮的交互时(蓝框标注的地方),由于每个任务完成得很快(大概在 18ms 左右),有效地减少了用户交互的延迟等待(可以对比上述例子的截图来观察),任务执行时间越短,响应越快。

requestIdleCallback

如果任务的优先级并不高,可以使用 requestIdleCallback代替setTimeout。对比setTimeout,并不是每一次事件循环都会执行,因为requestIdleCallback是在浏览器空闲时被调用,并且有可能一直得不到执行。

js 复制代码
// 执行下一段任务
requestIdleCallback((deadline) => {
    if (deadline.timeRemaining() >= 0) {
        task();
    }
});

及时让出主线程

使用setTimeout时,每执行完一段任务都会强制中断,但有时候希望任务可以一直不间断地执行,仅在必要时中断。

挂起点

使用一个由 setTimeout 解析的 Promise 来对任务进行挂起操作,本质上是在下一次事件循环中创建了一个微任务,这样做可以最大限度保证宏任务的优先执行,结合async/await也可以写出更加易读的代码。

js 复制代码
function yeildToMain() {
    return new Promise((resolve) => {
        setTimeout(resolve, 0);
    });
}

isInputPending

Better JS scheduling with isInputPending() - Chrome for Developers

该 API 在用户与页面交互时产生一个中断信号,JS 可以通过检测这个中断信号,来中止一些行为,改造之前的例子,使用挂起点来推迟任务。

js 复制代码
function incrment() {
    let i = 0;
    const MAX = 1234567;

    async function task() {
        if (i >= MAX) return;

        // 任务代码
        const deadline = i + 1000;
        while (i <= Math.min(deadline, MAX)) {
            console.log(i);
            i++;
        }
        // 任务代码

        // 有交互时主动让出主线程
        if (navigator.scheduling.isInputPending()) {
            await yeildToMain();
        }
        task();
    }

    task();
}

可以看到在没有用户交互时,任务会一直执行,红色箭头表示一直在递归,任务并未被中断,直到出现用户交互让出主线程让其处理点击事件。

总结

首先,通过例子说明长任务会长时间阻塞主线程,导致主线程无法响应用户行为,还有可能导致页面掉帧。紧接着,简述了长任务的处理思路并给出了两种处理方法,一种是使用setTimeout推迟子任务的执行,另外一种是创建挂起点,这样做效率更高,代码也更加直观。如果对于这些处理方法你有自己的想法,欢迎在评论区留言!!!

另外,本文只介绍了JavaScript代码层面产生的长任务,但脚本过大也会产生解析长任务。

相关推荐
Maer0910 分钟前
Cocos Creator3.x设置动态加载背景图并且循环移动
javascript·typescript
Good_Luck_Kevin201811 分钟前
速通sass基础语法
前端·css·sass
大怪v23 分钟前
前端恶趣味:我吸了juejin首页,好爽!
前端·javascript
反应热38 分钟前
浏览器的本地存储技术:从 `localStorage` 到 `IndexedDB`
前端·javascript
刘杭39 分钟前
在react项目中使用Umi:dva源码简析之redux-saga的封装
前端·javascript·react.js
某公司摸鱼前端42 分钟前
js 如何代码识别Selenium+Webdriver
javascript·selenium·测试工具·js
mingzhi611 小时前
奇安信渗透2面经验分享
安全·web安全·网络安全·面试
有一个好名字1 小时前
Vue Props传值
javascript·vue.js·ecmascript
pan_junbiao1 小时前
Vue使用axios二次封装、解决跨域问题
前端·javascript·vue.js
秋沐1 小时前
vue3中使用el-tree的setCheckedKeys方法勾选失效回显问题
前端·javascript·vue.js