12399 字
62 分钟
【转载】前端性能优化实践

【转载】前端性能优化实践#

把一个老项目从 6.5s 拉到 3s:前端性能优化实战思路#

一、项目背景:在「性能打折机」上跑出 3 秒体验#

1.1 项目概况:一个典型但不简单的老牌 B 端平台#

这次优化的对象,是一个已经维护了约 6 年的 企业级安全服务管理平台。它是典型的 ToB SaaS 产品,用来向客户集中展示和交付我们的安全服务能力,涵盖多个核心场景:

  • 应急响应:安全事件的处置流程与进展追踪
  • 风险分析:资产风险识别、威胁态势分析
  • 运营报告:周期性安全运营报告展示与导出
  • 评估分析:安全评估结果与改进建议呈现

从技术选型上看,它是一个「技术栈不算落伍,但历史包袱很重」的前端项目,整体基于 Vue + TypeScript 构建:

  • 核心框架栈

    • Vue 2.6.12(打了若干内部补丁)
    • Vue Router 3.5.0
    • Vuex 2.3.1 + vuex-class
    • @vue/composition-api 1.3.3(在 Options API 代码中局部引入组合式 API)
  • 工程与架构特点

    • 微前端架构:多个子应用独立开发、独立部署,通过主应用聚合
    • TypeScript 强类型:核心业务模块全部在 TS 约束下演进
    • 完善的国际化:支持中 / 英 / 德多语言切换,涉及大量文案与配置
    • 严格代码规范:ESLint + Prettier + Husky,保证长期多人协作质量
    • 可视化负载高:包含大量流程图、统计图表和报表组件,对渲染性能和数据量都非常敏感
    • 构建工具现代化:前期使用 Webpack,后续迁移到 Rspack,构建性能基本可控,但历史产物体积较大

从业务生命周期看,这个项目已经经历了多轮产品方向调整和功能扩展:

功能一直在长,页面一直在变复杂,但「性能」这件事,更多时候被排在需求之后。

久而久之,「一切都能跑,就是有点慢」成了团队的默认共识。

1.2 用户环境:不是跑在理想实验室,而是跑在「性能打折机」上#

和很多技术文章里不同,这个项目的目标用户群体,是企业内网环境下的普通办公电脑。为了描述得更具体一些,我们给优化设定了一个相对统一的用户基线:

  • CPU:一类大致相当于 Intel Core2 Duo T7700 时代水平 的处理器
  • 内存:16 GB
  • 浏览器:版本在企业统一管控范围内,整体偏「够用但不激进」

如果用今天主流的中端桌面 CPU(比如近几年常见的 i5 / Ryzen 5 档)来做一个量级对比,这样一台机器大致意味着:

  • 单核性能只有现在主流 CPU 的约 1/5~1/8

同样一段 JS,在新机器上可能 100ms,到了我们的目标环境里,可能就是 500~800ms。

  • 多核综合算力差一个数量级以上

多标签、多应用并行时,前端页面能分到的「注意力」更少。

  • 内存与缓存子系统相对吃紧

大量数据渲染、大表格、复杂可视化,都更容易被感知为「卡」和「掉帧」。

换句话说,我们没法指望用户通过「换一台更好的电脑」获得性能红利,所有可感知的性能改善都必须来自前端工程本身。

这也是为什么后面看似「只是从 6.5s 压到 3s」,其实在这个硬件前提下,并不是一个轻松的小目标。

1.3 我们所说的「端到端 6.5s」到底指什么?#

在正式启动优化之前,我们先把团队口中的「很慢」变成一个可以度量的数字。

这里有一个关键前提:我们关注的是用户真实使用场景下、携带缓存情况下的端到端体验时间。

在项目中,我们把「端到端 6.5s」定义为:

用户在已有常规静态资源缓存的前提下,从浏览器地址栏输入 URL 或从系统入口点击进入该页面开始,到页面关键内容区域的数据全部渲染完成为止,所经历的时间。

AI 绘制的示意图

这里有三点刻意的限定:

  • 带缓存场景

    • 默认用户已经访问过系统一次,有基础静态资源缓存(如框架 JS、通用样式等)。
    • 我们关注的是「日常使用体验」,而不是「第一次冷启动」的极端值。
  • 完整重新加载

    • 每次统计前都会执行完整刷新,确保这 6.5s 中包含所有必要的网络请求、JS 执行和渲染流程。
  • 只看「核心内容」是否就绪

    • 页面整体结构大概可以拆成:顶部导航区、左侧导航栏、中间主内容区,以及若干辅助元素。
    • 可以想象为这样一幅示意图:
      • 整体布局框架(导航、侧栏、内容容器等)
      • 页面的核心业务内容区域(例如主要图表、关键报表、主流程卡片)
      • 悬浮工具球(例如反馈按钮、帮助入口等)
    • 在我们的定义里:当布局已稳定、核心业务内容区域全部渲染完毕时,就判定为「页面加载完成」; 工具浮球的出现与否,不计入「核心加载完成」的判定。

因此,当我们说「当前端到端大约是 6.5s」时,指的是:

  • 在上述硬件环境下
  • 在带缓存的完整刷新场景中
  • 从用户触发页面跳转开始计时
  • 一直到核心内容区域最后一块关键数据渲染完成为止

平均耗时约 6.5 秒

这个数字并不是来自某个理想实验室,而是来自我们在真实环境中多次测量与埋点统计的结果。

