【译】优化长任务

1,863 阅读13分钟

您已经听说过“不要阻塞主线程”和“分解您的长任务”,但是做这些意味着什么?

如果您读了很多关于网络性能的东西,保持JavaScript程序快速运行的建议往往包括这些:

  • 不要阻塞主线程
  • 分解您的长任务

这是什么意思?少加载JavaScript是好事,但是这是否自动等同于整个页面生命周期中更快捷的用户界面?也许,但也许不是。

为了弄清楚为什么在JavaScript中优化任务很重要,您需要理解任务的角色和浏览器如何处理它们。首先要理解什么是任务。

什么是任务

任务是浏览器做的任何离散的工作。任务涉及例如渲染、解析HTMLCSS、执行您写的JavaScript代码,以及其他您可能无法直接控制的工作。在所有这些中,您编写并部署到webJavaScript是任务的主要来源。

click_task.png Chrome DevTools 的性能分析器中的click事件处理程序启动的任务的描述。

任务有多种方式影响性能。例如,当浏览器在启动期间下载JavaScript文件时,它会将任务排入队列以解析和编译JavaScript以便执行。稍后在页面的生命周期中,当您的JavaScript开始工作时,例如通过事件程序驱动交互、JavaScript驱动的动画和后台活动(例如分析收集),任务就会被启动。所有的这些(除了web workers和类似的API)都发生在主线程里。

什么是主线程

主线程是大多数任务在浏览器里运行的地方。它被称为主线程的原因:它是您编写的几乎所有JavaScript都在其中工作的线程。

主线程一次只能处理一个任务。当任务超过某个点(准确地说是50毫秒)时,它们被归类为长任务。如果用户在长任务运行时尝试与页面交互(或者如果需要重要的渲染更新时),浏览器将会延迟处理该工作。这会导致交互或渲染延迟。

long_task.png Chrome的performance里长任务的描述。长任务由红色的三角形表示,任务的阻塞部分用红色条纹图案填充

您需要分解任务。这意味着把一个长任务划分成较小的任务,这些任务单独运行的时间更少。

divid_long_task.png 单个长任务与同一任务分解为五个短任务

这很重要,因为当任务被分解时,浏览器有更多的机会响应更高优先级的工作——包括用户交互。

higher_priority.png 当任务太长并且浏览器没有足够快地响应交互时,以及把长任务分解为较小的任务

在上图的顶部,用户交互事件的处理程序被排队,必须等待单个长任务才能运行。在上图的底部,事件处理程序有机会更快地运行。因为事件处理程序有机会在较小的任务之间运行,所以它运行得比必须等单个长任务完成时更快。在上面的例子中,用户可能已经感受到了延迟;在底部,交互可能是即时的。

然而,问题在于“分解您的长任务”和“不要阻塞主线程”的建议不够具体,除非您已经知道如何做这些事情。这就是本指南将要解释的内容。

任务管理策略

软件架构中的一个常见建议是将您的工作分解为更小的功能。这为您提供了更好的代码可读性和项目可维护性的好处。这也会更容易编写测试代码。

function saveSettings () {  
    validateForm();  
    showSpinner();  
    saveToDatabase();  
    updateUI();  
    sendAnalytics();  
}

在这个例子中,有一个saveSettings()函数,它调用其中的五个函数来完成工作,例如:验证表单、显示加载动画、发送数据等。从概念上讲,这是很好的架构。如果您需要调试这里面的某个功能,您可以遍历项目树找出每个功能的作用。

然而,问题在于JavaScript不会将这些函数中的每一个作为单独的任务运行,因为它们是在 saveSettings()函数中执行的。这意味着这5个函数都作为单个任务运行。 JavaScript以这种方式工作是因为它使用run-to-completion model执行任务。这意味着每个任务将一直运行到完成,无论它阻塞主线程多长时间。

saveSettings.png saveSettings()调用5个函数。这项工作是作为一个长整体任务的一部分运行的。

在最好的情况下,即使只是其中一个函数也可以为任务的总长度贡献50毫秒或更多时间。在最坏的情况下,更多的那些任务可以运行更长的时间——尤其是在资源受限的设备上。下面是一组策略,您可以使用这些策略来分解任务并确定任务的优先级。

手动延时代码的执行

开发人员用来将任务分解为较小任务的一种方法涉及setTimeout()。使用这种技术,您可以将函数传递给setTimeout()。这会将回调推迟到单独的任务中执行,即使您指定的超时时间为0

function saveSettings () {  
    validateForm();  
    showSpinner();  
    updateUI();  
    //推迟执行
    setTimeout(() => {  
        saveToDatabase();  
        sendAnalytics();  
    }, 0);  
}

如果您有一系列需要按顺序运行的函数,这很有效,但您不可能一直用这种方式编写代码。例如,您可能需要在循环中处理大量的数据,如果您有数百万个项目,该任务可能需要很长时间。

function processData () {  
    for (const item of largeDataArray) {  
        // 在这里处理单独的数据
    }  
}

