首页 / 文章

JavaScript的性能提升在于减少工作量,而非减少代码行数。

性能优化工作在于选择哪些计算无需执行,如实衡量核心网页指标,并修复最耗资源的代码路径,而非仅仅为了减少源代码行数而为之。

发布者:REACTAPP.TOP阅读约 12 分钟2672 词

来源: JavaScript Performance Isn’t About Writing Fewer Lines

最快的 JavaScript 通常是那些从未被执行的 JavaScript

性能优化工作常常被理解为编写更高效的循环。在浏览器中,更大的提升通常来自于减少操作:更少的处理函数被触发、更少的 DOM 变更、更少的网络请求往返,以及更少的主线程占用时间。以一个每次按键都会重新筛选的实时搜索框为例:

input.addEventListener("input", () => {
    renderResults(filterUsers(input.value));
});

输入姓名后会触发一系列中间查询:

m
ma
mah
maha
mahad

这些中间结果大多会被丢弃。通过防抖机制可以减少查询次数,只执行最终确定的查询:

const debounce = (fn, delay = 250) => {
    let timer;
return (...args) => {
        clearTimeout(timer);
        timer = setTimeout(() => {
            fn(...args);
        }, delay);
    };
};
const search = debounce((query) => {
    renderResults(filterUsers(query));
});

函数本身的逻辑并没有变得更复杂;变化的是任务的执行顺序,而这通常决定了浏览器性能的优劣。采用“避免、延迟、合并”这样的策略,比对那些本就不应频繁执行的函数进行微观优化更为有效。

中等复杂度的查询并非免费。每一次按键都可能涉及数组分配、遍历长列表、更新 React 或 DOM 节点以及调度重绘操作。在性能一般的手机上,这些开销会累积并导致明显的卡顿现象。防抖仅是其中一种优化手段;限制滚动处理函数的执行频率、合并尺寸变化监听器,以及在标签页隐藏时跳过相关操作,都属于同一思路:仅在结果仍有意义时才执行相应操作。

不要优化函数本身,而要优化实际的操作流程。

对每个项目都应用成本较高的转换操作看似并无问题:

items.map(item => transform(item));

应当思考为何要执行该转换操作、针对的是哪种屏幕,以及结果变化的频率如何。如果同一用户的数据需要反复处理,应采用记忆化技术:

const cache = new Map();
function getFormattedUser(id, user) {
    if (cache.has(id)) {
        return cache.get(id);
    }
    const result = formatUser(user);
    cache.set(id, result);
    return result;
}

对相同的输入进行缓存可以避免重复的CPU运算,同时无需重新编写转换逻辑。排序、过滤以及生成视图模型时也适用同样的思路:在输入发生变化时才进行计算,而非在每次渲染时都执行。

记忆化并非无需代价的解决方案。无限制的映射结构会不断膨胀,最终因内存压力而导致性能问题。应使用稳定的标识符作为键值、限制缓存大小,并在源数据发生变化时使缓存失效。最好在状态发生变更时一次性生成视图模型,而非在每个叶子组件中都重新计算复杂的结构。

你的JavaScript可以很快,而UI却可以很慢

在循环中不断向DOM添加内容会迫使浏览器反复进行布局计算:

for (const item of items) {
    container.innerHTML += createCard(item);
}

一次性构建字符串并赋值一次的成本更低:

const html = items
    .map(createCard)
    .join("");
container.innerHTML = html;

DOM操作带来的影响并不仅限于触发它的JavaScript语句本身。即便脚本执行日志显示速度很快,样式和布局的重新计算仍可能占据大量帧时间。

当需要创建大量节点时,文档片段与离线DOM构建方式能提供帮助。若允许框架自动处理,它们会批量更新内容;而在性能敏感的路径中使用直接的innerHTML循环来对抗框架,反而会重新引入那些库原本旨在避免的开销。建议采用基于键的列表更新方式,这样无需销毁并重新创建未发生变化的行。

布局计算的成本可能高于函数执行成本

读取布局属性会迫使浏览器计算几何信息:

const height = element.offsetHeight;

写入样式后再再次读取则会导致性能浪费:

element.style.height = `${height + 20}px`;
const newHeight = element.offsetHeight;

先批量读取,再批量写入。避免在滚动处理程序和动画等高频执行的代码路径中混合进行测量与数据修改操作。

强制同步布局是自定义工具提示、固定标题栏以及与画布对齐的HTML结构中常见的性能瓶颈。如果某个库需要进行测量,应每帧测量一次并缓存结果。requestAnimationFrame比那些在每个事件发生时都读取几何数据的原始滚动监听器更适合作为调度方式。

不要让主线程变成你的个人待办清单