它既包含了网络波动、后端接口链路复杂度,也包含了前端自身的打包体积、执行时间和渲染成本。

1.4 为什么目标是「3 秒」,而不是更激进或更保守?#

在明确了「6.5s」这个起点之后,我们花了一些时间和业务方对齐预期:我们到底要快到什么程度,才算是「够用且有明显体感提升」。

综合硬件约束、项目体量和团队人力之后,我们给自己设定了这样一个目标:

  • 核心页面端到端时间压到 3 秒左右
  • 并且在 95% 以上的真实访问中,能够稳定落在 4 秒以内

这个目标有几个考虑:

  • 从体感上看

    • 6.5 秒是「明显的等待」,用户会下意识去开别的页面或看手机。
    • 3 秒以内,大多数人会认为「有点加载,但能接受」,注意力大部分还在当前系统。
  • 从工程可行性看

    • 在不推倒重来的前提下,3 秒是一个需要认真投入、但不至于完全失控的目标。
    • 再激进(比如追求 1~1.5 秒),就会强迫我们做大量架构级重写,这在一个 6 年老项目上成本极高。
  • 从长期演进看

    • 3 秒是一个可以作为「性能红线」的门槛,后续功能迭代都可以围绕这个门槛做防劣化治理。

所以,这篇文章后面所讲的所有思路和实践,都是围绕着这样一个前提展开的:

在硬件条件一般的企业用户环境下,如何用一套可复制的作战方法,把一个历史包袱不轻的 Vue 老项目,从端到端 6.5 秒,稳定压到 3 秒左右,并且尽量不再反弹。


二、作战方法:从「感觉慢」到「有指标、有路线图」的战役计划#

2.1 先看清问题:我用哪些工具观察性能?#

做性能优化之前,我先问自己三个问题:

  • 哪些页面最慢、最影响用户感受?
  • 它们慢在链路的哪一段?
  • 这些「慢」有多常见,是偶发还是常态?

为了回答这三个问题,我同时依赖两类工具:线上性能监测平台Chrome DevTools 性能面板

  • 线上性能监测平台(公司自研)

    • 持续采集真实用户的访问数据,包括首屏时间、LCP、FCP 等指标。
    • 记录网站加载过程中的瀑布流信息:静态资源请求、接口调用、长任务等关键节点,一眼就能看出一条访问链路是怎么被「拉长」的。
    • 可以按页面、时间、地域、浏览器版本等维度做拆分,帮助我们从全局上识别「问题集中区」。
    • 由于线上代码都是打包后的产物,它通常只能精确到某个 chunk 或某段长任务,很难直接定位到具体函数或业务模块,但非常适合做「大的问题定位」——比如先判断是接口拖慢了首屏,还是脚本执行和渲染占据了大头。
  • Chrome DevTools / Performance 面板(本地)

    • 在本地开发模式下启动项目,针对具体页面做深度分析:网络瀑布、主线程耗时、长任务、渲染阶段、JS 调用栈等。
    • 可以从打包后的 chunk 一路追到源码,精确到函数级,找到是哪段逻辑在不必要地占用主线程。
    • 支持反复「录制 → 调整 → 再录制」,用实验的方式验证每一项优化,是我们做细粒度定位和验证的主要工具。

可以简单理解为:线上平台负责告诉我线上真实网络情况,用户的缓存使用情况、哪块慢,Chrome 性能面板负责把当前页面的详细的加载详情告诉我、把问题精确到具体函数和渲染路径。

在这两类工具的配合下,我既能从全局上看到「哪些页面最值得优化」,也能在局部上定位「这一页到底卡在哪一环」。


2.2 度量体系:选一个大家都能对齐的「主指标」#

工具只是手段,更关键的是选什么样的指标来度量「我们到底快了没有」

我们项目使用的是公司自研的前端性能采集与展示平台,它支持:

  • 页面级的首屏时间
  • Web Vitals 相关指标(例如 LCP、FCP 等)
  • 一些自定义埋点和错误信息

在这次优化中,我没有试图把所有指标都盯一遍,而是刻意做了收敛,重点做了两件事:

  • 选一个对业务方也听得懂、能直接感知的主指标
  • 把这个主指标绑定到一组具体页面和一个清晰的目标值上。

最终,我们选择了平台提供的 「首屏时间(P75)」 作为这次战役的主指标,并且与业务方达成了一个简单明确的目标:

把划分好的 8 个高频页面,首屏时间 P75 控制在 3 秒以内

之所以选用 P75 的首屏时间,而不是平均值或极端最大值,主要有三个考虑:

  • 平均值容易被少量极快 / 极慢样本稀释,不足以代表大多数用户的真实体验;
  • 极端长尾(例如网络严重抖动)很难完全通过前端手段消除,不适合作为主 KPI;
  • 和业务沟通时,说「至少 75% 的访问能在 3 秒内看到首屏」是一个既直观又容易被接受的表达方式。

在这个基础上,我们再根据平台的数据,把系统里的页面按访问频次做了排序,和业务方一起选出了 8 个高频且对体验最敏感的页面 作为第一批优化对象。

于是,这次优化在指标层面就被浓缩成了一句话:

先把这 8 个高频页面的首屏时间 P75,从 6.5 秒拉到 3 秒以内。


2.3 首屏时间是怎么被采集出来的?#

