40秒到1秒:我如何将首屏加载优化了4000%

100 阅读19分钟

起因

新版本(v1.10.0)上线后,页面白屏近40s后才成功渲染展示,严重影响用户体验

何为白屏?
  • 前端白屏是指用户打开网页时,页面未能正常加载或渲染,导致浏览器显示一片空白。
  • 一般情况下 是由 JS执行错误 / 资源加载失败 / 网络问题 / 渲染逻辑错误 引起的。
  • 在单页面应用中(SPA),前端白屏问题会变得更加复杂,可能导致用户无法看到任何有效内容。

白屏问题本质上是浏览器渲染流水线的断裂,从 DNS 解析 -> 资源加载 -> JS 执行 -> DOM 构建 -> 渲染树生成 -> 页面绘制的完整链路中,任一环节的异常都可能导致最终呈现的空白。

排查

1. 问题现象

  • 核心表现:页面加载约 40 秒 后才成功渲染并展示内容。
  • 初步判断:长达 40 秒的白屏后页面仍能正常渲染,证明浏览器渲染流水线(DOM 构建、样式计算、布局、绘制等)本身是通畅的。问题根源极大可能出现在流水线启动前的某个阻塞环节

2. 问题定位与分析

2.1 性能面板分析:锁定瓶颈阶段

通过 Chrome DevTools 的 Performance 面板录制并分析页面加载过程,我们获取了以下关键性能指标:

  • FCP(首次内容绘制): 41.53 秒
  • LCP(最大内容绘制): 41.66 秒

关键发现

  1. FCP 与 LCP 时间极高,均超过 40 秒,远超良好体验的标准(FCP < 1.8秒, LCP < 2.5秒)。
  2. 两个指标的时间点非常接近,这表明页面从开始显示一点内容(FCP)到主要内容完全渲染出来(LCP)之间几乎没有间隔。
  3. 在 LCP 的耗时分解中,网络传输阶段耗时约 40 秒,占据了总时间的绝大部分。

初步结论:FCP 与 LCP 的异常数据共同将问题指向了浏览器渲染前的阶段。由于关键资源(JavaScript)未能及时加载,导致 DOM 内容构建被严重延迟,进而使得 FCP 和 LCP 这两个关键指标同时出现灾难性的数值。网络资源加载过慢是导致本次长时间白屏的直接元凶。

2.2 关键性能指标:FCP 与 LCP

在本案例中,我们选用 FCPLCP 这两个指标共同作为评价首屏性能的核心依据。

  • FCP(首次内容绘制):衡量浏览器首次绘制出任何来自 DOM 的内容(如文本、图片等)的时间点。它是“白屏阶段结束”的标志。本次高达 41.53 秒的 FCP,意味着用户等待了超过 40 秒才看到页面脱离完全空白的状态。
  • LCP(最大内容绘制):衡量视窗内最大的内容元素(如主图、标题块等)完成渲染的时间。它是“主要内容加载完成”的标志。本次 41.66 秒的 LCP,表明页面的核心内容也因同一瓶颈而被极度延迟。

综合分析:FCP 与 LCP 双双异常,且时间几乎重合,这构成了一个典型的 “资源加载阻塞渲染” 的特征。这说明有一个关键资源(极可能是 JavaScript)的下载和解析执行,严重阻塞了整个页面的渲染进程。

2.3 网络面板分析:定位具体资源

为进一步验证,我们打开 Network 面板进行网络请求分析。

发现:如下方示意图所示,生产环境的所有前端代码(包括第三方库和业务代码)都被打包成了一个巨大的 JavaScript 文件(单一 Chunk)。该资源文件的下载时间就接近 40 秒

3. 初步结论

综合性能面板与网络面板的分析,确定本次首页长时间白屏的主要原因是:前端资源打包策略不合理,导致生成的单一 JavaScript 文件过大,进而使得 “资源加载”环节成为性能瓶颈,严重拖慢了 FCP 时间,最终造成用户体验差的白屏问题。

决策

当前问题的根源在于将所有代码打包成单个大体积资源文件,导致加载时间过长。

与简单地将大文件均等拆分相比,更优的解决方案是代码分割(Code Splitting)。其中,懒加载(Lazy Loading) 是核心策略之一,它允许我们按需动态加载代码,而非一次性加载所有资源。

