JavaScript 性能优化

考虑您如何在网站上使用 JavaScript,并思考如何缓解可能由此造成的任何性能问题,这一点非常重要。虽然图像和视频占平均网站下载字节的 70% 以上,但就每个字节而言,JavaScript 产生负面性能影响的潜力更大——它会显著影响下载时间、渲染性能以及 CPU 和电池使用。本文介绍了优化 JavaScript 以提高网站性能的技巧和技术。

预备知识 已安装基本软件,并具备 客户端 Web 技术 的基础知识。
目标 了解 JavaScript 对 Web 性能的影响以及如何缓解或解决相关问题。

优化还是不优化

在开始优化代码之前,您应该回答的第一个问题是“我需要优化什么?”。下面讨论的一些技巧和技术是很好的实践,几乎所有 Web 项目都会受益,而有些只在特定情况下才需要。尝试将所有这些技术应用到所有地方可能是不必要的,并且可能会浪费您的时间。您应该弄清楚每个项目实际需要哪些性能优化。

为此,您需要测量您网站的性能。如前面的链接所示,有几种不同的方法可以测量性能,其中一些涉及复杂的性能 API。然而,最好的入门方法是学习如何使用内置浏览器网络性能工具等工具,以查看页面加载的哪些部分耗时较长并需要优化。

优化 JavaScript 下载

您能使用的性能最高、阻塞最少的 JavaScript 是您根本不使用的 JavaScript。您应该尽可能少地使用 JavaScript。以下是一些需要牢记的技巧:

  • 您并非总是需要框架:您可能熟悉使用 JavaScript 框架。如果您有使用此框架的经验和信心,并且喜欢它提供的所有工具,那么它可能是您构建大多数项目的首选工具。但是,框架是 JavaScript 密集型的。如果您正在创建具有很少 JavaScript 需求的相当静态的体验,您可能不需要该框架。您可能能够使用几行标准 JavaScript 实现您所需的功能。
  • 考虑更简单的解决方案:您可能有一个华丽有趣的解决方案要实现,但要考虑您的用户是否会欣赏它。他们会喜欢更简单的东西吗?
  • 删除未使用的代码:这听起来很明显,但令人惊讶的是,有多少开发人员忘记清理在开发过程中添加的未使用功能。您需要谨慎而有意地添加和删除内容。所有脚本都会被解析,无论是否使用;因此,加快下载速度的一个快速方法是摆脱任何未使用的功能。还要考虑,通常您只会使用框架中可用功能的一小部分。是否可以创建只包含您需要部分的自定义框架构建?
  • 考虑内置浏览器功能:您可能可以使用浏览器已有的功能,而不是通过 JavaScript 创建自己的功能。例如:

您还应该将 JavaScript 分成多个文件,分别代表关键和非关键部分。JavaScript 模块 允许您比简单地使用单独的外部 JavaScript 文件更有效地做到这一点。

然后您可以优化这些更小的文件。最小化 可减少文件中字符的数量,从而减少 JavaScript 的字节数或大小。Gzipping 进一步压缩文件,即使您不最小化代码也应该使用它。Brotli 类似于 Gzip,但通常优于 Gzip 压缩。

您可以手动分割和优化代码,但通常像 webpack 这样的模块打包器会做得更好。

处理解析和执行

在查看本节中的技巧之前,重要的是要讨论浏览器页面渲染过程中 JavaScript 的处理位置。当网页加载时:

  1. HTML 通常首先解析,按照它在页面上出现的顺序。
  2. 每当遇到 CSS 时,它都会被解析以理解需要应用于页面的样式。在此期间,图像和网页字体等链接资产开始被获取。
  3. 每当遇到 JavaScript 时,浏览器会解析、评估并针对页面运行它。
  4. 稍后,浏览器会根据应用于每个 HTML 元素的 CSS 来计算每个 HTML 元素的样式。
  5. 然后将样式化的结果绘制到屏幕上。

注意: 这只是对发生情况的非常简化的描述,但它确实能让您了解一些情况。