为了让后面「3 秒」这个目标更有说服力,有必要稍微展开讲一下:我们平台里的首屏时间,是如何在前端被采集出来的。

简单来说,平台 SDK 在页面加载过程中,会做两件事:

  1. 维护一份「资源清单」

    • 页面加载时,会收集所有与首屏相关的「资源」:接口请求、静态资源、长任务(Long Task)、较长的渲染阶段等。
    • 每当有新的相关资源开始或结束时,就更新这份清单。
  2. 通过宏任务轮询,等待「最后一个资源」结束

    • 在页面加载早期,SDK 会设置一个略微延迟的「长任务」,通常在 100ms 左右开始记录。
    • 这个长任务内部会用 setTimeout 安排一个宏任务回调,用来检查当前是否仍有未完成的资源。

这里的关键点在于:setTimeout** 属于宏任务(macro task)**。

它的执行时机是:

  • 会等到当前宏任务中的同步代码执行完;
  • 再执行所有排队的微任务(micro task),例如 Promise.thenMutationObserver 等;
  • 以及在这之前已经排队好的其它宏任务;
  • 最后才轮到我们通过 setTimeout 安排的这个检查回调。

平台 SDK 正是利用了这一点:

  • 第一次检查:在页面加载后约 100ms,通过一个 setTimeout 触发,查看当前资源清单里还有多少未完成的资源。

  • 如果还有资源在进行中

    • 说明页面还在「忙碌」:可能有接口还在返回、渲染还在继续、或者有长任务在执行。
    • SDK 不会急着认定「首屏完成」,而是再通过 setTimeout 安排下一次宏任务检查。
  • 如果已经没有资源在进行中

    • 说明在这一轮事件循环中,之前排队的同步任务、微任务以及已有宏任务都处理完了,
    • 且我们关注的接口、资源、长任务和长渲染都已经结束,页面进入了相对稳定状态。
    • 此时,SDK 会把当前时间减去起始时间(通常是 navigationStart 或自定义起点),作为首屏时间上报。

用伪代码来概括,就是这样一个循环:

  • 收集需要关注的资源 →
  • 100ms 后开启首轮检查(宏任务) →
  • 如果还有资源在加载 / 执行 → 再排一个宏任务继续观察 →
  • 直到某一轮检查发现「资源清单为空」,记录这一刻的时间。

这种做法有两个直接好处:

  • 它比简单监听 onload 更接近用户所感知的「首屏可用」时刻,因为它考虑了异步请求、长任务和额外渲染;
  • 利用宏任务 / 微任务模型,可以自然地等到「一批相关工作都安静下来以后」再认定首屏完成,而不是过早结束。

因此,当我们说「把首屏时间 P75 从 6.5 秒压到 3 秒」时,指的是在这样一套采集逻辑下测出来的结果,而不是某个简单事件时间点的差值。


2.4 优先级与路线图:先救谁,后救谁?#

有了度量体系之后,接下来要解决的是一个更现实的问题:在有限的人力和时间下,先动哪一刀最划算?

我大致按照下面三个步骤来制定优化路线图:

  • 按「影响面 × 慢的程度」排序页面

    • 使用性能平台的访问量 + 首屏时间数据,把所有页面按「访问量 × 首屏时间」粗略排了一个序。
    • 排在前面的,通常就是「既慢又常用」的页面,也是这次选出的 8 个高频页面的来源。
  • 按「瓶颈类型」给页面分组

    • 结合 Chrome 性能面板,对这些页面做抽样分析: 是首屏接口慢?还是 JS 执行过重?抑或是渲染阻塞?
    • 把页面粗分成几类:网络瓶颈型、脚本瓶颈型、渲染瓶颈型、混合瓶颈型。
  • 按「成本 / 收益」决定优化顺序

    • 先做低成本高收益的优化:例如资源压缩、缓存策略、移除明显冗余依赖、拆掉不必要的阻塞脚本等;
    • 再做结构性调整:例如首屏 / 非首屏拆包、路由级分包、接口合并或降频、关键数据预取;
    • 最后再考虑高成本方案:例如局部重构、引入新的渲染模式等。

最终,这些分析被沉淀成一份简单的路线图:每一轮要覆盖哪些页面、主要针对哪类瓶颈、预期能收回多少首屏时间、需要多少测试和回归成本。

这让团队在推进时不至于「今天想到哪儿改哪儿」,而是沿着一条有优先级、有预期的路径前进


2.5 如何通过瀑布流和 Performance 面板发现性能问题?#

有了度量体系和优先级之后,落到具体页面上,问题变成了:到底是哪一段把时间耗掉了?

这一步,我主要是通过两个视角来找答案:

  • 线上性能平台的加载瀑布流:看一条真实访问链路是怎么被拉长的;
  • 本地 Chrome DevTools / Performance 面板:在可控环境下,把整条加载流程「摊平」来看。

Chrome DevTools / Performance 面板本质上也是一条「更细粒度的瀑布流」:从网络请求,到 JS 执行,到渲染和重排,整个加载过程是一张图。

我的做法很简单——就是看图说话,从几个固定角度去「挑刺」。

下图随意找的网站仅是示范,用的就是公司性能很差的设备

2.5.1 从网络面板看:有没有「不该慢」的请求?#

在网络维度,我重点会关注这几个问题:

  • 资源加载时间是否过长?

某个静态资源或者接口请求,居然要 500ms 甚至更久 才能下载回来——

这往往意味着:

  • 资源加载的内容是不是太多了?