从实现原理看:通过合理的代码分割策略(如基于路由或组件拆分),可以将原本庞大的拆分为多个较小的 chunk。这样,当用户访问特定页面或触发特定交互时,浏览器只需加载当前所需的模块,而非整个大文件。

这种方案的优势在于:

1. 减少初始加载时间首屏仅加载必要代码,提升用户体验。
2. 避免传输和执行未使用的代码,节省带宽和计算资源。
3.充分利用并发请求,在 HTTP/1.1 下,浏览器可并行加载多个小文件,而非阻塞式下载单个大文件。

那么接下来的关键问题是:如何制定合理的代码分割策略?

Vite是如何拆分的?

Vite 的代码分割(分包)主要基于以下原则和单位:

  1. 入口点分割
  • 每个 HTML 入口文件自动作为一个单独的 chunk【这是最基础的分割层级】
  1. 动态导入分割【懒加载】
// 1.动态导入的模块会自动分割成独立的 chunk
const module = await import('./path/to/module.js');
// 2.路由级别懒加载,每个页面成为独立chunk
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
// 3.组件级别懒加载
// 4.大型图表组件懒加载
const Chart = lazy(() => import('./components/Chart'));
// 5.富文本编辑器懒加载
const Editor = lazy(() => import('./components/Editor'));

懒加载前提的实现:ES6的动态地加载模块——import()

调用 import() 之处,被作为分离的模块起点,意思是,被请求的模块和它引用的所有子模块,会分离到一个单独的 chunk 中\ ——摘自《webpack——模块方法》的import()小节

  1. 依赖包被多个chunk共享
    1. 多页面应用场景
// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      input: {
        main: resolve(__dirname, 'index.html'),
        admin: resolve(__dirname, 'admin.html'),
        mobile: resolve(__dirname, 'mobile.html')
      }
    }
  }
})
//如果三个页面都使用了 react,Vite 会自动将 react 提取到共享的 vendor chunk 中:
main.js    -> 页面1的业务代码
admin.js   -> 页面2的业务代码  
mobile.js  -> 页面3的业务代码
vendor.js  -> 共享的 react 代码
2. **<font style="color:rgba(0, 0, 0, 0.9);">代码分割+共享依赖</font>**
// 路由懒加载场景
const Home = lazy(() => import('./pages/Home'));     // 使用antd
const About = lazy(() => import('./pages/About'));   // 使用antd  
const Contact = lazy(() => import('./pages/Contact')); // 使用antd

Vite 检测到 antd 被多个异步chunk使用,会自动提取:
Home.js    -> 首页业务代码
About.js   -> 关于页业务代码
Contact.js -> 联系页业务代码
antd.js    -> 共享的antd组件库

  1. 手动配置分割
// vite.config.js
export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          // 将 React 相关库合并到同一个 chunk
          'react-vendor': ['react', 'react-dom'],
          // 将工具库合并
          'utils': ['lodash', 'axios']
        }
      }
    }
  }
}

当前拆分现状

当前项目所有依赖都在唯一一个入口中 (main.tsx)使用

  1. dist/index.html (0.44 kB)

作用: 应用的入口HTML文件

来源:根目录的 index.html 模板生成

  1. dist/assets/index-Hl7Y9d9v.css (86.19 kB)

作用: 所有样式文件的合并和压缩版本

  1. dist/assets/Runtime-ClaFJL1B.js (94.38 kB)

来源: LocatorJS 插件的chunk

为什么它会单独打成一个chunk

Vite 自动识别出这是开发工具依赖

但是这个文件应该被打包进生产环境吗?

4. `dist/assets/index-ByN0ITNo.js` (19,045.60 kB ≈ 19MB)

作用: 完整的应用代码包

小知识:文件名中的哈希值作用
  • index-ByN0ITNo.js 中的 ByN0ITNo 内容哈希
  • 缓存控制: 内容变化时哈希值变化,浏览器会重新下载
  • 缓存优化: 内容不变时哈希值不变,浏览器使用缓存
**结合上面Vite分包的原则,我们可以知道当前实际分包情况(没有做任何优化)也是符合上面讲的内容的。**

尝试多种拆分

粗粒度——路由级别懒加载

代码改变

打包情况变化

改动前:

改动后:

