首页 / 文章 / 为何微优化无法解决真正的JavaScript性能问题

为何微优化无法解决真正的JavaScript性能问题

解释了为何追求诸如代码包大小和重渲染次数等可量化指标,往往无法找到JavaScript应用中用户体验不佳的真正原因。

3977 词

从纸面上看,这个拉取请求相当不错。

程序包大小减少了18%,一些未被使用的依赖项也被移除。多个组件被加上了缓存机制,部分数组操作也被替换为据称更高效的循环结构。Lighthouse评分也有所提升,拉取请求描述中的各项指标都显示向好趋势。

团队毫不犹豫地批准了它。

两周后,用户们仍然觉得应用运行迟缓。

不是那种“基准测试显示多出几毫秒”的轻微迟缓,

也不是仪表盘上的数字问题。

而是真正会影响用户体验的那种迟缓。

他们点击按钮后不确定是否已被识别,

打开界面后只能等待有用内容出现,

切换筛选条件时,用户会看到界面短暂卡住后才恢复正常。

该团队在性能优化方面确实付出了大量努力。

只是他们的目标选错了。

这种状况在如今的 JavaScript 项目中屡见不鲜。

尽管有了更先进的性能分析工具、更快的运行时、更智能的打包工具、功能更强大的框架,以及越来越强大的浏览器,开发者们依然会陷入同样的习惯:

优化最容易测量的部分,而非用户实际体验到的部分。

JavaScript 提供了无数可以优化的对象。

某个组件重新渲染了四千次,于是你就去修复它。

一个打包文件有 300 KB,于是你就尝试缩小它的体积。

某个函数执行需要 12 毫秒,于是你就重新编写它。

某个依赖项占用了 40 KB,于是你就决定移除它。

微基准测试显示方法 A 比方法 B 快 7%。

所以你选择A。

这些做法都可能是合理的工作方式。

但它们并不会自动为使用你应用的用户带来更好的体验。

有时候,你写的最快的代码解决的只是根本没人会遇到的问题。

与此同时,缓慢的数据库查询、多余的网络请求、笨拙的加载顺序、臃肿的API数据包,或是设计糟糕的用户交互,都在悄悄耗费用户的宝贵时间。

这种不匹配才是真正值得探讨的问题。

性能与速度并非同一概念

在开发生产级系统过程中得到的一个重要启示是:性能无法用单一指标来衡量。

一个应用即便在微秒级都具备极高的计算效率,使用体验仍可能很糟糕。

想象一下按以下顺序运行的页面:

  1. 加载应用壳层。
  2. 下载多个JavaScript代码块。
  3. 启动框架。
  4. 获取配置数据。
  5. 获取当前用户信息。
  6. 获取权限数据。
  7. 获取控制面板内容。
  8. 渲染控制面板。
  9. 获取通知信息。
  10. 渲染通知内容。

每一步单独来看可能都很快。

真正的问题在于整个流程的连贯性。

用户并不关心每一部分是否都经过精心优化。

他们在意的是在内容出现之前只能面对空白屏幕。

因此,任何真正的性能分析都应从一个问题开始:

另一端的人到底在等待什么?

而不是:

如何减少这个函数的执行时间?

这些本质上是完全不同的研究方向。

一名开发者可能需要花费三小时将某个函数的执行时间从8毫秒缩短到3毫秒。

与此同时,页面却要闲置700毫秒,等待一个本就不该出现的请求。

那算不上真正的优化。

这就好比在墙后管道破裂时还在整理家具。

对5毫秒的执着

JavaScript开发者们特别热衷于微优化。

其中一些探索确实很有价值。

人们会争论使用for循环还是map()函数。

他们还会探讨内存分配策略。

他们会研究隐藏类、垃圾回收机制、闭包、解构赋值的成本、函数调用的开销以及JIT优化技术。

所有这些背后都蕴含着真正有价值的知识。

问题不在于无法理解这些机制。