这里的关键步骤是步骤 3。默认情况下,JavaScript 解析和执行是渲染阻塞的。这意味着浏览器会阻止解析 JavaScript 之后出现的任何 HTML,直到脚本被处理。因此,样式和绘制也会被阻塞。这意味着您不仅需要仔细考虑下载什么,还需要考虑何时以及如何执行该代码。

接下来的几节将提供优化 JavaScript 解析和执行的实用技术。

尽快加载关键资产

如果脚本非常重要,并且您担心它由于加载速度不够快而影响性能,您可以将其加载到文档的 <head> 中:

html
<head>
  ...
  <script src="main.js"></script>
  ...
</head>

这可以,但会阻塞渲染。更好的策略是使用 rel="preload" 为关键 JavaScript 创建预加载器:

html
<head>
  ...
  <!-- Preload a JavaScript file -->
  <link rel="preload" href="important-js.js" as="script" />
  <!-- Preload a JavaScript module -->
  <link rel="modulepreload" href="important-module.js" />
  ...
</head>

预加载 <link> 尽快获取 JavaScript,而不会阻塞渲染。然后您可以在页面中的任何地方使用它:

html
<!-- Include this wherever makes sense -->
<script src="important-js.js"></script>

或者在您的脚本中,如果是 JavaScript 模块:

js
import { someFunction } from "important-module.js";

注意: 预加载并不能保证在您包含脚本时它已经加载,但它确实意味着它会更快开始下载。渲染阻塞时间仍然会缩短,即使没有完全消除。

延迟执行非关键 JavaScript

另一方面,您应该旨在将非关键 JavaScript 的解析和执行推迟到稍后需要时。预先加载所有内容会不必要地阻塞渲染。

首先,您可以将 async 属性添加到您的 <script> 元素中:

html
<head>
  ...
  <script async src="main.js"></script>
  ...
</head>

这会导致脚本与 DOM 解析并行获取,因此它会同时准备好,并且不会阻塞渲染。

注意: 还有另一个属性,defer,它会导致脚本在文档解析后但在触发 DOMContentLoaded 事件之前执行。这与 async 具有类似的效果。

您也可以根本不加载 JavaScript,直到需要它时发生事件。这可以通过 DOM 脚本来完成,例如:

js
const scriptElem = document.createElement("script");
scriptElem.src = "index.js";
scriptElem.addEventListener("load", () => {
  // Run a function contained within index.js once it has definitely loaded
  init();
});
document.head.append(scriptElem);

JavaScript 模块可以使用 import() 函数动态加载:

js
import("./modules/myModule.js").then((module) => {
  // Do something with the module
});

分解长时间任务

当浏览器运行您的 JavaScript 时,它会将脚本组织成按顺序运行的任务,例如发出获取请求、通过事件处理程序驱动用户交互和输入、运行 JavaScript 驱动的动画等。

大部分操作都发生在主线程上,除了在 Web Worker 中运行的 JavaScript。主线程一次只能运行一个任务。

当单个任务运行时间超过 50 毫秒时,它被归类为长时间任务。如果用户在长时间任务运行时尝试与页面交互或请求重要的 UI 更新,他们的体验将受到影响。预期的响应或视觉更新将被延迟,导致 UI 显得迟钝或无响应。

为了缓解这个问题,您需要将长时间任务分解为更小的任务。这为浏览器提供了更多机会来执行重要的用户交互处理或 UI 渲染更新——浏览器可以在每个较小的任务之间执行它们,而不是只在长时间任务之前或之后执行。在您的 JavaScript 中,您可以通过将代码分解为单独的函数来实现这一点。这对于其他几个原因也很有意义,例如更易于维护、调试和编写测试。

例如

js
function main() {
  a();
  b();
  c();
  d();
  e();
}

然而,这种结构对主线程阻塞没有帮助。由于所有五个函数都在一个主函数中运行,因此浏览器将它们全部作为单个长时间任务运行。

为了解决这个问题,我们倾向于定期运行一个“yield”函数,让代码让步给主线程。这意味着我们的代码被分成多个任务,在这些任务的执行之间,浏览器有机会处理高优先级任务,例如更新 UI。此函数的一种常见模式使用 setTimeout() 将执行推迟到单独的任务中:

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