文件类型原方案新方案原因
HTML单个入口文件同原方案SPA应用,单一入口文件
CSS单个CSS文件两个CSS文件1. 首屏渲染所需的关键CSS通常会被内联或打包到主CSS文件中。
2. 当懒加载组件需要特定样式时,这些样式可能会被提取到单独的CSS文件中。
3. 为什么所有的样式都加载到一个css文件中呢?——因为该项目的引入css样式是直接通过import"reactflow/dist/style.css"; 这种全局样式导入方式会导致构建工具将这些样式打包到主CSS文件中,而不是按组件分割。
JS两个JS文件五个JS文件1. Runtimexxx.js文件——没变,LocatorJS 插件的chunk
2. index-BJvm1nkA.js——这是应用程序的主入口文件,包含以下内容:
+ 全局共享的工具函数和常量
+ 路由配置和主应用组件(App.tsx)
这个文件是所有页面都会加载的基础文件。
3. index-BccyVXYP.js ——这个文件主要包含ApiManagement页面(/web路由)的相关代码
4. HelpButton-BtF9E7yc.js——这个593KB的chunk主要包含:
+ 整个Antd组件库 (大部分样式和组件逻辑)、React相关库
+ 工具函数和共享代码、各种图标和UI组件
为什么会这样分配?
Vite的依赖分析过程:
1. 分析依赖图:Vite扫描所有懒加载的组件
2. 找到共同依赖:发现ApiManagement和ApiOrchestration都使用antd、都使用stores等
3. 优化策略:将这些共享依赖提取到一个"共享chunk"中
4. 命名规则:这个共享chunk被命名为第一个遇到的组件名
所以虽然叫"HelpButton",但实际上这个chunk包含:
- HelpButton组件本身(很小)所有两个懒加载页面的共享依赖(很大)
5. index-CSQgMWxP.js——包含了ApiOrchestration页面(/web/edit/:id路由)的所有相关代码

效果

在默认路由ApiManagement页面,确实是只加载ApiManagement页面所需要的资源

  • FCP(首次内容绘制): 1.31 秒 LCP(最大内容绘制): 2.78 秒

点击“新增API/编辑API”后,进入ApiOrchestration页面

  • LCP(最大内容绘制): 41.81 秒

小总结:

优化成果

通过代码分割策略的调整,实现了:

默认路由(ApiManagement页面)

  • FCP(首次内容绘制)优化至 1.31秒
  • LCP(最大内容绘制)降低到 2.78秒
  • 白屏时间从原先的40多秒大幅缩减至2秒级

新的问题

ApiOrchestration页面成为新的性能瓶颈:

  • LCP达到 41.81秒
  • 主要由于该页面所有资源(包括未优化的第三方依赖)被集中打包

本质

当前的优化方案实际上是将性能压力从入口页面转移到了功能页面,这种"拆东墙补西墙"的方式反映出:

  1. ApiOrchestration页面存在严重的资源打包问题
  2. 第三方依赖未合理拆分
  3. 动态导入策略需要进一步优化

细粒度——组件/功能级别懒加载

yarn add -D rollup-plugin-visualizer

通过 Vite 的可视化分析工具发现,新版本中增加的 @sl/convertor-nexus、react-monaco-editor和 monaco-editor等依赖包占据了绝大部分打包体积,这些包是为"TS/Go Struct"导入可视化编辑器功能引入的。这正是新版本首屏白屏问题暴露的主要原因。

这些功能仅在用户点击"快速导入代码"时通过 CodeImporter组件使用,属于低频功能而非首屏必需。因此,最佳优化方案是采用动态加载策略,将相关依赖从主包中分离,在用户实际使用该功能时再按需加载相关依赖,从而显著提升首屏加载性能。

代码改变

打包情况变化

改动前:

改动后:

文件类型原方案新方案原因
HTML单个入口文件同原方案SPA应用,单一入口文件
CSS两个CSS文件三个CSS文件通过对编辑器功能实施懒加载策略,仅在用户点击"TS导入"按钮时加载相关资源。使得原本 86.11KB 的CSS文件被拆分为:
+ 11.93KB 的核心CSS(包含React Flow样式,为ApiOrchestration页面必需加载)
+ 74.19KB 的monaco-editor相关CSS(延迟到点击按钮后加载)
JS五个JS文件七个JS文件通过对ApiOrchestration页面进行拆分,将原先 18,026.23KB 的单一chunk拆分为多个按需加载的模块:
+ 核心功能chunk:303.46KB(页面基础功能)
+ 编辑器功能chunk:2,517.34KB(懒加载)
+ TS导入功能chunk:15,202.73KB(懒加载)