总体下载量如果动辄 3mb** 甚至更多**,我会先问自己一个问题:

「这些内容里,有多少是首屏真正需要的?」

通常这里会暴露出:

  • 网络是否出现长时间空闲,而 CPU 在忙?

如果图上看到:一段时间内网络几乎没有新请求,但主线程却在高负载运行,

这往往意味着:

  • JS 执行和网络同时出现长时间空白?

如果同时看到「网络几乎闲着、主线程也有 50ms 以上的大片空白」,那通常说明:

线上平台的瀑布流,更多是帮我做「远程体检」:知道大概哪一块时间占比最高,再在本地用 Performance 面板做「局部 CT 扫描」。

2.5.2 从 JS 加载和长任务看:页面到底在忙些什么?#

第二个视角,是看整条 JS 加载和执行的主流程:从入口脚本开始,到首屏可用为止,这条时间线究竟被什么填满了。

Performance 面板里,我主要关注三类信号:

  • JS 长任务(Long Task)本身

    • 直接查看哪些 JS 任务单次执行超过 50ms;
    • 顺着调用栈往上翻,看是哪个模块 / 函数在占用主线程。
  • 长任务里是否掺杂了「首屏不需要」的 JS?

    • 比如在首页就完成了整站级的初始化、加载了后续路由才用到的大模块;
    • 或者一些「配置型」逻辑被放在了同步执行的入口,而实际上可以延后或懒加载。
    • 这类问题的解决思路通常是:拆包 + 延迟初始化
  • 长任务里是否包含「异常昂贵的重渲染」?

    • 有时长任务里真正耗时的不是计算,而是一段高频或大范围的 DOM 操作
    • 典型信号包括:在长任务内部频繁访问 / 修改布局相关属性,例如 document.querySelector*、获取元素宽高、频繁触发布局和回流的操作等;
    • 这类问题往往不是「多算了一点」,而是写法触发了浏览器的「强制同步布局」,导致整个渲染链路被拖慢。

2.5.3 发现问题,不是「把所有红色都涂成绿色」#

在实际优化过程中,我会刻意提醒自己:发现问题,不等于必须立刻解决所有问题。

真正需要做的是:先解决那些「严重又相对好解决」的点,而不是一头扎进那些「很难动、收益却有限」的角落。

结合这次「奔着 3 秒去」的目标,我的大致取舍是这样的:

  • 如果资源下载时间普遍偏长

    • 优先从前端能直接控制的部分下手:
      • 利用构建工具做更细的拆包,把明显与首屏无关的代码移出首包;
      • 结合业务流转,尽量把某些模块改成懒加载或按需加载。
    • 这类改动往往对代码入侵较小,但能显著减少首屏下载体积,是典型的「高收益、可控成本」。
  • 如果接口本身就要 1 秒甚至更久

    • 那么前端再怎么压缩 JS、优化渲染,最终效果也很有限。

    • 这时候,更务实的做法是:

      • 和后端一起看链路,给接口本身设一个合理的性能约束,比如控制在 300~500ms 区间;
      • 特别是那种「全局阻塞页面」的权限 / 初始化类接口,我会明确提目标:

      这类接口要么拆分 / 缓存,要么就把整体耗时压到 100~200ms 水平。

    • 在沟通时,我不会一上来就说「必须 100ms 返回」,而是结合当前硬件和链路情况,给出一个双方都认可的区间。

整体来看,性能优化从来不是前端一个人的事

前端要通过瀑布流和 Performance 面板,把问题拆解清楚;

同时也要在接口耗时、链路设计上,对后端提出清晰的性能约束和改进建议。

只有前后端站在同一套指标和目标上协同,类似「首屏 P75 ≤ 3 秒」这样的目标才真正有落地的可能。


2.6 面对历史债务:该逃的时候躲,该上的时候要敢上#

老项目做性能优化,绕不开一个现实:总会有一些历史代码实现,对性能的影响非常大,但改动起来牵一发而动全身。

很多人(包括我自己)一开始都会有这样的担忧:

  • 这块逻辑已经稳定跑了很多年了,会不会一动就出问题?
  • 关联上下游一大堆模块,改动范围这么大,值得冒这个险吗?

我最后给自己的结论是:如果这些历史债务不还,性能目标根本无法达成,那就要有勇气把它们摊开,系统性地解决掉。

一味地绕着走,只做一些边缘的小优化,最后大概率会落到一个「所有人都很努力,但指标就是上不去」的尴尬局面。

实际操作上,我会这样对待这些「动起来很吓人」的历史债务:

  • 先确认它是不是「必须还」的那一类

    • 如果通过拆包、懒加载、接口优化等常规手段,依然无法把首屏时间压到目标区间;
    • 且瀑布流和 Performance 面板一再指向同一块历史逻辑是主要瓶颈;
    • 那就意味着:不动它,目标就是达不成。
  • 把大债务拆成可以落地的小任务

    • 不要求一次性把所有问题全重写,而是先划出清晰边界,分阶段替换;
    • 能抽离的就抽离成独立模块,能隔离的就通过适配层隔离,尽量控制每一步的影响面。
  • 涉及多方上下游,就把问题上升到团队层面解决

    • 如果一块历史逻辑牵扯到了多个业务线或上下游系统,我不会一个人闷头改;
    • 而是把问题、数据和风险摊在桌面上,拉上相关角色一起评估和拆解;
    • 性能优化本质上是一场「团队级别的工程治理」,而不是某个工程师的个人战役。