在这里使用setTimeout()是有问题的,因为它的人机工程学使其难以实施,并且整个数据可能需要很长时间才能处理,即使每个项目都可以非常快速地处理。这一切合起来,setTimeout()不适合的工具——至少在以这种方式使用时不是。

除了setTimeout()之外,还有一些其他API允许您将代码推迟到后续任务里执行。一种方法是使用postMessage()。你也可以使用requestIdleCallback()分解任务,但是要小心!requestIdleCallback()将任务安排到尽可能低的优先级,并且只在浏览器空闲时期被调用。当主线程阻塞时,使用requestIdleCallback()调度的任务可能永远无法运行。

使用async/await创建退让点

在本指南的其余部分中,您将看到一个短语“让出主线程”——但这意味着什么?你为什么要这么做?你应该什么时候做?

当您让出主线程时,您就给了它一个机会来处理比当前排队的任务更重要的任务。理想情况下,每当您有一些关键的面向用户的工作需要比占用时更快地执行时,您应该让出主线程。让出主线程创造机会让紧要的工作运行的更快。

当任务被分解时,其他任务可以通过浏览器的内部优先级方案更好地进行优先级排序。让出主线程的一种方法是使用Promise。在Promise里,通过调用setTimeout()resolve

function yieldToMain () {  
    return new Promise(resolve => {  
        setTimeout(resolve, 0);  
    });  
}

虽然这段代码返回一个在setTimeout()里调用resolvePromisePromise不负责在新新任务中运行其余代码,而是在setTimeout()调用。Promise回调作为微任务而不是任务运行,因此不会让出主线程。

saveSettings()函数中,如果在每次函数调用后,使用await调用yieldToMain()函数,就可以在每次工作后让出主线程。

async function saveSettings () {  
    //任务数组
    const tasks = [
        validateForm,
        showSpinner,
        saveToDatabase,
        updateUI,
        sendAnalytics
    ];
    // 循环任务  
    while (tasks.length > 0) {  
        // 将第一个任务移出任务数组
        const task = tasks.shift();  
        // 运行任务:  
        task();  
        // 让出主线程  
        await yieldToMain();  
    }  
}

您不必每次调用函数后都让步。例如,如果您运行两个函数会导致用户界面的重要更新,您可能不想在他们之间让步。如果可以的话,让该工作先运行,然后考虑在那些不重要的功能或用户看不到的后台工作之间做出让步。

结果是曾经单一的任务现在被分解为单独的任务。

broken_long_task.png saveSettings()函数现在将其子函数作为单独的任务执行。

使用基于Promise的方法来让出,而不是手动使用setTimeout()的好处是更好的工程效率。用声明的方式让出主线程,因此更容易编写、阅读和理解。

仅在必要时让出

如果您有一堆任务,但您只想在用户尝试与页面交互时让出主线程,怎么办?这就是isInputPending()的用途。

isInputPending()是一个您可以随时运行以确定用户是否正在尝试与页面的元素交互的函数:调用 isInputPending()将返回true。否则返回false

假设您有一个需要运行的任务队列,但您不想妨碍任何输入。这段代码——它同时使用了isInputPending()和我们自定义的yieldToMain()函数——确保在用户尝试与页面交互时输入不会被延迟:

async function saveSettings () {  
    // 任务队列
    const tasks = [  
        validateForm,  
        showSpinner,  
        saveToDatabase,  
        updateUI,  
        sendAnalytics  
    ];  
    while (tasks.length > 0) {  
        // 在用户输入时,让出主线程
        if (navigator.scheduling.isInputPending()) {  
            // 等待用户输入
            await yieldToMain();  
        } else {  
            const task = tasks.shift();  
            task();  
        }  
    }  
}

saveSettings()运行时,它将遍历队列中的任务。如果在循环期间,isInputPending()返回truesaveSettings()将调用yieldToMain()处理用户输入。否则,它会将下一个任务从队列的最前面移出,并继续运行。它将执行此操作,直到没有其他任务为止。

isInputPending.png saveSettings()为五个任务运行一个任务队列,但用户在第二个工作项运行时单击以打开一个菜单。 isInputPending()为处理交互让出主线程,并继续运行其余任务。

isInputPending()可能并不总是在用户输入后立即返回true。这是因为操作系统需要时间来告诉浏览器发生了交互。这意味着其他代码可能已经开始执行(如您在上面的屏幕截图中使用saveToDatabase()函数所见)。 即使您使用isInputPending(),限制在每个函数中执行的工作量仍然很重要。

isInputPending()与让步机制结合使用,是让浏览器停止其正在处理的任何任务,以响应关键的面向用户的交互的好方法。当许多任务正在进行时,这可以帮助提高您的页面,在大多数情况下响应用户的能力。

浏览器不支持isInputPending()时的降级处理使用,是使用基于时间的方法和可选链运算符