效果

ApiOrchestration页面

  • FCP(首次内容绘制): 552.86ms LCP(最大内容绘制): 2.20s

编辑器功能(动态加载)

TS导入功能(动态加载)

小总结:

通过组件/功能级懒加载策略,我们成功将关键功能模块从首屏分离,实现了按需动态加载,使页面LCP时间从41.81秒显著优化至2.20秒,FCP时间控制在552.86毫秒,有效解决了首屏白屏问题。

虽然当前方案将大体积chunk的加载延迟到功能使用时,尚未从根本上解决资源体积过大的问题。

从懒加载到资源瘦身

通过懒加载策略,成功将大体积资源拆分为按需加载的模块,解决了首屏加载的燃眉之急。然而,这种"延迟加载"的方案本质上只是将性能问题后置,而非真正解决资源体积过大的根本问题。要进一步提升整体性能体验,我们需要将优化重点转向资源瘦身——通过代码压缩、依赖优化和资源精简等手段,从根本上减小chunk体积。这不仅能够缩短首次加载时间,也能降低后续功能模块的加载耗时,实现全生命周期的性能提升。

清理开发环境依赖

在构建分析中发现,这个94.38KB的Runtime chunk属于纯开发依赖,当前错误地被打包进生产环境,不仅增加了不必要的资源体积,还可能带来安全隐患。通过将静态导入改为条件动态导入,我们实现了环境隔离:静态导入在模块解析阶段就被处理,无论后续是否使用都会被打包;而动态导入是运行时执行,只有在开发环境条件满足时才会加载模块,生产环境下该模块会被Tree Shaking完全移除。

改动前:

改动后:

代码压缩

原来打包产物未压缩是19045.6kb,而gzip压缩后是4738.16kb。可知,生产环境请求的大小是19045.6kb,说明上传到的产物没有压缩。

由于没有权限在服务器端进行“代码压缩”这个动作,我们把这个动作前置在vite构建的时候进行,并且在ci的时候设置响应头。

压缩后

ApiManagement页面

  • FCP进一步优化至 329.14ms LCP进一步降低到 0.91秒

ApiOrchestration页面

  • FCP进一步优化至 301.35ms LCP进一步降低到 1.05秒

代码编辑器功能从5.21秒下载时间优化至1.37秒 TS导入功能从31.62秒网络总耗时优化至6.83秒

优化前

优化后

总结

本次针对新版本上线后出现的长达40秒首屏白屏问题,进行了一系列前端性能优化。问题的根本原因是版本更新引入的大型依赖(如代码编辑器和格式转换库)导致最终打包的JavaScript资源体积过大(约19MB),使得资源加载成为性能瓶颈。我们通过一套由浅入深的组合策略,显著提升了体验。

优化路径主要分为三个阶段:

  1. 路由级懒加载:将单一大包按页面维度拆分,解决了入口页面的白屏问题,但将性能压力转移到了功能复杂的API编排页面。
  2. 组件/功能级懒加载:对API编排页面中的重型功能(代码编辑器、TS导入)进行更细粒度的按需加载,成功解决了该页面的白屏问题。
  3. 资源瘦身:在前两步“延迟加载”的基础上,通过“清理开发依赖”和“构建时压缩”手段,从根本上减小了资源体积,实现了全方面的加载速度提升。

下表清晰地展示了每次优化后,关键性能指标的变化:

前端性能优化效果对比

优化阶段优化策略主要变更关键页面/功能FCP (首次内容绘制)LCP (最大内容绘制)资源负载变化
优化前 (v1.10.0)无拆分,单一大包所有代码打包为一个 ~19MB 的 JS 文件首页 (ApiManagement)-~40秒单文件:~19MB
第一阶段优化后路由级懒加载按路由拆分为多个 Chunk首页 (ApiManagement)1.31秒2.78秒主包减小,新增路由Chunk
API编排页 (ApiOrchestration)-41.81秒 (问题转移)该页面Chunk仍包含所有重型依赖
第二阶段优化后组件/功能级懒加载将重型功能(编辑器、TS导入)拆分为独立Chunk按需加载API编排页 (ApiOrchestration)552.86ms2.20秒重型依赖被拆分为独立Chunk,按需加载
第三阶段优化后资源瘦身1. 清理开发环境依赖 2. 构建时启用Gzip压缩首页 (ApiManagement)329.14ms0.91秒总体积显著减小
API编排页 (ApiOrchestration)301.35ms1.05秒总体积显著减小
代码编辑器功能 (动态加载)-加载时间:5.21s → 1.37s传输体积减小
TS导入功能 (动态加载)-加载时间:31.62s → 6.83s传输体积减小