对我来说,这一节想传递的只有一句话:

性能优化的路上,历史债务不是「能躲就躲」的存在,而是「实在躲不过就要敢上」,关键在于用合适的方式拆解和协同。


三、武器库:前端性能优化的关键技术点地图#

这一章我不打算把所有具体技巧都一一展开,原因有两个:

  • 一方面,网上已经有非常多写得足够细致的实践文章,例如掘金上的这两篇:

  • 另一方面,这篇文章更想回答的是:当我面对一个真实的老项目时,我脑子里的「武器地图」是什么样的,怎么从这张地图里挑选合适的武器。

所以,这里我只从几个大的方向,梳理一下我常用的性能「武器库」,方便读者在看具体资料时有一个坐标系。

3.1 按端到端链路拆:问题可能藏在哪几段?#

如果把一次页面加载看成一条完整链路,大致可以拆成四段:

  • 网络与传输层

    • 典型武器:CDN、HTTP/2/HTTP/3、多域名合并、连接复用、合理的缓存控制(强缓存 / 协商缓存)、压缩(gzip / brotli)等。
    • 思路是:先让「必须走网络的东西」走得尽可能快、尽可能少。
  • 资源体积与加载策略

    • 典型武器:代码分割、首屏 / 非首屏拆包、懒加载、按路由/按功能拆包、Tree Shaking、图片压缩与格式优化(WebP/AVIF)、字体与图标子集化等。
    • 思路是:不是所有代码都要在首屏立刻到场,把真正首要的那一小部分挑出来。
  • 脚本执行与主线程占用

    • 典型武器:减少长任务、拆分大计算、合理使用 Web Worker、避免无意义的深层响应式依赖、减少不必要的 watch / 计算属性等。
    • 思路是:主线程是最宝贵的资源,要尽量避免让它被一两个大任务「独占」。
  • 渲染与交互体验

    • 典型武器:Skeleton 骨架屏、渐进渲染(先出骨架和框架,再补细节)、虚拟列表 / 虚拟滚动、避免大规模同步 DOM 操作、动画帧优化等。
    • 思路是:即使所有数据都还没就绪,也要尽量让用户感受到「页面已经活了」。

在实战里,我通常会对照这四段来查漏补缺:这条链路里,到底是「路太远」、还是「行李太重」、抑或是「车子本身太慢」。

3.2 按「成本 / 收益」拆:先拿大锤,还是先用小刀?#

另一种更贴近日常决策的看法,是按「改动成本 × 预期收益」把武器分层,这样在做路线图时更有依据:

  • 低成本、高收益(优先级最高)

    • 典型动作:开启并正确配置缓存和压缩、移除明显的死代码和无用依赖、首屏与非首屏的基础拆包、图片体积压缩、关掉无意义的 SourceMap 等。
    • 特点:对业务逻辑几乎无侵入,工程风险相对可控,却往往能立刻看到首屏时间的下降。
  • 中成本、中高收益(第二梯队)

    • 典型动作:按路由或业务模块做更细粒度的代码分割、为核心页面重构一部分渲染逻辑、为长列表和大表格引入虚拟化、对接口做合并和预取。
    • 特点:需要和业务代码「深度握手」,但不至于推倒重来,适合作为一个个阶段性战役推进。
  • 高成本、高风险,但也是长期方向

    • 典型动作:引入 SSR / SSG 或混合渲染模式、重构全局状态管理方案、改造路由和微前端聚合方式、替换重量级依赖(例如图表库、可视化引擎)。
    • 特点:往往不是为了单一页面的那几百毫秒,而是为了整个系统在未来几年内有一个更好的性能上限。

对于这次从 6.5 秒到 3 秒的项目,我做决策时会尽量遵循一个原则:

在能靠低成本和中成本手段达成目标的前提下,尽量少动「架构级大手术」。

等到这些手段都用尽,且数据表明系统确实已经「撞到了天花板」,再去考虑引入更重的方案——这时候,高成本武器才真正值得上场。

3.3 如何把别人的「技巧清单」装进自己的武器库?#

最后,再回到一开始提到的那两篇文章:文章 1文章 2

它们列举的很多具体做法,我在这里就不重复了,更想强调的是一个使用方式:

  • 不要把这些文章当作「必须全部打勾的检查表」,而是当作一堆可以挑选的武器;
  • 先用本章的链路视角和成本视角,画出你自己项目的「问题热区」;
  • 再从这些文章里,按需选择最适合你当前阶段的问题和资源约束的那几招。

这样,别人的经验就不再是「一大堆散装技巧」,而是真正被组织进你自己这次性能战役的武器库里。


四、实战:我们是如何一步步从 6.5s 走到 3s 的#

前面讲了「作战方法」和「武器库」,这一章要回答的问题是:在真实项目中,我们到底是怎么用这些方法,把首屏时间从 6.5 秒压到 3 秒的?

我会按优化方向的方式来组织,每个方向都明确:主要针对哪类瓶颈、采用了哪些思路、最终收回了多少时间。 这样你就能看到,不是一次性地把所有优化技巧都堆上去,而是分方向、有节奏地推进


4.1 基础设施层优化#

基础设施层优化的目标很明确:先做那些「改动成本低、但收益明显」的优化,把能快速拿到的红利先拿到手。