问题在于在没有证据表明它们在此处重要之前就贸然使用它们。

假设在单次用户交互中某个函数被调用100次。

目前每次调用的耗时为2毫秒。

你花了半天时间将其耗时降至1毫秒。

总节省时间为100毫秒。

这或许有一定价值。

但假设同一次交互还会触发一个不必要的API调用,其处理时间需600毫秒。

删除该调用只需5分钟,就能节省的延迟是你整个下午调试工作所节省量的六倍。

说白了,这很显然。

然而代码审查时的讨论往往集中在第一种问题上,因为它们直接体现在差异对比中。

相比之下,那些不必要的网络调用可能隐藏在三层抽象之后。

这会带来一种可预见且危险的偏差:

团队最终优化的是他们能看到的代码,而非用户实际使用的系统。

浏览器并非你的功能范畴

另一个常见的错误是认为JavaScript的执行时间就能反映所有的性能状况。

事实远非如此。

基于浏览器的应用是一个完整的系统,而非单一的功能调用。

该系统包括:

  • DNS解析
  • 连接建立
  • TLS握手
  • 服务器端处理
  • 数据库查询
  • API响应序列化
  • 网络传输时间
  • HTML解析
  • JavaScript解析
  • JavaScript执行
  • 渲染
  • 布局计算
  • 绘制
  • 合成
  • 以及用户与所有这些元素的实际交互情况
  • 每一个阶段都可能影响用户感受到的响应速度。

    假设你成功将一个 React 组件的渲染时间从 15 毫秒缩短到 8 毫秒,这确实是一项不错的成果。

    但如果后端生成该组件所需数据需要 900 毫秒,那么你的改进几乎不会被察觉。

    或者服务器响应很快,但却为只需要 20 KB 的数据传输了 2 MB 的 JSON 文件,这样就会浪费 CPU 资源和带宽来处理本不应以那种形式存在的数据。

    又或者页面在能够渲染任何有意义的内容之前就先下载了庞大的客户端库,这也是同样的问题。

    这些都不是组件本身的问题,而是架构层面的问题。

    正因如此,真正的性能优化工作往往不像“让 JavaScript 运行得更快”那么简单,而更像是需要深入调查的工作。

    你需要追溯延迟的根源,无论它指向何处。

    成本最高的操作往往正是应该跳过的那个

    在考虑性能优化时,有一个有用的优先级顺序。

    加快某项操作的运行速度是个不错的结果。

    但通常直接跳过该操作才是更好的选择。

    想象以下代码片段:

    const results = expensiveTransform(items);
    

    性能分析显示,这个转换操作会消耗40毫秒。

    你的第一反应可能是对其进行优化。

    也许你会引入缓存机制。

    也许会换用更高效的算法。

    也许会把这项工作完全转移到其他地方处理。

    但在采取任何行动之前,先问自己:

    为什么首先会出现这个转换操作?

    也许只有当过滤器被调整时,输出结果才会发生变化。

    也许无论何时渲染你都在重新执行它。

    也许后端可以直接把已处理好的版本交给你。

    也许界面实际上并不需要那10,000行数据。

    也许你正在获取用户根本不会打开的数据。

    真正的解决方案可能根本不在expensiveTransform()函数内部。

    也许只需去掉那次调用即可。

    这种思维方式可以广泛应用于其他类似场景。

    跳过那些无需发送的请求。

    跳过那些始终隐藏的UI元素的渲染。

    跳过那些没人会使用的数值计算。

    跳过那些用户永远不会触发的代码路径。

    跳过那些本可以在上游就过滤掉的数据处理。

    跳过那些结果实际上并未改变的重复工作。

    最快的操作永远是根本不会被执行的操作。

    包大小并非全部

    包大小确实值得关注,这一点没有错。

    但它已成为一个被广泛追踪的指标,以至于团队有时会将其视为性能本身的代名词。

    某个团队将JavaScript包的大小减少了50 KB,便认为这是巨大的成功。

    然而在用户能够进行任何操作之前,该应用仍需依次发送六次请求。

    没错,包大小确实变小了。

    但这并不代表用户体验就一定有所提升。

    这并不是说包大小无关紧要。

    关键在于你需要弄清楚那份特定的JavaScript代码究竟在何时会被使用。

    一个会阻碍初始交互的100 KB脚本,其影响可能远大于一个300 KB、在用户每月才使用一次的功能相关脚本。

    时机很重要。

    代码的执行时间也很重要。

    它运行的设备也很重要。

    数据传输所经过的网络同样关键。

    是否有缓存也存在影响。

    同样重要的是,用户实际想要做什么也很重要。

    如果某些功能使用频率很低,在启动时预先加载所有所需资源可能并非明智之举。

    代码拆分有助于解决这个问题。

    懒加载也是如此。

    但如果你在不了解页面实际加载方式的情况下应用这些方法,它们只会变成徒有形式的仪式。

    值得思考的问题不是:

    如何进一步缩小这个代码包的体积?

    而是:

    当前这位特定用户需要什么,我们又能多快让这些内容可用?

    这样的思考方式会让你取得更大进展。

    你正在查看的组件未必有问题

    任何使用过 React 的人都熟悉这种循环。

    某个组件渲染次数超过了应有的程度。

    于是有人开始使用 useMemo

    一次多余的渲染消失了。

    所有人都感到满意。

    接着下一个组件也遭遇了同样的问题。

    不久之后,代码库中就充斥着各种记忆化调用:

    const filtered = useMemo(
      () => expensiveFilter(items, query),
      [items, query]
    );
    
    const handleClick = useCallback(() => {
      doSomething(id);
    }, [id]);
    
    const value = useMemo(
      () => ({ user, permissions }),
      [user, permissions]
    );
    

    有时这确实是正确的做法。

    但更多时候,它只是让代码更难理解,却并未真正解决问题。

    优化并非没有代价。

    尤其是记忆化,会带来实际的成本。

    它会增加内存开销、需要维护依赖数组、增加认知负担,还会为隐蔽错误提供新的藏身之处。

    在采用之前先进行评估。

    如果某项计算的耗时为0.2毫秒且很少执行,对其进行优化没有任何意义。

    但如果其耗时为50毫秒且每次按键都会触发,那就完全是另一回事了。

    重点不在于找出每一个需要重新渲染的场景。

    关键是要让那些重要的交互操作有足够的快速响应感。

    这两个目标并非完全相同。

    关注交互而非单个组件

    很多团队都需要在思维方式上做出转变才能从中受益。

    用户并不会将各个组件视为独立的单元。

    他们体验的是自己正在做的事情。

    他们会输入搜索查询。

    他们会填写表单字段。

    他们会在页面之间切换。

    他们会提交表单。

    他们会打开下拉菜单。

    他们会在标签页之间切换。

    他们会上传文件。

    他们会滚动浏览内容。

    他们会坐下来等待某些内容出现。

    而不是去问:

    这个组件的优化程度如何?

    可以试着这样问:

    在这个搜索框中输入内容时是否有即时的响应感?

    而不是:

    这个列表的渲染效率高吗?

    应该这样问:

    用户能否流畅地滚动这个列表,而不会遇到界面卡顿的情况?

    而不是:

    我们是否减少了React的渲染次数?

    应该这样问:

    点击这个按钮后,能否为用户提供即时且有意义的反馈?

    后一组问题更贴近产品真正重要的方面。

    那种让关键交互依然保持缓慢速度的“巧妙优化”,无论在代码差异对比中看起来多么优雅,都未必是真正有价值的工程解决方案。

    浏览器的网络标签页通常比你正在重写的循环代码更有效

    在修改任何循环之前,先打开浏览器的网络面板。

    这并非随便的建议。

    你常常会在那里发现比数百行手工调优的 JavaScript 代码更多的改进空间。

    注意以下情况:

    • 相同的请求被发送多次
    • 本可并行执行的请求却依次发送
    • 在尚未真正需要数据时就已发起请求
    • 响应内容远超实际显示范围
    • 本应存在却未启用的缓存机制
    • 频繁进行的非必要轮询操作
  • 充斥着没人会查看的元数据的有效载荷
  • 即使用户界面只需三个字段,接口却返回完整记录
  • 每次按键都会触发请求
  • 数据被那些根本不可见的组件获取
  • 尽管只有应用的一小部分会使用这些资源,它们仍被全局加载
  • 一个不必要的请求所带来的影响可能超过数十处微小JavaScript修改的总和。

    以搜索功能为例。

    这是一种简单的处理方式:

    User types "j"
    → request
    
    User types "ja"
    → requestUser types "jav"
    → requestUser types "java"
    → request
    

    团队可能会花费大量精力来加快结果展示的速度。

    但实际上,瓶颈可能在于应用发出了四个独立的请求,而本应只需一个即可。

    采用防抖、请求取消、缓存以及更合理的查询设计,往往能带来更大的改进效果。

    这种改进发生在系统层面,而非单个函数内部。

    不要优化错误的设备

    在自己的开发机上运行基准测试几乎无法反映应用在那些CPU性能受限、网络连接不稳定的老旧手机上的实际运行情况。

    这对JavaScript尤为重要。

    现代硬件能够高效处理大量代码,开发者往往察觉不到性能下降。

    高性能笔记本电脑可以掩盖问题。

    旗舰手机也可以掩盖问题。

    快速的办公网络同样可以掩盖问题。

    在本地开发也能掩盖问题。

    但当真正有人在网络状况不佳的廉价设备上打开该应用时,原本经过精心调优的应用就会显得运行缓慢且反应迟钝。

    这正是为什么要在真实环境下进行测试如此重要的原因。

    无需一直这样做。

    但必须足够频繁地执行,以便团队能够真正了解应用在非开发环境中的实际表现。

    正确的问题不是:

    在我的环境中,它的速度够快吗?

    而是:

    对实际使用它的人而言,它的速度够快吗?

    架构通常比微优化更重要

    这可以说是最重要的经验教训。

    架构决定了性能的上限。

    如果您的应用在显示主界面之前必须依次发起五次API调用,那么再怎么巧妙地操作数组也无法改善用户体验。

    如果每个路由都会加载整个应用包,仅仅删除少量辅助函数也无法解决根本问题。

    如果客户端下载了大量数据集,然后在浏览器中进行过滤,优化过滤逻辑的价值很可能低于修复 API 接口本身。

    如果用户的操作会清除大量缓存状态,仅通过记忆化包装组件也无法弥补有缺陷的失效策略。

    如果需要一次性渲染数千个 DOM 节点,改变使用 map() 的方式并不能解决问题。

    真正能带来改善的方法通常在于调整工作执行的地点时间,或是判断是否真的需要执行该操作

    这些属于架构层面的决策,而非代码级别的微调。

    在性能分析时我实际关注的内容

    当系统运行显得缓慢时,应避免立刻深入研究代码。

    首先重现问题。

    然后询问用户究竟在等待什么。

    从这一步开始,调查通常会按照一定的顺序进行。

    1. 加载

    在用户能够看到并使用页面的重要内容之前,需要完成哪些操作?

    追踪关键路径。

    哪些资源是真正必需的?

    哪些请求正在阻碍进度?

    有哪些不必要的内容被加载进来了?

    2. 网络

    打开“网络”面板。

    观察数据加载的顺序情况。

    连续发出的请求序列需要重点关注。

    重复的请求以及过大的数据量也同样值得注意。

    3. 渲染

    接下来,查看浏览器本身的操作情况。

    页面是否生成了大量的DOM元素?

    布局和绘制步骤的耗时是否过长?

    是否有因用户操作而产生的高成本处理任务?

    4. JavaScript

    只有到了这个阶段,函数层面的细节才会发挥作用。

    哪些操作实际上正在消耗CPU时间?

    其中哪些操作会反复执行?

    哪些操作与用户刚刚进行的操作有关?

    5. 内存

    如果应用在运行时间越长表现就越差,就需要检查内存使用情况。

    内存泄漏以及无节制的内存增长可能会被误认为是普通的运行迟缓现象。

    6. 对真实用户的影响

    最后,需要将所发现的问题的影响与实际使用体验联系起来。

    启动速度变快了吗?

    搜索响应是否更快了?

    导航功能是否有改善?

    某些工作流程是否变得更轻松了?

    如果这些方面都没有变化,那就值得怀疑是否真的找出了问题所在。

    性能预算很有用——前提是它与现实情况相关

    团队通常会设定诸如以下的规则:

    JavaScript 包的大小应控制在 300 KB 以内。

    这是个合理的起点,总比完全没有约束要好。

    但以实际使用体验为依据制定的标准往往更有用:

    • 主要内容应能快速显示
    • 搜索过程不应显得过于缓慢
    • 导航操作应能立即给出反馈
    • 关键页面不应依赖一系列连续的请求
    • 首次交互所需的 JavaScript 代码应尽可能精简
    • 大量数据不应一次性全部显示在屏幕上

    这些标准较难简化为单一的数字。

    但它们更贴近用户真正关注和在意的内容。

    只有当指标能帮助你理解实际发生的情况时,它们才有价值。

    一旦追求具体数字本身成为目标,它们就会变得危险。

    优化陷阱

    有一种微妙的心理因素促使开发者走上一条错误道路。

    优化某样东西似乎就意味着进步。

    你可以指着某个代码提交说:

    代码包大小减少了14%。

    或者指着某个基准测试结果说:

    这个函数的运行速度提升了32%。

    又或者把性能分析工具的截图交给别人作为证据。

    这些成就确实令人愉悦。

    但坦白说,一些最具影响力的性能改进其实相当乏味。

    减少不必要的API调用并不能带来令人兴奋的演示效果。

    重新设计后端响应也同样没有亮点。

    优化依赖链也算不上。

    修正那些本只需50行数据却会加载5,000行的查询也不算。

    添加缓存同样不算。

    那些你选择不去处理的任务往往本质上是看不见的。

    这也正是它们如此容易被忽视的原因。

    最佳的性能提升或许只会让你拥有更少的代码、更少的请求、更少的计算,以及更少正在运行的程序。

    可能根本没有什么值得截图的东西。

    只是应用的使用体验会更好而已。

    优化应从证据入手

    这里有一条值得普遍遵循的规则:

    不要优化代码,要优化已确认的问题。

    这并不要求你为每个发布的功能都建立复杂的性能工程流程。

    只需收集足够的证据,弄清楚时间实际上花在了哪里即可。

    利用性能分析工具。

    使用浏览器内置的性能面板。

    捕获网络请求日志。

    获取生产环境中的遥测数据。

    在合适的位置设置真实用户监控。

    在能够反映实际用户的设备上进行测试。

    最重要的是,按照用户报告的情况重现问题。

    如果相关方说“控制面板运行缓慢”,请不要急于直接查看组件代码并开始删除渲染代码。

    首先弄清楚“缓慢”具体指的是什么。

    是服务器的初始响应速度慢吗?

    是某个特定的API调用成了瓶颈吗?

    JavaScript代码包太大了吗?

    解析过程耗时过长吗?

    渲染是耗时的部分吗?

    有耗时较长的任务阻塞了主线程吗?

    数据库查询性能不佳吗?

    是否有请求堆积导致的延迟?

    浏览器是否在空闲等待本可以更早开始处理的任务?

    还是说应用在技术层面能够响应,但却无法向用户提供任何视觉反馈?

    调试性能就像做侦探工作。

    这并非比谁能删除更多JavaScript代码的比赛。

    最佳的JavaScript优化方案或许是减少JavaScript代码量

    对于JavaScript开发者来说,这听起来可能有些奇怪。

    但随着应用规模的扩大,这一点变得越来越重要。

    在客户端运行的每一行代码都会带来成本。

    它需要被下载。

    可能需要被解析。

    可能需要被编译。

    必须执行代码。

    会占用内存。

    会与浏览器争夺渲染时间。

    还可能增加用户交互时的阻力。

    这一切并不意味着 JavaScript 本身就不好。

    它意味着在客户端进行的任何计算都应有存在的必要。

    有时更好的解决方案是在服务器端进行渲染。

    有时则是采用流式传输内容而非阻塞式处理。

    有时则需要将逻辑完全移至后端。

    有时则是引入缓存机制。

    有时则需要减少 API 返回的数据量。

    有时则是逐步加载内容而非一次性全部加载。

    有时则只需移除那些实际上没人使用的功能。

    而有时,现有的 JavaScript 保持原样就已经完全没问题了。

    关键在于不要默认认为优化必须在 JavaScript 本身内部进行。

    资深开发者最终会领悟到的道理

    在开发者职业生涯的早期,优化通常意味着让现有代码运行得更快。

    随着经验积累,重点会转向消除那些本不必进行的操作。

    最终,思考会进一步深入,探究这些操作存在的根本原因。

    这是一种有意义的思维进步。

    不再问:

    这个循环能否跑得更快?

    问题变为:

    为什么要在浏览器中遍历20,000条记录?

    不再问:

    如何阻止这个组件重新渲染?

    问题变为:

    为什么这一次交互就会引发整个页面状态的变化?

    不再问:

    如何让这个包变得更小?

    问题应该变成:

    为什么用户必须先下载这些代码才能进行任何有意义的操作?

    而不是问:

    如何让这个请求更高效?

    问题应该变成:

    这个请求真的有必要吗?

    提出这些更深层次的问题才能带来真正更好的架构。

    目标并非快速代码

    这是最难真正理解的道理。

    性能优化并非要生成最快的 JavaScript 代码。

    而是要打造出对实际使用它的人来说速度足够快的产品。

    这两个目标并非同一回事。

    如果用户不得不等待两秒才能启动相关操作,那么再优秀的优化算法也毫无价值。

    即便组件已实现完美缓存,但如果页面渲染了40个用户根本看不到的组件,这也毫无意义。

    仅仅缩小代码包体积并不意味着就有好处,如果应用仍会因无意义的操作而阻碍主要交互功能的实现。

    同样的,即便基准测试数值有所提升,但如果真实用户感受不到任何差异,那也毫无意义。

    如今的JavaScript开发者可使用的优化技术比以往任何时候都多。

    正因如此,了解哪些内容不值得优化就显得尤为重要。

    首先要以用户为中心。

    找出真正的延迟点。

    对其进行准确测量。

    从起点到终点,全程追踪延迟来源。

    剔除所有不必要的操作。

    只有到那时,才能着手加快剩余部分的处理速度。

    顺序很重要。

    因为最有效的优化并不一定是技术上最令人惊叹的那一项。

    而是能让用户根本察觉不到应用程序原本就很慢的那一种优化。

    相关阅读

  • 现代 JavaScript 的真正复杂性源自工具而非语言本身 — 本文阐述了 async/await 和可选链等核心 JavaScript 特性如何简化代码,而过多的工具和依赖却会带来不必要的复杂性。
  • GA4 DataLayer 如何判断您的分析数据是否真实 — 解释了 dataLayer 如何实现网站、GTM 与 GA4 之间的数据传递,为何单纯的事件会破坏电商分析报告,以及如何正确构建和调试数据推送。