这可以在任务运行器模式中使用,以便在每个任务运行后让步给主线程,如下所示:

js
async function main() {
  // Create an array of functions to run
  const tasks = [a, b, c, d, e];

  // Loop over the tasks
  while (tasks.length > 0) {
    // Shift the first task off the tasks array
    const task = tasks.shift();

    // Run the task
    task();

    // Yield to the main thread
    await yieldFunc();
  }
}

为了进一步改进这一点,我们可以在可用时使用 Scheduler.yield() 允许此代码在队列中其他不太关键的任务之前继续执行:

js
function yieldFunc() {
  // Use scheduler.yield() if available
  if ("scheduler" in window && "yield" in scheduler) {
    return scheduler.yield();
  }

  // Fall back to setTimeout:
  return new Promise((resolve) => {
    setTimeout(resolve, 0);
  });
}

处理 JavaScript 动画

动画可以提高感知性能,使界面感觉更流畅,并让用户在等待页面加载时感觉正在取得进展(例如加载旋转器)。然而,更大的动画和更多的动画自然需要更多的处理能力来处理,这会降低性能。

最明显的动画建议是减少动画的使用——取消任何非必要的动画,或者考虑让您的用户设置一个偏好来关闭动画,例如,如果他们使用的是低功耗设备或电池电量有限的移动设备。

对于必要的 DOM 动画,建议尽可能使用 CSS 动画,而不是 JavaScript 动画(Web 动画 API 提供了一种使用 JavaScript 直接挂钩 CSS 动画的方法)。使用浏览器直接执行 DOM 动画而不是使用 JavaScript 操作内联样式要快得多且效率更高。另请参阅 CSS 性能优化 > 处理动画

对于无法在 JavaScript 中处理的动画,例如,动画 HTML <canvas>,建议使用 Window.requestAnimationFrame() 而不是旧选项,例如 Window.setInterval()requestAnimationFrame() 方法专门设计用于高效且一致地处理动画帧,以提供流畅的用户体验。基本模式如下所示:

js
function loop() {
  // Clear the canvas before drawing the next frame of the animation
  ctx.fillStyle = "rgb(0 0 0 / 25%)";
  ctx.fillRect(0, 0, width, height);

  // Draw objects on the canvas and update their positioning data
  // ready for the next frame
  for (const ball of balls) {
    ball.draw();
    ball.update();
  }

  // Call requestAnimationFrame to run the loop() function again
  // at the right time to keep the animation smooth
  requestAnimationFrame(loop);
}

// Call the loop() function once to set the animation running
loop();

您可以在 绘制图形 > 动画 中找到画布动画的精彩介绍,并在 对象构建实践 中找到更深入的示例。您还可以在 Canvas 教程 中找到一套完整的画布教程。

优化事件性能

事件对浏览器来说跟踪和处理起来可能很昂贵,尤其是在您持续运行事件时。例如,您可能正在使用 mousemove 事件跟踪鼠标位置,以检查它是否仍在页面的某个区域内:

js
function handleMouseMove() {
  // Do stuff while mouse pointer is inside elem
}

elem.addEventListener("mousemove", handleMouseMove);

您可能正在页面中运行一个 <canvas> 游戏。当鼠标在画布内时,您会希望不断检查鼠标移动和光标位置并更新游戏状态——包括分数、时间、所有精灵的位置、碰撞检测信息等。游戏结束后,您将不再需要做所有这些事情,事实上,继续监听该事件将浪费处理能力。

因此,删除不再需要的事件监听器是一个好主意。这可以通过使用 removeEventListener() 来完成:

js
elem.removeEventListener("mousemove", handleMouseMove);

另一个技巧是尽可能使用事件委托。当您有一些代码需要响应用户与大量子元素中的任何一个进行交互时,您可以在它们的父元素上设置一个事件监听器。在任何子元素上触发的事件都会冒泡到它们的父元素,因此您无需单独为每个子元素设置事件监听器。需要跟踪的事件监听器越少意味着更好的性能。