4.1.1 网络与传输层:升级协议、启用 CDN、优化缓存策略#

通过性能平台的瀑布流分析,我们发现:首屏时间里有相当一部分,花在了静态资源的网络传输上。

针对这个问题,我们做了以下几类改动:

  • 升级到 HTTP/2

    • 利用多路复用、头部压缩等特性,减少连接开销和传输时间。
  • 启用 CDN

    • 将静态资源下沉到 CDN 节点,缩短用户到资源的物理距离。
  • 缓存策略调整

    • 除特殊文件(如 HTML、配置类 JSON)外,所有静态资源统一使用强缓存
    • 这样在用户二次访问时,大部分资源可以直接从本地缓存读取,网络请求大幅减少。
  • 启用 Brotli 压缩

    • 相比传统的 Gzip,Brotli 在文本类资源(JS、CSS)上压缩率更高,能进一步减小传输体积。
  • 图片格式统一迁移到 WebP

    • 在保证视觉质量的前提下,将项目中的图片资源尽可能替换为 WebP 格式,减小图片体积。

这部分优化,主要解决的是「网络传输时间」这个瓶颈,通过减少资源下载时间和提升缓存命中率,显著改善了首屏加载速度。


4.2 资源体积与加载策略的结构性调整#

基础设施层优化主要解决的是「传输效率」问题,但如果我们继续看瀑布流,会发现另一个明显的问题:首屏加载的资源总量依然偏大,且很多资源其实并不需要在首屏就加载。

这部分优化,我们把重点放在了「让首屏只加载首屏真正需要的东西」上。

4.2.1 代码拆分与按需加载:从路由到模块的精细化控制#

  • 路由级按需加载排查

    • 逐个路由模块检查,确保每个路由都使用了动态 import(),而不是在入口就把所有路由代码都打包进来。
    • 这一步看似基础,但在老项目里,往往会有一些「历史遗留」的路由没有做懒加载。
  • Webpack 代码拆分策略优化

    • 在构建层面,我们根据项目实际模块划分,做了更细粒度的拆包:
      • 防止出现单个 chunk 过大(> 500KB),导致首屏下载时间过长;
      • 同时避免单个 chunk 过小(< 1KB),造成请求数量过多、反而增加网络开销。
    • 目标是让每个 chunk 的体积落在合理区间,既保证并行下载效率,又不会让某个 chunk 成为明显瓶颈。
  • 模块联邦(Module Federation)子应用体积控制

    • 我们的项目是微前端架构,子应用通过模块联邦的方式加载。
    • 在优化过程中,我们做了两件事:
      • 控制每个子应用的体积,避免单个子应用过大;
      • **没有用到的子应用,不加载其 **remoteEntry,减少不必要的网络请求和初始化开销。
  • NPM 包 Tree-shaking 排查

    • 在瀑布流中,如果发现某个 chunk 体积异常大,我们会排查其中引入的 NPM 包:
      • 是否支持 Tree-shaking?
      • 是否只引入了包的一小部分功能,却把整个包都打进来了?
    • 对于不支持 Tree-shaking 或引入方式不当的包,要么替换为更轻量的替代方案,要么调整引入方式,只引入真正需要的部分。

4.2.2 一个典型案例:字体图标从 966 个瘦身到 574 个#

在排查资源体积时,我们发现了一个典型的「历史债务」问题:项目里使用的字体图标库,经过 6 年的积累,已经膨胀到了 966 个图标,对应的 JS 体积达到 1MB,且还有约 100ms 的加载和初始化耗时。

这个问题如果不解决,会持续拖累首屏性能。但直接全部删除又怕影响业务,所以我们采用了更务实的做法:

  • 和设计团队一起做「图标审计」

    • 逐个图标检查,确认哪些图标在当前版本中完全没有被使用;
    • 最终删除了 392 个完全未使用的图标,保留 574 个实际在用的图标
  • 优化图标使用方式

    • 移除了 SVG 中的 symbol 重用机制(通过 <use> 引用定义好的图标)的使用方式;
    • 这种方式虽然能减少代码重复,但在一次性插入大量图标时,会触发频繁的 DOM 操作和重渲染,导致明显的性能开销。
    • 改为更直接的使用方式后,解决了「一次性插入渲染耗时」的问题。

这个案例想说明的是:有时候性能优化不是「加新东西」,而是「减掉那些已经不再需要的东西」。 但减之前,需要和业务方、设计方对齐,确保不会影响功能。

这部分优化后,主要通过减少首屏需要下载和执行的代码量,显著改善了首屏加载速度。


4.3 脚本执行与主线程优化#

前面主要解决的是「资源下载」和「资源体积」问题,但如果我们继续用 Chrome Performance 面板深入分析,会发现另一个瓶颈:主线程被长任务占用,导致页面响应变慢。

4.3.1 通过 Performance 面板定位长任务#

在 Performance 面板中,我们重点关注两类问题:

  • 发现使用不当的函数导致长任务

    • 通过调用栈分析,定位到某些函数执行时间过长,超过了 50ms 的长任务阈值;
    • 这类问题通常是因为:
      • 同步处理了过大的数据集;
      • 在循环中做了昂贵的 DOM 操作;
      • 或者调用了某些「看起来无害、实际上很重」的第三方 API。
    • 解决思路通常是:异步化、分批处理、或者用更轻量的实现替代
  • 发现 chunk 加载过程中引入了不需要的函数代码

    • 某些 chunk 在加载时,会执行一些「全局初始化」逻辑,但这些逻辑里可能包含了当前页面根本不需要的功能;
    • 这类问题可以通过两种方式解决:
      • 业务编码层面的懒加载:把某些初始化逻辑延后到真正需要时才执行;
      • 代码拆分:把「全局初始化」和「页面特定逻辑」拆成不同的 chunk,避免首屏加载时执行不必要的代码。

