Skip to content

19 卡顿:页面响应卡顿是什么原因造成的?

你好,我是三桥。

从这节课开始,我们就进入了新的篇章,一起来探讨全链路的优化内容。我们将从性能优化开始,把INP指标优化作为切入点。

什么是INP指标呢?它能够衡量网页对用户互动的响应速度,是一项重要的响应性指标。优化INP的过程很复杂,但优化后会带来巨大的成果。

例如,电商网站的商品详情页面可以通过降低INP值来提升点击率;印度的票务网站通过改善搜索功能和列表显示机制,成功降低了INP值并提高了销售额;《经济时报》网站通过改善TBT和INP,访问量提高40%,跳出率降低了一半。

由此可见,优化INP对公司的业务能带来很大的价值。那么,他们具体做对了哪些优化,让业务价值得到提升呢?

与INP相关的三个概念

在深入研究INP的优化之前,我们先来看下三个概念。

首先,浏览器主线程 它是浏览器中负责处理用户交互,解析和执行 JavaScript 代码,布局和绘制页面等核心任务的线程。

主线程一次只能处理一个工作任务,比如执行JavaScript代码时,绘制更新页面就会被阻塞。换个角度想,这说明了INP指标会受两个因素影响,一是JavaScript的解析和执行,二是布局和绘制页面。

这节课我们先重点探讨JavaScript的解析和执行,关于布局和绘制页面,我们将会在下一节课详细说明。

好,再说第二个概念,浏览器任务。其实就是指浏览器执行的任何独立工作,就像程序员执行待办任务一样,执行完一个任务后再执行下一个。

大部分浏览器任务都在浏览器主线程上执行。例如解析HTML和CSS。运行JavaScript代码以及重排、重绘都是在这里运行。点击事件、滚动事件、setTimeout的回调函数都可以被视为一次任务,而且他们都会被自动添加到宏任务队列中。

到这里,我们又涉及了第三个概念,宏任务和微任务。也许这两个词会让你联想到常见的前端八股文面试题。没错,在优化INP之前,我们必须先理解宏任务和微任务的执行机制。

浏览器执行JavaScript是单线程,其执行机制是事件循环。它有两种类型的任务,一种是宏任务,是在主线程上独立执行的工作单元。另一种是微任务,它同样是一个独立工作单元,但它的执行优先级比宏任务更高。

从两种任务的概念可以看出,如果没有微任务,主线程会寻找宏任务并执行。如果在执行过程中产生了微任务,那么主线程会在当前宏任务执行完后,立即完成所有微任务,然后才会再执行下一个宏任务。

下图是以极客时间官网为例,通过手机访问首页,借用Chrome开发者工具的性能面板看到的一次任务执行情况。

图片

如上图所示,性能面板显示了这个任务花了66.45毫秒,而且它还提醒我们这是个长任务。

这里又涉及了一个新的知识点。浏览器通常以每秒60帧的速度渲染页面,以保持流畅的用户体验。超过50毫秒的任务,浏览器就认为是长任务。在用户体验上,长任务可能会导致页面渲染出现卡顿、掉帧等现象。

虽然66.45毫秒比50毫秒只多了16毫秒,但从浏览器的角度来看,这已经算是长任务,只不过其影响较小,如果这里显示的是200毫秒甚至更多,那就说明这里的任务耗时太久了。

所以,我们优化INP指标,其实就是在优化由JavaScript造成的长任务。那长任务是怎么产生的呢?

长任务是如何产生的?

有很多前端同学会发现,每次编写事件逻辑代码时,都离不开一些常见的功能,比如表单校验、状态更新、更新数据库、更新界面、发送统计等。

假设,我们将这5种常见的逻辑都放在一个点击事件函数中执行,会产生什么效果?我们首先来看一下这样的代码。

const score = document.querySelector('score-keeper');
const button = score.button;

button.addEventListener("click", () => {
  // 统计点击次数
  score.incrementAndUpdateUI();
  // 验证表单
  validateForm(60);
  // 显示Loading界面
  showLoading(60);
  // 更新数据到数据库
  saveToDatabase(90);
  // 更新数据
  updateUI(60);
  // 埋点
  sendAnalytics(30);
});

在代码中的 click 事件执行了6种方法。第一个函数是统计点击次数的,我们暂且不讨论它。其余5个函数模拟了5种业务逻辑场景,拉长函数执行的时间。每个函数都接收一个数值参数,代表该函数的执行时间。

我们模拟这5个函数的执行,并通过在页面实时输出INP指标、交互数值、FPS帧数和当前运行时长等4个指标来观察性能情况。

