起因
新版本(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 秒
关键发现:
- FCP 与 LCP 时间极高,均超过 40 秒,远超良好体验的标准(FCP < 1.8秒, LCP < 2.5秒)。
- 两个指标的时间点非常接近,这表明页面从开始显示一点内容(FCP)到主要内容完全渲染出来(LCP)之间几乎没有间隔。
- 在 LCP 的耗时分解中,网络传输阶段耗时约 40 秒,占据了总时间的绝大部分。
初步结论:FCP 与 LCP 的异常数据共同将问题指向了浏览器渲染前的阶段。由于关键资源(JavaScript)未能及时加载,导致 DOM 内容构建被严重延迟,进而使得 FCP 和 LCP 这两个关键指标同时出现灾难性的数值。网络资源加载过慢是导致本次长时间白屏的直接元凶。
2.2 关键性能指标:FCP 与 LCP
在本案例中,我们选用 FCP 和 LCP 这两个指标共同作为评价首屏性能的核心依据。
- 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 的代码分割(分包)主要基于以下原则和单位:
- 入口点分割
- 每个 HTML 入口文件自动作为一个单独的 chunk【这是最基础的分割层级】
- 动态导入分割【懒加载】
// 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()小节
- 依赖包被多个chunk共享
- 多页面应用场景
// 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组件库
- 手动配置分割
// vite.config.js
export default {
build: {
rollupOptions: {
output: {
manualChunks: {
// 将 React 相关库合并到同一个 chunk
'react-vendor': ['react', 'react-dom'],
// 将工具库合并
'utils': ['lodash', 'axios']
}
}
}
}
}
当前拆分现状
当前项目所有依赖都在唯一一个入口中 (main.tsx)使用
dist/index.html(0.44 kB)
作用: 应用的入口HTML文件
来源:根目录的 index.html 模板生成
dist/assets/index-Hl7Y9d9v.css(86.19 kB)
作用: 所有样式文件的合并和压缩版本
dist/assets/Runtime-ClaFJL1B.js(94.38 kB)
来源: LocatorJS 插件的chunk
为什么它会单独打成一个chunk
Vite 自动识别出这是开发工具依赖
但是这个文件应该被打包进生产环境吗?
作用: 完整的应用代码包
小知识:文件名中的哈希值作用
index-ByN0ITNo.js中的ByN0ITNo是内容哈希- 缓存控制: 内容变化时哈希值变化,浏览器会重新下载
- 缓存优化: 内容不变时哈希值不变,浏览器使用缓存
尝试多种拆分
粗粒度——路由级别懒加载
代码改变
打包情况变化
改动前:
改动后:
| 文件类型 | 原方案 | 新方案 | 原因 |
|---|---|---|---|
| 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秒
- 主要由于该页面所有资源(包括未优化的第三方依赖)被集中打包
本质
当前的优化方案实际上是将性能压力从入口页面转移到了功能页面,这种"拆东墙补西墙"的方式反映出:
- ApiOrchestration页面存在严重的资源打包问题
- 第三方依赖未合理拆分
- 动态导入策略需要进一步优化
细粒度——组件/功能级别懒加载
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),使得资源加载成为性能瓶颈。我们通过一套由浅入深的组合策略,显著提升了体验。
优化路径主要分为三个阶段:
- 路由级懒加载:将单一大包按页面维度拆分,解决了入口页面的白屏问题,但将性能压力转移到了功能复杂的API编排页面。
- 组件/功能级懒加载:对API编排页面中的重型功能(代码编辑器、TS导入)进行更细粒度的按需加载,成功解决了该页面的白屏问题。
- 资源瘦身:在前两步“延迟加载”的基础上,通过“清理开发依赖”和“构建时压缩”手段,从根本上减小了资源体积,实现了全方面的加载速度提升。
下表清晰地展示了每次优化后,关键性能指标的变化:
前端性能优化效果对比
| 优化阶段 | 优化策略 | 主要变更 | 关键页面/功能 | 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.86ms | 2.20秒 | 重型依赖被拆分为独立Chunk,按需加载 |
| 第三阶段优化后 | 资源瘦身 | 1. 清理开发环境依赖 2. 构建时启用Gzip压缩 | 首页 (ApiManagement) | 329.14ms | 0.91秒 | 总体积显著减小 |
| API编排页 (ApiOrchestration) | 301.35ms | 1.05秒 | 总体积显著减小 | |||
| 代码编辑器功能 (动态加载) | - | 加载时间:5.21s → 1.37s | 传输体积减小 | |||
| TS导入功能 (动态加载) | - | 加载时间:31.62s → 6.83s | 传输体积减小 |
最终成果:通过上述优化,成功将首屏渲染时间从40秒降低到约1秒以内,解决了白屏问题,体验得到质的飞跃。优化过程体现了从“拆包”到“瘦身”的渐进式性能优化思路。
补充
资源加载过慢可以出发的角度
我们可以从 “资源、网络、服务器、浏览器” 四个维度来系统性地分析所有可能造成“资源加载过慢”的原因。
一、资源本身的问题
- 资源体积过大(解决“传输量”)
- JavaScript/CSS 过重
- 单Bundle/Chunk过大:所有代码打成一个文件。
- 未Tree Shaking:打包了未引用的代码。
- 未按需引入:完整引入了大型UI库(如Antd)。
- 未代码分割:首屏加载了非首屏代码。
- 资源格式未优化
- 图片:使用PNG代替更小的WebP/AVIF格式;图片尺寸过大,未使用响应式图片(srcset)。
- 字体:加载了整个字体包,而非子集。
- 资源数量过多(****解决“排队与调度”)
- 原因:在HTTP/1.1时代,浏览器对同一域名的并发请求数有限制(通常为6个)。如果页面有100个小图片或JS文件,它们需要排队加载,这就是队头阻塞。
- 现状:HTTP/2的多路复用解决了同一域名的队头阻塞,但过多的请求仍然会增加浏览器调度开销和TCP连接压力。
- 资源加载优先级不当(****解决“加载顺序”)
- 原因:浏览器会智能地安排加载优先级(如CSS、字体是高优先级,图片是低优先级)。但如果开发不当,可能导致:
- 关键资源加载晚:首屏渲染必需的CSS/JS没有被标记为高优先级。
- 非关键资源抢占带宽:比如首屏下方的图片过早加载,抢占了关键JS的带宽。
- 原因:浏览器会智能地安排加载优先级(如CSS、字体是高优先级,图片是低优先级)。但如果开发不当,可能导致:
二、网络链路的问题
- TCP连接建立耗时
- 原因:每次建立TCP连接都需要“三次握手”,这在网络延迟(RTT)高的环境下(如移动网络)代价显著。如果使用HTTPS,还要加上TLS握手,进一步增加延迟。
- DNS查询缓慢
- 原因:浏览器需要先将域名解析为IP地址。如果本地DNS缓存失效,或者公共DNS(如8.8.8.8)响应慢,就会增加耗时。特别是页面中引用了多个不同域名的第三方资源时,DNS查询的消耗会叠加。
三、服务器/源站的问题
- 服务器处理能力不足
- 原因:服务器CPU、内存、I/O性能瓶颈,导致处理请求、读取静态资源文件的速度变慢。
- 缓存配置不当
- 原因:服务器没有为静态资源(如JS、CSS、图片)设置正确的HTTP缓存头(如Cache-Control)。导致每次都需要向服务器验证资源是否新鲜,甚至重新下载。
- 未开启高效压缩
- 原因:没有开启或只开启Gzip,而未开启更先进的Brotli压缩(对文本文件压缩率更高)。
- HTTP版本过时
- 原因:服务器仍只支持HTTP/1.1,无法利用HTTP/2的多路复用、服务器推送等性能优化特性。
四、浏览器与客户端的机制问题
- 浏览器并发请求限制
- 原因:如上所述,浏览器对同一域名有并发请求数限制。超过限制的请求必须排队等待。
- 缓存策略失效
- 原因:虽然服务器设置了缓存,但前端构建工具每次生成的资源文件名没有做“哈希化”(Hash)。导致文件内容变了,但文件名没变,浏览器可能因缓存策略而使用旧文件,或反之,文件内容没变但哈希变了,导致缓存失效。
- 第三方资源拖慢
- 原因:页面中引用的第三方脚本(如数据分析、广告、客服插件)如果响应缓慢,会阻塞后续资源的加载,甚至拖慢整个页面。一个慢的第三方资源可以毁掉你所有的优化努力。