这部分优化的核心思路是:让主线程在首屏加载阶段,只做首屏真正需要做的事,其他事情能延后就延后。


4.4 渲染与交互体验优化#

前面主要解决的是「加载时间」问题,但用户感知到的「快慢」,不仅包括加载时间,还包括页面渲染的流畅度。 特别是在我们这种「硬件条件一般」的用户环境下,任何一点渲染开销都会被放大。

4.4.1 移除昂贵的视觉效果#

  • 移除 SVG 路径动画

    • 在某个高频页面上,我们发现了一个 SVG 路径动画效果;
    • 虽然视觉效果不错,但在性能较差的机器上,这个动画会持续占用 CPU 进行计算;
    • 移除后,该页面的 CPU 计算负载下降了约 30%
  • 移除 CSS 高斯模糊(filter: blur()

    • 高斯模糊是一个非常昂贵的 CSS 效果,特别是在低端设备上;
    • 在某个高频页面上,我们发现使用了高斯模糊样式,导致帧率从 60 掉到了 40 左右;
    • 移除后,帧率重新稳定到 60 左右,页面滚动和交互明显更流畅。

这两个案例想说明的是:在硬件条件受限的环境下,要避免使用那些「看起来很酷、但计算成本很高」的视觉效果。 对于 ToB 产品来说,功能可用性和流畅度,比视觉效果更重要

CPU & 内存#

FPS 显示#

4.4.2 虚拟滚动:让大表格在低端设备上也能流畅#

ToB 业务中,表格是高频组件,且往往列数多、每页数据量大。 在性能较差的机器上,如果一次性渲染 100 条数据、每行 40 列,很容易出现明显的卡顿。

我们的解决方案是:

  • 使用支持虚拟滚动的表格组件

    • 只渲染可视区域内的行,减少 DOM 节点数量,降低渲染压力。
  • 针对极端性能环境,做「完全不滚动、仅切数据模拟滚动」的特殊处理

    • 在性能极差的机器上,即使虚拟滚动也可能不够流畅;
    • 我们做了一个更激进的优化:完全不滚动,而是通过切换数据来模拟滚动效果
    • 这样虽然交互方式略有变化,但保证了在 100 条/页、每行 40 列的场景下,依然能实现流畅的「滚动」体验。

这个案例想说明的是:有时候,为了在极端环境下保证可用性,我们需要在交互方式上做一些妥协,而不是一味追求「完美的视觉效果」。


4.5 业务层优化:去除串行加载逻辑#

最后,我想特别强调一个在实战中经常被忽视,但影响很大的问题:业务代码中的串行加载逻辑。

很多老项目里,会存在这样的代码模式:

_// 伪代码示例_async function loadPage() {
const data1 = await fetch('/api/step1'); _// 等接口1返回_const data2 = await fetch('/api/step2'); _// 等接口2返回_const data3 = await fetch('/api/step3'); _// 等接口3返回// 最后才渲染页面_
}

这种「一个接口等另一个接口」的串行逻辑,会把总耗时累加起来。

如果三个接口各需要 500ms,串行就是 1500ms;但如果改成并行,可能只需要 500ms(取最慢的那个)。

在优化过程中,我们重点排查了:

  • 哪些接口之间其实没有依赖关系,可以并行请求?
  • 哪些接口可以提前发起,而不是等前一个接口返回后再发起?
  • 哪些「看起来必须串行」的逻辑,其实可以通过数据预取、缓存、或者调整接口设计来优化?

这一条优化,往往能带来非常明显的收益,因为它直接减少了「等待时间」的总和。 但它的难点在于:需要深入理解业务逻辑,知道哪些数据可以并行获取,哪些必须按顺序。


4.6 优化成果总结#

通过以上各个方向的优化,我们最终基本达成了「首屏时间 P75 ≤ 3 秒」的目标。

需要说明的是,实际优化过程并不是严格按照「先做完 A 再做 B」的顺序推进的,而是各个方向结合实际情况并行或交替进行:

这个表格想说明的是:性能优化不是「一次性把所有技巧都用上」,而是分方向、有节奏地推进,每个方向都针对当前最明显的瓶颈下手。 但实际执行时,往往是多个方向的优化工作同时进行或交替推进,而不是严格按顺序「做完一个再做下一个」。


五、防劣化:让「3 秒体验」变成系统能力,而不是一次性项目#

性能优化最怕的是什么?不是「一次优化没做好」,而是「好不容易优化到 3 秒,结果过几个月又回到 6 秒」。

这就是为什么我们需要「防劣化」机制:把性能优化从「一次性工程」变成「持续的系统能力」。

理想情况下,一个完善的防劣化体系应该包括:

  • 线上监控与告警:实时监控首屏时间、LCP 等关键指标,一旦劣化立即告警
  • CI/CD 集成:在构建流程中加入 bundle 体积检查、Lighthouse 分数阈值等
  • 代码评审规范:在 Code Review 中加入性能相关的检查清单
  • 定期性能体检:每个季度或每个版本,做一次全面的性能回顾