有关更多详细信息和实用示例,请参阅 事件委托

编写更高效代码的技巧

有几个通用的最佳实践可以让您的代码运行得更有效率。

  • 减少 DOM 操作:访问和更新 DOM 的计算成本很高,因此您应该尽量减少 JavaScript 执行的操作量,尤其是在执行持续的 DOM 动画时(参见上面的处理 JavaScript 动画)。

  • 批量处理 DOM 更改:对于必要的 DOM 更改,您应该将它们分批处理,而不是在每次单独的更改发生时就立即触发。这可以减少浏览器实际执行的工作量,同时也可以提高感知性能。一次性完成几次更新而不是持续进行小更新,可以使 UI 看起来更流畅。这里有一个有用的提示——当您有一大块 HTML 要添加到页面时,首先构建整个片段(通常在 DocumentFragment 中),然后一次性将其全部附加到 DOM 中,而不是单独附加每个项目。

  • 简化您的 HTML:您的 DOM 树越简单,它被 JavaScript 访问和操作的速度就越快。仔细考虑您的 UI 需要什么,并删除不必要的冗余。

  • 减少循环代码量:循环开销很大,因此尽可能减少代码中循环的使用量。在循环不可避免的情况下:

    • 在不必要时避免运行完整的循环,根据需要使用 breakcontinue 语句。例如,如果您正在数组中搜索特定名称,一旦找到名称,您就应该跳出循环;没有必要运行进一步的循环迭代:

      js
      function processGroup(array) {
        const toFind = "Bob";
        for (let i = 0; i < array.length - 1; i++) {
          if (array[i] === toFind) {
            processMatchingArray(array);
            break;
          }
        }
      }
      
    • 执行只需要一次的工作,放在循环之外。这听起来有点明显,但很容易被忽视。看下面的代码片段,它获取一个包含数据需要以某种方式处理的 JSON 对象。在这种情况下,fetch() 操作在循环的每次迭代中完成,这浪费了计算能力。不依赖于 i 的获取可以移到循环之外,这样只需执行一次。

      js
      async function returnResults(number) {
        for (let i = 0; i < number; i++) {
          const response = await fetch(`/results?number=${number}`);
          const results = await response.json();
          processResult(results[i]);
        }
      }
      
  • 在主线程之外运行计算:前面我们讨论了 JavaScript 通常如何在主线程上运行任务,以及长时间操作如何阻塞主线程,可能导致糟糕的 UI 性能。我们还展示了如何将长时间任务分解为更小的任务,从而缓解这个问题。解决此类问题的另一种方法是将任务完全移出主线程。有几种方法可以实现这一点:

    • 使用异步代码:异步 JavaScript 基本上是不会阻塞主线程的 JavaScript。异步 API 倾向于处理从网络获取资源、访问本地文件系统上的文件或打开用户网络摄像头流等操作。由于这些操作可能需要很长时间,因此在等待它们完成时阻塞主线程会很糟糕。相反,浏览器执行这些函数,保持主线程运行后续代码,并且这些函数将在未来的某个时刻可用时返回结果。现代异步 API 是基于 Promise 的,这是一个为处理异步操作而设计的 JavaScript 语言特性。如果您有需要异步运行的功能,可以编写自己的基于 Promise 的函数
    • 在 Web Worker 中运行计算:Web Worker 是一种机制,允许您打开一个单独的线程来运行一段 JavaScript,这样它就不会阻塞主线程。Worker 有一些主要的限制,最大的限制是您不能在 Worker 中进行任何 DOM 脚本操作。您可以做大多数其他事情,Worker 可以向主线程发送和接收消息。Worker 的主要用例是如果您有大量计算要做,并且您不希望它阻塞主线程。在 Worker 中进行计算,等待结果,并在准备好时将其发送回主线程。
    • 使用 WebGPUWebGPU 是一种浏览器 API,允许 Web 开发人员使用底层系统的 GPU(图形处理单元)来执行高性能计算并绘制可在浏览器中渲染的复杂图像。它相当复杂,但可以提供比 Web Worker 更好的性能优势。

另见