跳转到内容

高量级任务执行优化

刀刀

4/7/2025

0 字

0 分钟

题目

现有一个场景,有一个小球通过动画在绕长方形运动,有一个按钮,点击后会执行高量级任务(本质是循环了上千次的函数)。点击按钮后,由于 Javascript 是同步任务,因此小球会卡住不动,等待函数执行完毕后才继续转动。此过程耗时 5000ms 。

js
function runTask(task) {
  task()
}

现在要求提供一个优化方案,需求如下:

  1. 如果要执行一步任务,需返回 Promise
  2. 任务执行速度要快,减少页面卡顿
  3. 尽量兼容更多的浏览器

异步任务

他要求异步任务返回 Promise ,因此需要 return 一个 new Promise 在任务执行完毕后返回成功状态。

js
function runTask(task) {
  return new Promise().then((resolve) => {
    Promise.resolve().then(() => {
  		task()
    	resolve()
    })
  })
}

但是点击按钮后发现还是阻塞,这是因为浏览器要执行完全部的微任务才会执行后续的任务,包括渲染。因此原本的渲染帧会被微任务挤到后面执行完微任务后再执行。

既然微任务也会阻塞,那宏任务(或者叫延时任务)效果会不会好一点呢?

js
function runTask(task) {
  return new Promise().then((resolve) => {
    setTimeout(() => {
  		task()
    	resolve()
    }, 0)
  })
}

点击后没有阻塞了,但是小球动画会有卡顿。

想要分析为什么会造成卡顿,需要先了解宏任务执行的本质。事件循环本质是一个死循环,每次会先取出宏任务中的第一个任务,执行任务。然后判断是否到渲染的时机,到时机再渲染。

因此他为什么会造成卡顿呢?因为不同浏览器判断渲染时机是不同的,像谷歌浏览器,他发现队列里有这么多任务,有这么高的计算需求,它会匀一点资源给宏任务的执行,因此会稍微把渲染时机往后延。Edge浏览器处理方式和谷歌浏览器一样。

而对于 Safari 浏览器,它无论任务有多少,都不会交出渲染资源,该渲染就渲染,因此最后执行完后耗时会比谷歌和Edge高一点。

requestAnimationFrame

首先介绍一下这个 API,requestAnimationFrame 是 JavaScript 中用于执行动画的 API,它可以让你在浏览器的下一次重绘之前执行指定的函数,从而实现更加流畅和高效的动画效果。

传统上,JavaScript 中实现动画的方式是使用 setTimeoutsetInterval 来定时执行函数,但这些方法存在一些问题,比如:

  1. 它们的执行时间间隔固定,无法与浏览器的渲染频率同步,可能导致动画不流畅或者过度绘制。
  2. 当页面处于后台标签页或者隐藏状态时,定时器的回调函数仍然会被触发,这样会消耗额外的 CPU 资源。

requestAnimationFrame 则是针对这些问题提出的解决方案,它具有以下特点:

  1. 能够根据浏览器的渲染频率来优化动画性能,保证动画在每一帧之间的间隔是恰当的,从而使得动画更加流畅。
  2. 当页面处于后台标签页或者隐藏状态时,动画会自动暂停,节省了额外的 CPU 资源。

使用 requestAnimationFrame 编写动画的方式通常如下:

javascript
function animate() {
    // 在这里执行动画逻辑,比如更新元素的位置、样式等

    // 请求下一帧动画
    requestAnimationFrame(animate);
}

// 启动动画
requestAnimationFrame(animate);

在这个例子中,animate 函数用于执行动画逻辑,然后通过 requestAnimationFrame(animate) 请求下一帧动画,从而形成一个动画循环。浏览器会在每一帧渲染之前调用 animate 函数,这样可以保证动画的流畅性和性能。

但是他还是会造成阻塞,原因和异步任务一样,在本该执行渲染帧的时候被方法阻塞推迟渲染了,执行完后才能继续渲染。

手动控制任务执行时机

可以写一个方法判断,由我们来控制,判断当前是否适合运行,合适的话运行任务,不合适则递归调用。

js
function _runTask(task, callback) {
  requestIdleCallback((idle) => {
    if(idle.timeRemmaining() > 0) {
      task()
      callback()
    }
    else {
      _runTask(task, callback)
    }
  })
}

function runTask(task) {
  return new Promise().then((resolve) => {
    Promise.resolve().then(() => {
  		_runTask(task, resolve)
    })
  })
}

兼容

该方法兼容性还行,不过 Safari 浏览器不兼容,无法使用该方法。因此如果要考虑兼容的话,只能用回 requestAnimationFrame ,通过计时的方式来判断当前是否合适渲染。

js
function _runTask(task, callback) {
  let start = Date.now()
  requestAnimationFrame(() => {
    if(Date.now() - start < 16.6) {
      task()
      callback()
    }
    else {
      _runTask(task, callback)
    }
  })
}

拓展

requestIdleCallback 是浏览器提供的一个用于在浏览器空闲时执行任务的 API。当浏览器空闲时,即没有其他高优先级任务需要执行时,可以使用 requestIdleCallback 来安排任务执行,以避免影响用户界面的响应性能。

使用 requestIdleCallback 有助于优化性能,特别是在执行一些耗时较长、不紧急的任务时,比如:

  1. 执行一些较为耗时的计算,例如复杂的数据处理或计算密集型操作。
  2. 执行一些需要持续时间较长的操作,例如大规模数据的渲染或处理。

通过将这些任务放到 requestIdleCallback 中执行,可以最大程度地避免对用户交互和页面渲染造成影响,提高页面的流畅度和用户体验。

在使用 requestIdleCallback 时,你可以传递一个回调函数作为参数,当浏览器空闲时,该回调函数会被调用。回调函数会接收一个 IdleDeadline 对象作为参数,通过该对象可以获取当前空闲期的信息,例如剩余可用时间等,从而更好地控制任务的执行。

以下是一个简单的示例代码,演示了如何使用 requestIdleCallback

js
function performIdleTask(deadline) {
  while ((deadline.timeRemaining() > 0 || deadline.didTimeout) && tasks.length > 0) {
    const task = tasks.shift();
    // 执行任务
    task();
  }
  if (tasks.length > 0) {
    requestIdleCallback(performIdleTask);
  }
}

// 在空闲时执行任务
requestIdleCallback(performIdleTask);