对大型数组进行复杂的变换操作会阻塞输入处理和渲染过程:

function processLargeDataset(data) {
    return data.map(expensiveTransformation);
}

先思考这项工作是否真的必须在主线程上执行。工作线程可以将那些依赖CPU的计算任务从UI线程中分离出来:

const worker = new Worker("processor.js");
worker.postMessage(largeDataset);
worker.onmessage = (event) => {
    updateUI(event.data);
};

要时刻注意消息传递的开销,有意识地传输或结构化复制大型数据负载。

工人改变的是 CPU 的归属,而非产品复杂度。序列化处理可能会抵消小型任务的收益。应将它们用于解析、加密、图像处理或大规模数据聚合,而不适用于那些在一次 postMessage 通信就能完成的微任务。当负载很大且安全策略允许时,SharedArrayBuffer 和可传输对象能提供帮助。

懒加载是一种性能优化手段

只有在需要时才加载相关内容。动态导入功能可将代码延迟到极少使用的路径上:

async function openAnalytics() {
    const { Analytics } = await import("./analytics.js");
const analytics = new Analytics();
    analytics.open();
}

从不查看分析数据的用户就不会下载该模块。应结合路由级别的代码分割技术,以保持初始页面加载的轻量级。

基于路由的分割应遵循用户的操作流程,而不仅仅是文件夹边界。对于可能接下来的页面,应在鼠标悬停或空闲时预先加载,这样才能避免因懒加载导致的点击等待。要注意各代码块之间可能存在重复的依赖关系,这会在分割后增加整体体积。

包大小并非唯一考量因素

较小的包固然有益,但若过小的包仍会阻塞主线程或导致瀑布式加载,其体验可能比稍大一些但能优先加载关键UI元素的包更差。应关注页面可交互的时间以及交互延迟,而不仅仅是文件大小。

网络性能也关乎JavaScript性能

顺序的await操作会串行化延迟:

const user = await fetch("/api/user");
const posts = await fetch("/api/posts");
const notifications = await fetch("/api/notifications");

独立的请求则应并行执行:

const [user, posts, notifications] = await Promise.all([
    fetch("/api/user"),
    fetch("/api/posts"),
    fetch("/api/notifications")
]);

瀑布式加载既可能出现在结构混乱的脚本标签中,也容易在async函数内部出现。

最昂贵的请求往往是你并不需要的那个

初始界面通常只需要少量核心数据,而应用却会急于加载其他辅助面板。建议先加载主控制台,再处理其余内容:

await loadDashboard();
requestIdleCallback(() => {
    loadSecondaryData();
});

并非所有任务都应享有相同的优先级。空闲时的回调、低优先级的数据获取以及用户明确的操作指令,都是根据任务紧急程度来分配处理顺序的工具。

缓存往往比微优化更为重要

缓存存在于HTTP层、服务工作线程层、内存层以及派生状态层。正确的缓存键与失效策略所能减少的延迟,往往超过重新编写排序逻辑所带来的效果。应明确记录数据更新规则,避免过时的用户界面成为隐性的产品缺陷。

HTTP缓存头和ETags可以减少重复下载;内存缓存则能降低JSON解析的次数;UI存储可减少重复的计算操作。每一层都需要明确的负责人来处理数据失效的问题。如果没有这样的责任人,团队要么为了“万全起见”过度获取数据,要么显示过时的信息,从而损害用户信任。

虚拟化彻底改变了这个问题

试图一次性渲染数千行数据是行不通的:

rows.forEach(renderRow);

虚拟列表只渲染可见的内容。问题重点从“降低每行的处理成本”转变为“避免创建屏幕外的行”,而这通常是更合理的取舍。

窗口化库以简化实现为代价来保证滚动功能的正确性:需要妥善处理行高变化、焦点管理以及固定列等问题。即便如此,对于大型数据集而言,它们的表现仍优于那些复杂的CSS技巧。应结合服务器分页功能,这样客户端就不必为了实现虚拟化而存储数百万条记录。

性能工具胜过猜测

当感觉程序运行缓慢时,进行测量。Chrome DevTools能够显示网络请求流程、主线程中的耗时任务、布局刷新问题以及内存使用情况。仅凭猜测会导致错误地优化功能。

简单的性能排查方法

按照检查清单逐步操作,而非过早地采取微调措施:

  1. 网络连接是否缓慢?
  2. 是否有过多的JavaScript代码被下载?
  3. JavaScript是否阻塞了主线程?
  4. 渲染过程是否耗时过长?
  5. 我们是否在重复执行相同的工作?
  6. 内存使用量是否在增加?
  7. 该问题是否仅在大规模使用时出现?

用实际数据而非传言来回答这些问题。