但现实是:我们目前在这方面的投入还比较有限,防劣化机制相对简单。 这一节,我会如实说明我们目前做了什么,以及为什么这样做,而不是列出一堆「理想但还没落地」的方案。


5.1 用 AI 提示词固化「异常代码写法」的检查#

在性能优化过程中,我们发现了一些典型的「会导致性能问题的代码写法」,例如:

  • 在循环中频繁访问 DOM 或触发布局计算
  • 串行加载多个无依赖关系的接口
  • 在首屏就加载了非首屏需要的模块
  • 使用了昂贵的 CSS 效果(如高斯模糊、复杂动画)但未做降级处理

为了避免这些写法在后续开发中「卷土重来」,我们做了一件事:把这些异常写法整理成 AI 提示词,在代码评审时让 AI 辅助检查。

具体做法是:

  • 把常见的性能反模式整理成结构化的检查清单
  • 在 Code Review 阶段,用 AI 工具(例如 Cursor、GitHub Copilot 等)扫描变更的代码
  • AI 会基于这些提示词,识别出可能存在性能风险的代码片段
  • 评审者再结合 AI 的提示,重点审查这些片段

这种做法有两个好处:

  • 成本低:不需要搭建复杂的自动化工具链,只需要维护一份提示词清单
  • 覆盖面广:AI 可以检查各种「看起来无害、实际上有性能隐患」的写法,而不仅仅是体积、加载时间这些可量化的指标

当然,AI 提示词也不是万能的,它更多是作为「辅助提醒」,最终的判断还是要靠人的经验。 但至少,它能让团队在 Code Review 时,有意识地关注性能问题,而不是完全依赖「事后发现再修复」


5.2 代码评审:人工把关的最后一道防线#

AI 提示词可以帮我们发现「明显的反模式」,但很多性能问题其实隐藏在业务逻辑里,需要结合上下文才能判断。 这时候,人工的 Code Review 就是最后一道防线

在我们的 Code Review 流程中,虽然没有强制要求「必须检查性能」,但我们会重点关注几类变更:

  • 新增或修改了路由/页面组件:是否做了按需加载?是否引入了过大的依赖?
  • 新增或修改了接口调用:是否存在串行加载?是否可以并行化?
  • 新增或修改了渲染逻辑:是否会导致大量 DOM 操作?是否触发了不必要的重渲染?
  • 新增或修改了视觉效果:是否使用了昂贵的 CSS 效果?在低端设备上是否有降级方案?

这些检查点,更多是靠评审者的经验和意识,而不是硬性的工具约束。 但至少,它能让团队在代码合并前,多一层「性能视角」的审视。


5.3 上线前性能测试:用真实环境验证是否劣化#

前面两节主要解决的是「预防问题」,但有些性能问题只有在真实环境下才能暴露出来。 因此,我们在上线前的系统测试阶段,会专门跑一轮性能测试

具体做法是:

  • 使用和线上相同的性能监测工具:确保测试环境和线上环境的度量口径一致
  • 使用和线上相同的数据量级:例如,如果线上某个页面平均有 100 条数据,测试时也用 100 条数据,而不是用 10 条数据「象征性地测一下」
  • 对比基准数据:把本次测试的结果,和之前建立好的「性能基线」做对比
  • 如果发现劣化:会提 bug 给对应的模块看护人,要求排查和修复

这个流程虽然简单,但有两个直接的好处:

  • 能在上线前发现问题:而不是等用户反馈「怎么又变慢了」才发现
  • 有明确的负责人:每个模块都有看护人,性能劣化会直接落到具体的人头上,而不是「大家一起背锅」

当然,这个流程也有局限性:

  • 测试覆盖可能不全:可能只测了主要页面,一些边缘场景没测到
  • 数据量级可能不够真实:虽然尽量模拟线上,但真实用户的网络环境、设备性能等很难完全复现
  • 发现问题时可能已经临近上线:如果劣化比较严重,可能需要紧急修复,影响发布节奏

但至少,它能在大部分情况下,在上线前拦截明显的性能劣化


5.4 未来可以加强的方向#

虽然我们目前的防劣化机制相对简单,但至少建立了一个「有意识关注性能」的基础。

未来,如果团队有更多资源投入,可以考虑加强的方向包括:

  • 接入线上性能监控和告警:实时监控首屏时间、LCP 等指标,一旦超过阈值立即告警
  • 在 CI/CD 中加入自动化检查:例如 bundle 体积上限、Lighthouse 分数阈值等
  • 建立更完善的性能测试体系:覆盖更多场景、更接近真实用户环境
  • 定期性能体检:每个季度或每个版本,做一次全面的性能回顾,识别新的瓶颈

但这些都需要额外的工具链投入和团队时间投入,不是「必须现在就做」的事。 对我们来说,先把现有的三点做好、做扎实,比列出一堆「理想但没落地」的方案更有价值


5.5 小结:防劣化是一个「持续演进」的过程#

这一节想传递的核心观点是:

  • 防劣化不是「一次性搭建完美体系」,而是「在现有资源下,先做能做的事」
  • 即使机制简单,也比「完全没有机制」要好
  • 随着项目演进和团队资源增加,再逐步完善防劣化体系

对我们来说,从「完全没有防劣化」到「有基础的防劣化机制」,已经是很大的进步了。 未来,我们会根据实际情况,逐步加强和完善这套机制。