async function saveSettings () {
    const tasks = [  
        validateForm,  
        showSpinner,  
        saveToDatabase,  
        updateUI,  
        sendAnalytics  
    ];  
    let deadline = performance.now() + 50;  
    while (tasks.length > 0) {  
        // 防止浏览器不支持`isInputPending`报错 
        if (navigator.scheduling?.isInputPending() || performance.now() >= deadline) { 
            // 让步给主线程
            await yieldToMain();  
            // 延长deadline:  
            deadline += 50; 
            continue;  
        }
        const task = tasks.shift();
        task();  
    }  
}

使用这种方法,在不支持isInputPending()的浏览器,您可以通过使用基于时间的方法做降级处理,该方法使用(并调整)截止日期,以便在必要时分解工作,无论是通过给用户输入让步,或到某个时间点。

当前API的差距

到目前为止提到的API可以帮助您分解任务,但它们有一个明显的缺点:当您通过把代码推迟到后续任务中运行,来让步给主线程时,该代码将被添加到任务队列的最后。

如果您控制页面上的所有代码,则可以创建自己的调度程序,并能够确定任务的优先级,但第三方脚本不会使用您的调度程序。实际上,在这样的环境中,您并不能真正确定工作的优先级。您只能将其分块,或显式地让给用户交互。

幸运的是,目前正在开发一个专门的调度程序API来解决这些问题。

scheduler API

scheduler目前提供postTask()函数,在撰写本文时,可以在Chromium浏览器和Firefox中使用该函数。postTask()允许对任务进行更细粒度的调度,并且是帮助浏览器确定工作优先级以便低优先级任务让给主线程的一种方法。postTask()使用promise,并接受优先级设置。

postTask()API有三个可以使用的优先级:

  • 'background'用于最低优先级的任务。
  • 'user-visible'用于中等优先级的任务,这是默认值。
  • 'user-blocking'用于需要以高优先级运行的关键任务。

以下面代码为例,postTask()API以尽可能高的优先级运行三个任务,以尽可能低的优先级运行其余两个任务。

function saveSettings () {  
    // 高优先级验证表单
    scheduler.postTask(validateForm, {priority: 'user-blocking'});  
    // 高优先级显示加载动画 
    scheduler.postTask(showSpinner, {priority: 'user-blocking'});  
    // 在后台更新数据库  
    scheduler.postTask(saveToDatabase, {priority: 'background'});  
    // 高优先级更新用户界面 
    scheduler.postTask(updateUI, {priority: 'user-blocking'});  
    // 在后台发送分析数据
    scheduler.postTask(sendAnalytics, {priority: 'background'});  
};

在这里,任务的优先级以这种方式被调度,这是一种浏览器优先任务(例如用户交互)可以按自己的方式进行的方式。

scheduler.png 运行saveSettings()时,该函数使用postTask()调度各个函数。面向用户的关键工作安排在高优先级,而用户不知道的工作安排在后台运行。这允许用户交互更快地执行,因为工作被适当地分解和优先级排序。

这是一个如何使用postTask()的简单示例。可以实例化不同的TaskController对象,这些对象可以在任务之间共享优先级,包括根据需要更改不同的TaskController实例的优先级的能力。

不是所有浏览器都支持postTask()。您可以使用特征检测来查看它是否可用,或者考虑使用polyfill

让步和继续

schedulerAPI的一个提议部分是内置的让步机制,目前没有在任何浏览器中实现。它的使用类似于本文前面演示的yieldToMain()函数:

async function saveSettings () { 
    const tasks = [  
    validateForm,  
    showSpinner,  
    saveToDatabase,  
    updateUI,  
    sendAnalytics  
    ]  
    while (tasks.length > 0) {   
        const task = tasks.shift();  
        task();  
        // 使用scheduler让步给主线程
        // API自带的让步机制
        await scheduler.yield();  
    }  
}

您会注意到,上面的代码非常熟悉,但是您没有使用yieldToMain(),而是调用await scheduler.yield()

compare.png 不让步、有让步、有让步和继续的任务执行的可视化。当使用scheduler.yield()时,即使在让步点之后任务执行也会从中断的地方继续执行。

scheduler.yield()的好处是可继续,这意味着如果你在一组任务中间让步,其他计划任务会在让步点之后以同样的顺序继续。这可以避免第三方脚本中的代码篡夺您的代码执行顺序。

总结

管理任务具有挑战性,但这样做有助于您的页面更快地响应用户交互。对于管理任务和确定任务的优先级,没有任何一条建议。相反,它是许多不同的技术。重申一下,这些是您在管理任务时需要考虑的主要事项:

  • 让出主线程以完成关键的、面向用户的任务。
  • 当用户尝试与页面交互时,使用isInputPending()让步给主线程。
  • 使用postTask()对任务进行优先级排序。
  • 最后,在你的函数中做尽可能少的工作。 使用这些工具中的一种或多种,您应该能够在应用程序中构建任务管理,以便优先考虑用户的需求,同时确保仍然完成不太重要的工作。这将创造更好的用户体验,响应速度更快,使用起来更愉快。

原文

Optimize long tasks