最常出现的性能陷阱

团队们花费数周时间优化那些与问题无关的代码——试图将某个转换操作的耗时减少几微秒,而实际上四秒钟的瀑布流渲染或无限列表的加载才是影响用户体验的关键。应先确定真正的瓶颈所在。

现代 JavaScript 提供了更好的工具

利用平台自带功能来减少自定义代码的编写:

structuredClone(data);
const result = await Promise.allSettled(tasks);
const module = await import("./feature.js");
const controller = new AbortController();
fetch("/api/search", {
    signal: controller.signal
});

搜索用户体验通常需要能够中止正在处理的请求,以避免过时的响应抢先完成处理。一种有效的解决方案是:

javascript
javascript performance
let controller;
async function search(query) {
    controller?.abort();
    controller = new AbortController();
    try {
        const response = await fetch(
            `/api/search?q=${encodeURIComponent(query)}`,
            { signal: controller.signal }
        );
        return await response.json();
    } catch (error) {
        if (error.name !== "AbortError") {
            throw error;
        }
    }
}

AbortController、动态导入以及 Promise.allSettled 可以替代许多手动实现的队列和状态标志机制。

性能还涉及产品层面

100毫秒的提速并不一定就有价值,位置很关键。将2毫秒的辅助时间减半几乎不会改变用户感知,但能把4秒的首次加载时间缩短到2秒则会有显著效果。即时的输入反馈往往比稍微快一点的后台处理更重要,流畅的滚动体验也比缩短API转换时间更为重要。性能是系统为用户带来的使用体验,而非微基准测试的数值精美程度。

别再追求那些无法反映用户真实情况的数字

合成测试工具固然有用,但在局域网中搭配简单配置的快速笔记本电脑并不能代表实际生产环境。应使用具有代表性的设备、网络、数据规模以及交互模式进行测试——否则你优化的是一个虚拟应用而已。

实验室指标与实际使用环境中的指标都很重要。Lighthouse能检测出持续集成过程中的功能退化问题,而真实用户监控则能发现不同设备及地区带来的差异。当两者结果不一致时,应以实际使用环境的数据来确定处理优先级,以实验室数据来确保问题可复现。基于LCP、INP及复杂任务时长设定的性能预算,能让相关讨论更加具体。

性能思维方式

建议按以下顺序思考问题:有哪些工作可以避免?以后可能会出现什么问题?哪些问题出现的频率可以降低?哪些问题可以在其他地方解决?对用户来说真正重要的是什么?解决方案可能包括延迟处理、缓存机制、代码拆分、虚拟化技术、工作线程使用、API调整、查询优化,或是删除未被使用的功能。

总结

关于 JavaScript 性能的最重要教训并非某个特定的 API。快速运行的代码并不一定就是执行速度高的代码。有时它根本不会运行,或者延迟运行,或者只运行一次而非五十次,又或者离开主线程,还可能停止向其他系统请求不必要的操作。不要再将“更少的代码行数”当作速度的代名词,而应致力于减少用户在操作过程中感受到的那些不必要的工作。

删除未被使用的功能模块其实也是一种提升性能的手段。每一个处于休眠状态的图表库、自动播放的媒体插件,或是盲目进行的预加载操作,都在争夺带宽和主线程的时间。产品团队与工程团队应当评估哪些功能确实值得投入资源。最快的 JavaScript 其实就是那些从未被下载、从未被解析、也从未被运行的代码。

实际场景中的防抖处理:搜索、自动保存与窗口大小调整

实时搜索只是一个教学示例,但同样的调度原则也适用于其他场景。每次按键都自动保存数据可能会给后端带来巨大压力;等到用户停止输入后再操作则更为温和,从用户视角来看效果往往也差不多。那些在每次事件发生时都重新计算布局的窗口resize和scroll处理程序是导致界面卡顿的常见原因;通过限制为固定动画帧数或间隔来处理这些操作,才能保持主线程的响应能力。核心思路并非“添加lodash”这类库,而是将意图(用户希望获得过滤后的结果)与调度(我们允许多久计算一次)分开处理。

在实现防抖功能时,应根据产品体验选择合适的延迟时间:对于频繁搜索的场景,150–300 毫秒的延迟通常就能让人感觉几乎是即时响应;而较长的延迟则更适合那些成本较高的分析信标。当新的查询取代旧查询时,应立即取消正在处理的工作,以避免延迟响应返回过时的结果。这一取消步骤与计时器本身同样重要。

超越 Map 的缓存策略

