JavaScript 性能优化
考虑您在网站上如何使用 JavaScript 并思考如何减轻它可能造成的任何性能问题非常重要。虽然图像和视频占普通网站下载字节的 70% 以上,但从字节数来看,JavaScript 对性能负面影响的潜力更大——它会严重影响下载时间、渲染性能以及 CPU 和电池使用情况。本文介绍了优化 JavaScript 以增强网站性能的技巧和方法。
先决条件 | 已安装基本软件,以及对客户端 Web 技术的基本了解。 |
---|---|
目标 | 了解 JavaScript 对网页性能的影响以及如何减轻或解决相关问题。 |
是否优化
优化 JavaScript 下载
您可以使用的最具性能且最不阻塞的 JavaScript 是您根本不使用的 JavaScript。您应该尽可能少地使用 JavaScript。以下是需要牢记的一些技巧
- 您并不总是需要框架:您可能熟悉使用JavaScript 框架。如果您有经验并有信心使用此框架,并且喜欢它提供的所有工具,那么它可能是您构建大多数项目的首选工具。但是,框架是 JavaScript 密集型的。如果您正在创建几乎静态的体验,并且 JavaScript 需求很少,那么您可能不需要该框架。您可能可以使用几行标准 JavaScript 来实现您需要的功能。
- 考虑更简单的解决方案:您可能有一个炫酷、有趣的解决方案要实施,但请考虑您的用户是否会欣赏它。他们是否更喜欢更简单的方案?
- 删除未使用的代码:这听起来可能很明显,但令人惊讶的是,许多开发人员忘记清理开发过程中添加的未使用的功能。您需要仔细谨慎地添加和删除代码。所有脚本都会被解析,无论它是否被使用;因此,加快下载速度的快速方法是摆脱任何未使用的功能。还要考虑,您通常只会使用框架中可用功能的一小部分。是否可以创建仅包含您所需部分的框架自定义构建?
- 考虑内置的浏览器功能:您可能可以使用浏览器已经具有的功能,而不是通过 JavaScript 创建自己的功能。例如
- 使用内置的客户端表单验证。
- 使用浏览器自己的
<video>
播放器。 - 使用CSS 动画 而不是 JavaScript 动画库(另请参见处理动画)。
您还应该将 JavaScript 拆分为多个文件,分别代表关键部分和非关键部分。JavaScript 模块 允许您比仅使用单独的外部 JavaScript 文件更有效地做到这一点。
然后,您可以优化这些较小的文件。缩小 会减少文件中的字符数,从而减少 JavaScript 的字节数或权重。Gzipping 会进一步压缩文件,即使您没有缩小代码,也应该使用它。Brotli 与 Gzip 类似,但通常压缩性能优于 Gzip。
您可以手动拆分和优化代码,但通常像Webpack 这样的模块打包器会做得更好。
处理解析和执行
在查看本节中包含的技巧之前,重要的是要谈论 JavaScript 在浏览器页面渲染过程中的位置。当加载网页时
- HTML 通常首先解析,按其在页面上的出现顺序解析。
- 每当遇到 CSS 时,都会解析它以了解需要应用于页面的样式。在此期间,开始获取链接的资产,例如图像和网络字体。
- 每当遇到 JavaScript 时,浏览器都会解析、评估它并在页面上运行它。
- 稍后,浏览器会根据应用于它的 CSS,确定每个 HTML 元素的样式应如何。
- 然后,样式化的结果会绘制到屏幕上。
注意:这只是发生情况的非常简化的描述,但它确实让您有所了解。
这里关键的一步是步骤 3。默认情况下,JavaScript 解析和执行是渲染阻塞的。这意味着浏览器会阻塞对 JavaScript 遇到的 JavaScript 后面出现的任何 HTML 的解析,直到脚本被处理完毕。因此,样式和绘制也会被阻塞。这意味着您不仅需要仔细考虑要下载的内容,还需要考虑何时以及如何执行该代码。
接下来的几节提供了优化 JavaScript 解析和执行的有用技术。
尽快加载关键资产
如果脚本确实很重要,并且您担心它由于加载速度不够快而影响性能,您可以将它加载到文档的<head>
中
<head>
...
<script src="main.js"></script>
...
</head>
这可以正常工作,但会阻塞渲染。更好的策略是使用rel="preload"
为关键 JavaScript 创建预加载器
<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,不会阻塞渲染。然后,您可以在页面的任何位置使用它
<!-- Include this wherever makes sense -->
<script src="important-js.js"></script>
或者在您的脚本中,如果是 JavaScript 模块
import { function } from "important-module.js";
注意:预加载不保证脚本在您包含它时会被加载,但它确实意味着它会更快地开始下载。即使渲染阻塞时间没有完全消除,也会缩短。
延迟非关键 JavaScript 的执行
另一方面,您应该尽量推迟解析和执行非关键 JavaScript,直到需要时再执行。提前加载所有内容会不必要地阻塞渲染。
首先,您可以将async
属性添加到<script>
元素中
<head>
...
<script async src="main.js"></script>
...
</head>
这会导致脚本与 DOM 解析并行获取,因此它将在同一时间准备好,不会阻塞渲染。
注意:还有一个属性defer
,它会导致脚本在文档解析后执行,但在触发DOMContentLoaded
事件之前执行。这与async
具有类似的效果。
您也可以在需要时才加载 JavaScript,直到事件发生。这可以通过 DOM 脚本完成,例如
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);
可以使用import()
函数动态加载 JavaScript 模块
import("./modules/myModule.js").then((module) => {
// Do something with the module
});
分解长时间任务
当浏览器运行您的 JavaScript 时,它会将脚本组织成按顺序运行的任务,例如发出获取请求,通过事件处理程序驱动用户交互和输入,运行 JavaScript 驱动的动画等等。
大多数操作都在主线程上进行,除了在Web Workers 中运行的 JavaScript 之外。主线程一次只能运行一项任务。
当一项任务的运行时间超过 50 毫秒时,它就会被分类为一项长时间任务。如果用户尝试在长时间任务运行时与页面交互或请求重要的 UI 更新,他们的体验将会受到影响。预期的响应或视觉更新将被延迟,导致 UI 显得迟缓或无响应。
为了缓解这个问题,您需要将长时间任务分解成更小的任务。这会让浏览器有更多机会执行重要的用户交互处理或 UI 渲染更新——浏览器有可能在每个较小的任务之间执行这些操作,而不仅仅是在长时间任务之前或之后。在您的 JavaScript 中,您可能会通过将代码分解成单独的函数来做到这一点。这对于其他几个原因也说得通,例如更易于维护、调试和编写测试。
例如
function main() {
a();
b();
c();
d();
e();
}
但是,这种结构对主线程阻塞没有帮助。由于所有五个函数都在一个主函数中运行,浏览器会将它们全部作为一个长时间任务运行。
为了处理这种情况,我们倾向于定期运行一个“yield”函数,以让代码让步给主线程。这意味着我们的代码被拆分成多个任务,在执行这些任务之间,浏览器有机会处理高优先级任务,例如更新 UI。此函数的常见模式使用setTimeout()
将执行推迟到另一个任务中
function yield() {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
这可以用于像这样在任务运行器模式中使用,以便在每个任务运行后让步给主线程
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 yield();
}
}
为了进一步改进这一点,我们可以使用navigator.scheduling.isInputPending()
仅在用户尝试与页面交互时运行yield()
函数
async function main() {
// Create an array of functions to run
const tasks = [a, b, c, d, e];
while (tasks.length > 0) {
// Yield to a pending user input
if (navigator.scheduling.isInputPending()) {
await yield();
} else {
// Shift the first task off the tasks array
const task = tasks.shift();
// Run the task
task();
}
}
}
这使您能够在用户积极与页面交互时避免阻塞主线程,从而提供更流畅的用户体验。但是,通过仅在必要时让步,我们可以在没有用户输入需要处理时继续运行当前任务。这还可以避免任务排在队列的后面,位于在当前任务之后安排的其他非必需的浏览器启动任务的后面。
处理 JavaScript 动画
动画可以提高感知性能,使界面感觉更灵敏,并让用户感觉在等待页面加载时正在取得进展(例如加载动画)。但是,更大的动画和更多的动画自然需要更多的处理能力来处理,这会降低性能。
最明显的动画建议是使用更少的动画——减少任何非必要的动画,或者考虑让用户设置一个偏好设置,让他们可以选择关闭动画,例如,如果他们使用的是低功耗设备或电池电量有限的移动设备。
对于基本的 DOM 动画,建议您尽可能使用 CSS 动画,而不是 JavaScript 动画(Web 动画 API 提供了一种使用 JavaScript 直接挂钩到 CSS 动画的方法)。使用浏览器直接执行 DOM 动画,而不是使用 JavaScript 操作内联样式,速度更快,效率更高。另请参见 CSS 性能优化 > 处理动画。
对于无法在 JavaScript 中处理的动画,例如,动画 HTML <canvas>
,建议您使用 Window.requestAnimationFrame()
,而不是旧选项,例如 setInterval()
。requestAnimationFrame()
方法专门设计用于高效且一致地处理动画帧,以提供流畅的用户体验。基本模式如下所示
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 动画的良好介绍,以及在 对象构建实践 中找到更深入的示例。您还可以在 Canvas 教程 中找到完整的 canvas 教程集。
优化事件性能
对于浏览器而言,跟踪和处理事件可能很昂贵,尤其是在您持续运行事件时。例如,您可能正在使用 mousemove
事件来跟踪鼠标的位置,以检查它是否仍在页面的某个区域内
function handleMouseMove() {
// Do stuff while mouse pointer is inside elem
}
elem.addEventListener("mousemove", handleMouseMove);
您可能在页面中运行 <canvas>
游戏。当鼠标在 canvas 内时,您需要不断检查鼠标移动和光标位置,并更新游戏状态——包括分数、时间、所有精灵的位置、碰撞检测信息等。游戏结束后,您将不再需要执行所有操作,事实上,继续监听该事件将是一种浪费处理能力。
因此,最好删除不再需要的事件监听器。这可以使用 removeEventListener()
完成
elem.removeEventListener("mousemove", handleMouseMove);
另一个技巧是在任何可能的情况下使用事件委托。当您有一些代码需要响应用户与大量子元素中的任何一个交互时,您可以在其父元素上设置事件监听器。在任何子元素上触发的事件都会冒泡到其父元素,因此您无需在每个子元素上单独设置事件监听器。更少的事件监听器意味着更好的性能。
有关更多详细信息和有用示例,请参阅 事件委托。
编写更高效代码的技巧
有几个通用的最佳实践可以让您的代码运行效率更高。
- 减少 DOM 操作:访问和更新 DOM 在计算上很昂贵,因此您应该尽量减少 JavaScript 执行的操作量,尤其是在执行持续的 DOM 动画时(请参见上面的 处理 JavaScript 动画)。
- 批量 DOM 更改:对于基本的 DOM 更改,您应该将它们批处理到一起执行的组中,而不是在发生时立即触发每个单独的更改。这可以减少浏览器实际执行的工作量,还可以提高感知性能。一次性完成多个更新,而不是不断进行小的更新,可以让 UI 看起来更流畅。这里一个有用的技巧是——当您有一大块 HTML 要添加到页面时,请先构建整个片段(通常在
DocumentFragment
中),然后将其全部附加到 DOM 中,而不是单独附加每个项目。 - 简化您的 HTML:DOM 树越简单,使用 JavaScript 访问和操作它就越快。仔细考虑 UI 的需求,并删除不必要的冗余内容。
- 减少循环代码的数量:循环很昂贵,因此尽可能减少代码中的循环使用量。在循环不可避免的情况下
- 避免在不需要的情况下运行整个循环,使用
break
或continue
语句,具体取决于情况。例如,如果您正在数组中搜索特定名称,则应在找到名称后退出循环;没有必要运行进一步的循环迭代jsfunction processGroup(array) { const toFind = "Bob"; for (let i = 0; i < array.length - 1; i++) { if (array[i] === toFind) { processMatchingArray(array); break; } } }
- 将仅需执行一次的操作放到循环之外。这听起来可能有些明显,但很容易被忽视。以下代码片段获取包含要以某种方式处理的数据的 JSON 对象。在这种情况下,
fetch()
操作在循环的每次迭代中执行,这是一种浪费计算能力。获取操作不依赖于i
,可以移动到循环之外,这样就只执行一次。jsasync 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 中执行该计算,等待结果,并在结果准备好后将其发送回主线程。
- 使用 WebGPU:WebGPU 是一种浏览器 API,允许 Web 开发人员使用底层系统的 GPU(图形处理单元)来执行高性能计算和绘制可以在浏览器中渲染的复杂图像。它相当复杂,但可以提供比 web worker 更好的性能优势。
- 使用异步代码:异步 JavaScript 基本上是不阻塞主线程的 JavaScript。异步 API 倾向于处理诸如从网络获取资源、访问本地文件系统上的文件或打开用户网络摄像头的流之类的操作。由于这些操作可能需要很长时间,因此仅在等待这些操作完成时阻塞主线程将非常糟糕。相反,浏览器会执行这些函数,使主线程继续运行后续代码,这些函数将在结果可用时在将来的某个时间点返回结果。现代异步 API 是基于
另请参阅
- 优化 web.dev 上的长时间任务 (2022)
- Canvas 教程