最终,可以观看下面的小视频来查看运行效果。

从视频中可以看出,每次点击按钮后,页面都会产生一定程度的卡顿,阻碍用户后续操作。

产生卡顿的主要原因有两点。

首先,这5个函数是在一个函数内按顺序依次执行的,而这个执行过程包含了JavaScript逻辑、渲染界面、请求发起、埋点发送等多种不同的逻辑代码。

其次,当浏览器在解析和执行点击事件时,会把这段代码识别为一个宏任务,并在完成这5个函数后才会释放该任务。

下面是每次点击产生的任务耗时的火焰图,记录了每个函数的执行顺序和执行时间。

图片

大多数前端同学都使用过Chrome开发者工具的性能面板,也都见过类似的火焰图。然而,并非所有人都能理解面板中的各种任务和数据,得出优化方案。没关系,我们一起来看看如何优化这种长任务的事件。

如何优化长任务?

通过性能面板发现,页面卡顿的主要原因是长任务耗时。因此,我们的目标是就将长任务拆分成多个短任务,确保每个任务的执行时间尽可能短。

从之前的代码中,我们可以看到有5个函数导致了长任务的耗时。那么,优化这个长任务,就是优化这5个函数的执行优先级,并将它们拆分为多个任务。

首先, validateFormshowLoadingupdateUI 三个函数都与页面渲染相关,我们可以把它们归类为渲染任务。其次, saveToDatabasesendAnalytics 这两个函数都与网络相关,我们可以将他们归类为网络任务。

这样,我们就能将五个函数拆分成两个任务,每个任务只负责同类的事务。

明白了这个道理后,那么如何拆分这两个任务呢?

方法一:使用定时器拆解任务

我们都知道, setTimeoutsetInterval 的回调函数执行的都是宏任务。如果我们将部分函数的执行时机放到该回调函数里,那么就相当于创建了一个新的宏任务,等待其余任务执行完毕后再执行这个宏任务。

结果,我们实现了拆分两个宏任务,代码逻辑可以参考下面。

const score = document.querySelector('score-keeper');
const button = score.button;

button.addEventListener("click", () => {
    //
  score.incrementAndUpdateUI();
    // 验证表单
  validateForm(30);
  // 显示Loading界面
  showLoading(30);
  // 更新数据
  updateUI(30);

  setTimeout(() => {
      // 更新数据到数据库
      saveToDatabase(90);
      // 埋点
      sendAnalytics(30);
  }, 0);

});

方法二:使用Promise调整任务优先级

第二种方法是利用Promise的异步特性来调整函数任务执行的优先级。

具体的方法是,Promise短暂中断任务队列,并将回调函数放到微任务的队列中,这样,在主线程的宏任务完成后,再立刻执行微任务。

因此,通过Promise解决长任务的代码实现逻辑,可以这样实现。

const score = document.querySelector('score-keeper');
const button = score.button;

button.addEventListener("click", () => {
    //
  score.incrementAndUpdateUI();
    // 验证表单
  validateForm(30);
  // 显示Loading界面
  showLoading(30);
  // 更新数据
  updateUI(30);

  Promise.resolve().then(() => {
      // 更新数据到数据库
      saveToDatabase(90);
  });

  setTimeout(() => {
      // 埋点
      sendAnalytics(30);
  }, 0);

});

在这里,我们将 saveToDatabasesendAnalytics 拆分成了两个不同的任务,一个是宏任务,一个是微任务。这样拆分能减少后面的宏任务的执行时间,并让 saveToDatabase 函数更好地完成单一任务。

方法三:使用优化动画效果API

同理, requestAnimationFrame 函数和Promise产生的效果基本相通。

requestAnimationFrame 函数是一个优化动画效果的 API,它会在下次浏览器重绘前执行回调函数,使得动画的更新和重绘同步进行,避免掉帧,提升动画流畅度。

因此,它的回调函数是一个微任务。如果你用同样的代码逻辑,把Promise换成 requestAnimationFrame,效果不会相差太大。参考代码如下。

const score = document.querySelector('score-keeper');
const button = score.button;

button.addEventListener("click", () => {
    //
  score.incrementAndUpdateUI();
    // 验证表单
  validateForm(30);
  // 显示Loading界面
  showLoading(30);
  // 更新数据
  updateUI(30);

  requestAnimationFrame(() => {
      // 更新数据到数据库
      saveToDatabase(90);
  });

  setTimeout(() => {
      // 埋点
      sendAnalytics(30);
  }, 0);
});