以 id 为键的内存 Map 只是最基础的一层缓存方式。HTTP 缓存会遵循 Cache-Control 和 ETags 规则,从而让重复访问时跳过数据传输。服务工作线程可以通过明确的清除规则来缓存壳层资源及 API GET 请求的结果。应用存储则可以保存标准化实体,使得列表视图和详细信息视图能够共享同一条记录。那些经过排序、过滤处理后的派生视图模型,应在输入数据发生变化时重新计算,而非在每次无关的父级内容渲染时都进行计算。

失效处理才是难点。生存时间机制简单直接。在能够控制数据写入方的情况下,基于事件的失效处理(如“用户更新了个人资料”)更为精准。混合式方法则结合较短的生存时间以及针对数据变更的显式检测机制。需明确记录哪一层负责管理哪些数据,以避免两个缓存之间出现无声的冲突。

DOM 批处理与框架协作

即使在非框架环境中,直接的 innerHTML += 循环也是一种反模式。使用文档片段、replaceChildren 方法,或一次性构建字符串,都能减少布局重算的次数。在 React、Vue 或 Svelte 中,应优先选择能让框架自动协调键值列表的状态更新方式。在渲染过程中测量 offsetHeight 会再次引发强制布局重算;应将此类测量操作放到布局效果处理中,或安排在绘制完成后执行。

CSS containment与content-visibility机制可在虚拟化处理过于繁重时限制屏幕外区域的处理能力。但它们并非万能解决方案:需在您支持的浏览器上进行测试,并留意内容被跳过时对无障碍性的影响。

主线程资源分配

应确保事件处理器的执行时间控制在帧预算范围内——在60 Hz的刷新率下大约为16毫秒,若需与其他任务共享帧,则时间还需更短。可使用requestIdleCallback或将异步生成器设计为定期输出数据的方式,将大型变换操作拆分成多个小部分处理。对于多兆字节的JSON数据,建议使用流式解析器或工作线程进行处理。可通过性能面板中的长任务跟踪功能进行分析;若某项任务的执行时间超过50毫秒,使用较慢设备的用户就会感受到明显影响。

工作线程应当有明确的所有权归属:依赖 CPU 的纯函数适用;而任何需要操作 DOM 的操作则不适用。结构化克隆的成本会随着对象图规模的增大而上升;尽可能使用 ArrayBuffers 进行传输。对于一次性处理的简单任务,创建工作线程的启动成本很重要——有时分块在主线程中处理会更高效。

网络流水线与优先级

依次使用 await 会无形中增加延迟。独立的请求应放在 Promise.all 中处理;而相互依赖的请求只需传递所需的 ID,而非整个数据负载,以此减少后续响应的大小。HTTP/2 的多路复用技术虽有帮助,但无法消除服务器的处理时间;在可行的情况下,使用能一次性返回屏幕所需数据的 API 可以减少往返次数。

优先级很重要:先展示核心内容,再处理次要组件。fetch的优先级提示、链接预加载以及空闲时调用的函数都能体现这一意图。如果成本可控,可在悬停时预加载可能用到的页面内容。应避免对用户可能永远不会打开的大型已验证数据集进行盲目预加载。

虚拟化与数据结构

虚拟列表假定行高大致一致,或依赖精确的测量缓存。对于行高不固定的情况,则需要更复杂的处理机制,但仍比渲染一万个DOM节点更为高效。应将客户端虚拟化与服务器分页或光标API结合使用,以保持内存占用稳定。当标签页被隐藏时,无限滚动应暂停,并需为辅助技术提供加载状态提示。

测量文化

实验室工具能检测持续集成过程中的功能退化,而现场工具则能反映设备的实际运行状况。当INP指标不佳时,应检查事件处理程序和耗时较长的任务,而不仅仅是网络问题。当LCP指标不佳时,需检查主图加载优先级以及服务器的响应时间。当CLS指标不佳时,应为后续加载的内容预留空间。要将每项指标对应到具体的负责人和预算,这样“性能优化工作”就不会变成无休止的对无关功能的微调。

纯函数的合成微型基准测试很难准确预测用户体验。应优先使用真实交互的日志:在搜索框中输入内容、打开模态窗口、滚动浏览密集的数据表。在提交缺陷报告时,将这些日志作为证据保存下来,以便讨论更有针对性。

产品权衡

并非每一毫秒都同等重要。对输入或拖动操作的即时反馈,远比省去每周运行一次的背景报告更有价值。有时候,最好的优化方式就是去掉那些占用带宽和精力却几乎毫无价值的功能。性能其实是通过工程手段体现出的产品决策。

将这种思维方式写在便利贴上

避免工作。推迟工作。整合工作。将工作从关键路径上移开。衡量用户的实际感受。这五句话比一百条过早的微优化措施更有用。JavaScript性能的提升不在于减少代码行数,而在于确保保留的每一行代码都能在用户设备上发挥应有作用。