最终成果:通过上述优化,成功将首屏渲染时间从40秒降低到约1秒以内,解决了白屏问题,体验得到质的飞跃。优化过程体现了从“拆包”到“瘦身”的渐进式性能优化思路。

补充

资源加载过慢可以出发的角度

我们可以从 “资源、网络、服务器、浏览器” 四个维度来系统性地分析所有可能造成“资源加载过慢”的原因。

一、资源本身的问题

  1. 资源体积过大(解决“传输量”
    1. JavaScript/CSS 过重
    • 单Bundle/Chunk过大:所有代码打成一个文件。
    • 未Tree Shaking:打包了未引用的代码。
    • 未按需引入:完整引入了大型UI库(如Antd)。
    • 未代码分割:首屏加载了非首屏代码。
    1. 资源格式未优化
    • 图片:使用PNG代替更小的WebP/AVIF格式;图片尺寸过大,未使用响应式图片(srcset)。
    • 字体:加载了整个字体包,而非子集。
  2. 资源数量过多(****解决“排队与调度”)
    • 原因:在HTTP/1.1时代,浏览器对同一域名的并发请求数有限制(通常为6个)。如果页面有100个小图片或JS文件,它们需要排队加载,这就是队头阻塞
    • 现状:HTTP/2的多路复用解决了同一域名的队头阻塞,但过多的请求仍然会增加浏览器调度开销和TCP连接压力。
  3. 资源加载优先级不当(****解决“加载顺序”)
    • 原因:浏览器会智能地安排加载优先级(如CSS、字体是高优先级,图片是低优先级)。但如果开发不当,可能导致:
      • 关键资源加载晚:首屏渲染必需的CSS/JS没有被标记为高优先级。
      • 非关键资源抢占带宽:比如首屏下方的图片过早加载,抢占了关键JS的带宽。

二、网络链路的问题

  1. TCP连接建立耗时
    • 原因:每次建立TCP连接都需要“三次握手”,这在网络延迟(RTT)高的环境下(如移动网络)代价显著。如果使用HTTPS,还要加上TLS握手,进一步增加延迟。
  2. DNS查询缓慢
    • 原因:浏览器需要先将域名解析为IP地址。如果本地DNS缓存失效,或者公共DNS(如8.8.8.8)响应慢,就会增加耗时。特别是页面中引用了多个不同域名的第三方资源时,DNS查询的消耗会叠加。

三、服务器/源站的问题

  1. 服务器处理能力不足
    • 原因:服务器CPU、内存、I/O性能瓶颈,导致处理请求、读取静态资源文件的速度变慢。
  2. 缓存配置不当
    • 原因:服务器没有为静态资源(如JS、CSS、图片)设置正确的HTTP缓存头(如Cache-Control)。导致每次都需要向服务器验证资源是否新鲜,甚至重新下载。
  3. 未开启高效压缩
    • 原因:没有开启或只开启Gzip,而未开启更先进的Brotli压缩(对文本文件压缩率更高)。
  4. HTTP版本过时
    • 原因:服务器仍只支持HTTP/1.1,无法利用HTTP/2的多路复用、服务器推送等性能优化特性。

四、浏览器与客户端的机制问题

  1. 浏览器并发请求限制
    • 原因:如上所述,浏览器对同一域名有并发请求数限制。超过限制的请求必须排队等待。
  2. 缓存策略失效
    • 原因:虽然服务器设置了缓存,但前端构建工具每次生成的资源文件名没有做“哈希化”(Hash)。导致文件内容变了,但文件名没变,浏览器可能因缓存策略而使用旧文件,或反之,文件内容没变但哈希变了,导致缓存失效。
  3. 第三方资源拖慢
    • 原因:页面中引用的第三方脚本(如数据分析、广告、客服插件)如果响应缓慢,会阻塞后续资源的加载,甚至拖慢整个页面。一个慢的第三方资源可以毁掉你所有的优化努力。