方法四:任务最低优先级

requestIdleCallback 函数同样也能拆分任务,但与其他方法不同的是,它的回调函数会以最低优先级执行。简单来说,只有当浏览器的宏任务和微任务都执行完了,才会执行回调函数。

我觉得,这种最低优先级的任务最适合处理埋点功能。通常,我们会在完成所有功能后上报埋点数据。但如果页面有很多事件任务,而且每个宏任务执行时长较长,主线程的执行资源就难以释放,就会造成卡顿。

要想浏览器有足够的空闲时间执行重要任务,我们应该把埋点这种用户无感知的任务安排到优先级更低的队列里,这样就能减少长任务的执行时间,还能提高浏览器主线程的资源利用率。

具体的代码实现,可以参考下面的逻辑。

const score = document.querySelector('score-keeper');
const button = score.button;

button.addEventListener("click", () => {
    //
  score.incrementAndUpdateUI();
    // 验证表单
  validateForm(30);
  // 显示Loading界面
  showLoading(30);
  // 更新数据
  updateUI(30);

  setTimeout(() => {
      // 更新数据到数据库
      saveToDatabase(90);
  }, 0);

  requestIdleCallback(() => {
      // 埋点
      sendAnalytics(30);
  });

});

方法五:通过调度器调整优先级

你可能已经注意到,前面的4种方法都是依靠宏任务和微任务的机制来拆分任务。这种方法有一个很明显的缺点,就是任务每次被拆分成多个新任务后,新任务会被添加到任务队列的末尾。如果任务队列很长,新任务就需要等待很长时间才能执行。

因此,浏览器提供了一种任务调度器的功能,可以根据每个任务的需求调整其执行优先级别。

任务调度器API位于浏览器window对象的scheduler对象中。它提供了 postTask 函数,这个函数可以更精确地控制浏览器中的任务执行优先级,从而提高页面的性能和用户体验。

这个函数提供了高、中、低三种优先级别,它们分别是 user-blockinguser-visiblebackground。有了任务调度器,我们可以更好地优化长任务。

接下来,我们将使用 postTask 函数来优化上述代码,利用调度器来改善长任务的处理机制。

button.addEventListener("click", () => {
  score.incrementAndUpdateUI();
  scheduler.postTask(validateForm, {priority: 'user-blocking'});
  scheduler.postTask(showLoading, {priority: 'user-blocking'});
  scheduler.postTask(saveToDatabase, {priority: 'background'});
  scheduler.postTask(updateUI, {priority: 'user-blocking'});
  scheduler.postTask(sendAnalytics, {priority: 'background'});
});

就这样,我们轻松地完成了长任务的优化工作。然而,任务调度器API存在浏览器兼容性问题,因此在使用时需要注意它的适用场景。

至此,我们已经学习了解决长任务的5个方法,感觉如何?有没发现,实际上,我们利用前端最基础的概念就能完成优化工作。

事实上,我们主要的开发工作是通过不断地迭代代码满足业务需求,尽管我们经常修改、删除和重构代码,但很少会关注函数任务的优先级和执行顺序。因此,要做好性能优化,就得掌握好JavaScript的运行机制。

总结

好,我们来总结一下。在本节课中,我们主要探讨了如何优化INP指标的第一种方法。优化INP指标就是优化网页对用户互动的响应速度。通常,响应速度受到影响的根本原因是浏览器主线程的任务执行耗时过长。

由此我们可以得出,要做好浏览器主线程任务的性能优化,就必须要了解宏任务和微任务的知识,理解浏览器是如何处理两种任务的执行顺序的。想要优化性能,最简单的方案就是将一个长任务拆分成多个任务,从而提高页面的响应速度。

我们总结出5种方法来优化长任务,其中4种方法都是利用宏任务和微任务的机制来实现的。此外, requestIdleCallback 的回调函数将被安排到最低的优先级来执行。还有一种方法是基于浏览器新特性的方案 ,就是通过任务调度器API来实现任务优先级顺序。

前端同学以后去面试时,回答如何优化页面性能问题的时候,就不要再把八股文的答案拿出来了。宏任务和微任务的优化就是最好的答案之一。

思考题

思考不如立即行动。现在,我给你布置一道思考题。

从你现在维护的前端项目中,尝试用Chrome开发者工具的性能面板观察下项目中有多少个长任务?最长的任务耗时是多少?不妨尝试使用这5种优化方法,看看哪一个方法的效果最佳?

欢迎你在留言区和我交流。如果觉得有所收获,也可以把课程分享给更多的朋友一起学习。我们下节课见!