前端性能优化:一次 325 字节超标后的客户端瘦身复盘
从启动代码预算、按需加载、稳定 vendor chunk、事件驱动刷新到初始化请求合并,整理一套不靠调高预算的客户端优化思路。
最近一次客户端发布,在构建阶段被拦了下来。
登录后 App 的启动代码上限是 1,600 KiB,当时构建出来是 1,638,725 字节。换算下来,只比上限多了 325 字节。
如果把预算往上调一点,这次发布很快就能继续。但客户端就是这样一点点变重的:每个功能都只多一点,等真正感觉到慢时,已经很难说清那些代码为什么必须在首屏。
我们没有改预算,而是回头检查这 325 字节是怎么进来的。最后保留了完整的加载状态和可访问性,构建报告回到 1,600.0 KiB,gzip 后是 444.8 KiB。
这件小事也让我重新梳理了前端性能优化的顺序:先控制首屏必须付出的成本,再减少页面持续运行时的重复工作。
别只看一个入口文件有多大
说到前端体积,很容易只看 app.js 有多大。但浏览器启动应用时,还会沿着它的静态依赖继续加载 React、状态库、实时通信客户端和其他共用模块。只称入口文件,就像只称行李箱,不称里面的东西。
所以我们现在检查的是完整启动图。构建脚本会从 App 入口和 React DOM 渲染入口出发,遍历首次加载必然到达的文件,再分别统计原始体积和 gzip 体积。
当前这道门有三个数字:
| 检查对象 | 上限 | 它防的问题 |
|---|---|---|
| App 入口 | 160 KiB | 启动协调层本身不断膨胀 |
| 完整启动图原始体积 | 1,600 KiB | 下载后仍有过多代码需要解析和执行 |
| 完整启动图 gzip 体积 | 480 KiB | 首次网络传输过大 |
这三个数字不能互相替代。gzip 很小,不等于解压后的代码不需要解析、编译和执行。首次加载也不等于回访,因为回访还会受浏览器和 Service Worker 缓存影响。
体积预算不是用来表明“页面一定快”。它的作用更具体:防止一次看似无害的 import,把一整套编辑器或图表代码偷偷带进启动路径。
按“什么时候需要”拆代码
找到启动图以后,下一步不是把所有模块都改成懒加载。切得太碎会增加请求、加载状态和故障边界,最后可能只是把一个大包变成一地小包。
我们用的分界很简单:用户现在不会用到的功能,不进入启动图。
| 首次打开 App | 进入对应场景后 |
|---|---|
| 认证、多语言、导航、错误与加载外壳 | Dashboard 和其他业务路由 |
| 当前页面必须的共用运行时 | 第一次打开时才加载的 App Agent |
| 实时连接的最小客户端 | 第一次使用时才加载的全局搜索 |
| 图表、Markdown、代码与文档编辑器 |
业务路由都是独立的动态 import。App Agent 和全局搜索只在用户第一次打开时下载,加载后可以保持挂载,避免每次开关都重新初始化。图表、Markdown 和编辑器等大依赖也保持在可选边界之外。
懒加载不应该等到点击后才开始手忙脚乱。用户用鼠标指向或用键盘聚焦一个导航入口时,可以提前请求对应的路由代码。这一步只预取静态代码,不读用户数据,也不提前触发业务操作。
这样做的目标不是使首屏体积无限接近零,而是让用户此刻需要的代码尽快到达,下一步大概会用到的代码适时准备。
让业务改动少让用户重新下载东西
代码拆开后,还要看缓存能不能真正复用。
如果每次修改业务代码,都让 React、Zustand、Immer、Zod 和 Socket.IO 这些稳定依赖换一个 URL,用户就得重新下载它们。依赖没变,缓存却失效了。
我们把这些公共依赖编译为独立、版本化的 vendor chunk。URL 同时包含依赖版本和内容哈希。只改业务代码时,vendor 文件的 URL 和字节都应该保持不变;真正升级依赖时,新内容才会得到新地址。
这个边界还要确保页面和可选模块共用同一个 React 和状态库实例。不然 chunk 是拆开了,运行时反而装了两份。这不只增加体积,还可能带来 Hook 和状态上下文问题。
所以,“分包”和“复用”不是两个独立目标。好的分包既减少首次加载,也让没变的代码可以跨发布继续命中缓存。
一个新加载状态,不一定需要一套新代码
这次 325 字节超标,直接原因是桌面登录回调需要一个新的“处理中”效果。它需要防止重复点击,保持按钮尺寸稳定,向辅助技术报告状态,并在用户关闭动画时保持清楚。
这些是真实的交互要求,但不代表必须新增一组包装组件和动画节点。最后的方案是复用按钮里已有的前导图标,用 CSS 脉冲表达等待,保留尾部操作位置,同时收紧状态订阅代码。
这不是说 CSS 永远比 JavaScript 好。更有用的判断是:新交互有没有引入新的业务状态?如果只是已有状态的视觉表达,先看现有 DOM 和样式能否完成,不要为一个新效果建第二套状态树。
首屏之后,继续减少请求数量
代码加载完成不代表前端性能工作结束了。页面打开以后,请求次数与刷新方式同样会影响速度、功耗和服务器压力。
首先是初始化请求。Dashboard 和市场页面打开时,通常要读当前用户、设置、通知、自选、分组和页面数据。如果每项都单独发请求,同一次身份检查和连接开销会被重复多次。
我们保留每个业务读取的独立结果和错误,但允许一次受限的初始化快照携带 1–12 个已批准读取,整体只做一次用户准入。同时发生的页面读取可以进入一个很短的批次,K 线这类大型流式数据则继续保持独立,不把“合并请求”做成一个无限长的大包。
服务端已经渲染出来的首屏数据,前端水合时只消费一次。如果用户恢复的页面对象与服务端快照不一致,才重新读取当前对象。这样可以避免“服务端刚查完,浏览器上来又查一遍”。
然后是轮询。订单页面曾经每五秒读一次状态,其他页面也存在定时刷新或断线后改用 HTTP 轮询的路径。这些请求大多数时候只会得到“没变”。
现在的规则是:初次打开时读取,用户操作后读取,收到与当前资源匹配的事件后读取,连接恢复后再对账一次。同一资源短时间内连续到达的提醒会被合并,已离开的页面不发新请求,迟到的返回也不能覆盖新状态。
事件只是“该刷新了”的提醒,不是最终数据。这条边界很重要:它让页面可以停止轮询,又不会在丢失一条实时消息后永久停在错误状态。
把性能预算放进日常构建
前端优化最难的部分,通常不是找到一个技巧,而是防止同一类问题慢慢长回来。
我们把下面几件事变成了构建和回归的一部分:
- 沿静态 import 检查完整启动图,而不是只看入口文件。
- 明确禁止图表、Markdown、编辑器、Dashboard 和 App Agent 等可选模块进入启动图。
- 验证业务改动不会改写稳定 vendor 文件,也不会装入两份 React 或状态库。
- 用浏览器时间前进测试证明,没有事件时不会发生业务轮询。
- 体积超标就让构建失败,不在发布时临时调高数字。
这套思路不会自动让每个页面都快。图片解码、复杂表格、长列表渲染和慢数据源,仍然需要各自的测量和优化。它解决的是一个更基础的问题:让客户端新增功能时,不要默认把成本留给每一个用户。
开头那 325 字节最后是怎么省下来的?不是靠一个神奇的压缩参数,而是删掉不必要的表达,把已有能力用得更完整。
客户端瘦身很少有一刀见效的方法。真正有用的是,每次多一点时,都能有一道门问清楚:这一点,真的需要现在就让用户下载吗?