本文是「前端工程现场」系列的第 9 篇。
下面使用一个便于理解的 Vue 3 与 Vite 后台项目作为假设场景。构建结果、文件大小和日志均为简化示例,不对应具体线上项目。
设想一个后台项目新增了 Excel 导出功能。功能只出现在订单列表页,开发者本地验证导出正常后提交上线。发布完成后,首页没有报错,接口响应也不算慢,但用户打开系统时,白屏时间比之前明显增加。
排查开始时,团队看到首页初始化接口耗时约 200 毫秒,于是先把注意力放在后端查询上。继续查看浏览器网络记录后,才发现首页脚本从原来的几百 KB 增长到 1 MB 以上,新增的导出依赖被打进了初始构建产物。
这个场景里,首屏慢只是最终表现。实际上用户等待的时间可能花在三个不同阶段:
text
资源还没有下载完成
JavaScript 已下载,但主线程仍在执行
页面正在等待接口或其他初始化条件
如果一开始就修改接口、图片、路由懒加载和缓存配置,很容易同时动很多地方,却无法确认哪项改动真正缩短了首屏时间。
本文沿着一次实际排查顺序展开:先确认页面等待在哪个阶段,再定位构建产物为什么发生变化,最后说明修改后怎样验证,以及这套处理还没有覆盖哪些情况。
一、先把首屏慢拆成三个可以观察的阶段
仅看最终白屏时间,很难判断等待发生在哪一步。下面把资源下载、脚本执行和数据请求放到同一条首屏链路中。

用户看到页面之前,浏览器通常需要完成多步工作。下面只保留与本次问题有关的部分:
text
请求 HTML
→ 下载 CSS 和 JavaScript
→ 解析并执行入口脚本
→ 创建应用和路由
→ 请求首页数据
→ 渲染首屏内容
任何一个阶段变慢,用户都可能只看到同一种结果:页面迟迟没有出现。
因此,排查首屏问题时,第一步不应该直接猜测原因,而是确认时间主要花在哪里。
| 观察结果 | 更可能需要检查的位置 |
|---|---|
| HTML 或主脚本下载时间明显增加 | 网络、缓存、构建产物体积 |
| 资源下载完成后页面仍长时间无响应 | JavaScript 执行、初始化逻辑、长任务 |
| 页面框架已经出现,但内容区一直等待 | 接口请求、串行依赖、加载状态 |
| 页面内容已出现,但图片和字体继续跳动 | 图片、字体、布局和渲染策略 |
本次假设场景中,浏览器网络面板显示:
text
index.html 18 KB 82 ms
index-a81f3.js 1.31 MB 1.74 s
user-profile 3.2 KB 196 ms
dashboard-summary 8.6 KB 224 ms
这段记录位于浏览器加载流程中。需要关注的是主脚本下载时间明显高于两个首页接口。
它改变了排查判断:即使把接口从 200 毫秒优化到 100 毫秒,主脚本仍然需要较长时间下载和执行,用户看到页面的时间不会因此减少同样的幅度。
这份网络记录还不能说明脚本为什么变大,也不能说明下载完成后的执行时间。下一步需要回到构建产物。
二、构建产物为什么比源码文件更值得先看
开发者新增导出功能时,修改的源码可能只有几十行:
ts
import * as XLSX from 'xlsx'
export function exportOrders(rows: Order[]) {
const sheet = XLSX.utils.json_to_sheet(rows)
const book = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(book, sheet, 'orders')
XLSX.writeFile(book, 'orders.xlsx')
}
这段代码位于订单导出功能中。读者应关注顶部的静态导入。
静态导入会参与当前模块的依赖分析。如果这个模块又被应用入口、全局插件或首页同步导入,导出依赖就可能进入初始构建链路。
本地源码只有一个导入语句,并不能直接说明最终产物增加了多少。构建完成后,可以先查看工具输出的文件大小。
下面是一次简化的构建记录:
text
dist/assets/index-a81f3.js 1,342.18 kB │ gzip: 421.65 kB
dist/assets/vendor-f3c2.js 684.52 kB │ gzip: 218.31 kB
dist/assets/order-91ab.js 86.40 kB │ gzip: 27.66 kB
这段记录位于生产构建阶段。它说明初始入口脚本和公共依赖已经较大,但只看文件名还不能确认 Excel 依赖具体进入了哪个文件。
如果项目配置了构建分析插件,可以通过依赖图继续定位。没有分析插件时,也可以先在产物中搜索依赖特征,或者对比修改前后的构建结果:
bash
git checkout <previous-commit>
pnpm build
git checkout <current-commit>
pnpm build
这组命令用于构建结果对比。需要关注的是同一构建命令下,哪些产物在本次提交后明显增长。
它要求两次构建使用相同的 Node.js、包管理器、依赖锁定文件和构建参数。否则大小差异可能来自依赖版本或环境变化,而不只是当前代码。
假设对比结果如下:
| 构建版本 | 初始脚本 gzip 大小 | 首页接口耗时 | 首屏可见时间 |
|---|---|---|---|
| 修改前 | 238 KB | 210 ms | 1.4 s |
| 修改后 | 422 KB | 224 ms | 2.3 s |
接口耗时变化很小,初始脚本却增加了 184 KB。这个结果把排查范围进一步缩小到初始依赖链。
删除 node_modules 再安装,有时会让某些构建问题暂时消失,但它不能解释为什么一个只在订单页使用的功能进入了首页脚本。这里更需要检查导入关系。
三、静态导入怎样把非首屏功能带进入口脚本
假设项目把导出方法注册成全局工具:
ts
// src/plugins/export.ts
import * as XLSX from 'xlsx'
export function installExport(app: App) {
app.config.globalProperties.$exportXlsx = XLSX
}
应用入口同步加载这个插件:
ts
// src/main.ts
import { installExport } from './plugins/export'
const app = createApp(App)
installExport(app)
app.mount('#app')
这段代码位于应用启动阶段。入口脚本在创建应用前就需要加载导出模块,导出模块又静态依赖 Excel 库。
即使用户当前只打开首页,没有进入订单列表,浏览器仍然需要下载和处理这部分代码。
依赖链可以写成:
text
main.ts
→ plugins/export.ts
→ xlsx
构建工具可以拆分文件,但只要入口在启动阶段需要这个模块,浏览器初始加载时仍可能需要获取对应依赖。
当前设计解决的问题是任何页面都能直接调用导出工具,代价是所有用户都为一个低频功能承担初始加载成本。
这类全局注册还会带来两个维护问题:
- 业务代码很难从导入语句看出导出能力来自哪里;
- 替换导出库时,应用入口和全局类型声明也可能一起修改。
如果导出功能只在订单页使用,更直接的做法是让订单模块自己管理依赖。
四、按需加载怎样改变首屏的依赖链
导出功能通常在用户点击按钮后才需要执行,所以我们可以把依赖加载移动到导出动作中:
ts
export async function exportOrders(rows: Order[]) {
const XLSX = await import('xlsx')
const sheet = XLSX.utils.json_to_sheet(rows)
const book = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(book, sheet, 'orders')
XLSX.writeFile(book, 'orders.xlsx')
}
这段代码位于用户点击导出后的执行流程中。动态导入返回 Promise,构建工具可以把该依赖放入单独的异步产物。
修改后的加载链路变成:
text
打开首页
→ 加载首页需要的脚本
→ 不加载导出依赖
进入订单页并点击导出
→ 请求导出依赖
→ 生成 Excel 文件
修改前后可以这样比较:
| 阶段 | 静态全局导入 | 点击后动态导入 |
|---|---|---|
| 首页加载 | 需要处理导出依赖 | 不需要处理导出依赖 |
| 第一次点击导出 | 依赖已在初始加载中获取 | 需要等待异步依赖加载 |
| 后续点击导出 | 通常可复用已加载模块 | 通常可复用已加载模块 |
| 代码归属 | 应用入口或全局插件 | 订单功能内部 |
| 维护成本 | 全局声明和入口耦合 | 需要处理首次加载状态和失败 |
动态导入减少的是初始依赖链,当用户第一次点击导出时,仍然需要下载和执行这部分代码。
因此,按钮需要处理加载状态:
ts
const exporting = ref(false)
async function handleExport(rows: Order[]) {
exporting.value = true
try {
await exportOrders(rows)
} finally {
exporting.value = false
}
}
这段代码位于导出交互阶段。它避免用户在异步模块尚未加载完成时重复点击。
它还没有处理网络失败和错误提示。真实项目需要结合现有通知组件、错误上报和重试策略补充,不能只依靠 finally。
动态导入适合低频、非首屏功能。如果首页加载后马上就会使用某个模块,把它拆成额外请求未必能缩短可见时间,还可能增加请求调度和等待。
五、脚本下载完成后,为什么页面仍可能很慢
假设优化后,初始脚本从 422 KB 降到 250 KB,但用户仍然感觉页面出现得慢。此时需要继续查看资源下载后的 JavaScript 执行。
浏览器下载脚本后,还要解析、编译和执行。入口中同步执行的大量初始化逻辑,会占用主线程。
例如项目在挂载应用前一次性注册所有图标:
ts
import * as Icons from '@element-plus/icons-vue'
for (const [name, component] of Object.entries(Icons)) {
app.component(name, component)
}
app.mount('#app')
这段代码位于应用启动阶段,它会遍历导入的图标并完成全局注册,然后才挂载页面。
即使网络缓存命中了,初始化工作仍然需要执行,在低性能设备上的脚本执行时间可能比开发机器更明显。
浏览器性能记录中,假设出现:
text
Evaluate Script 620 ms
Application bootstrap 410 ms
Render 95 ms
这段记录说明资源已经到达浏览器,时间主要花在脚本执行和应用初始化阶段。
它改变了排查方向:继续压缩接口或开启强缓存,无法直接减少这部分主线程工作。
可以检查入口中是否包含:
- 全量注册组件或图标;
- 启动时解析大体积配置;
- 与首页无关的 SDK 初始化;
- 同步读取和转换大量本地数据;
- 应用挂载前必须完成的多个任务。
这里不应该看到上面的内容就立即优化,需要先确认它是否真的出现在首屏关键路径中,以及实际占用了多少时间。
对图标注册,可以按项目现有使用方式改为按需导入:
ts
import {
Search,
Download
} from '@element-plus/icons-vue'
app.component('Search', Search)
app.component('Download', Download)
这项调整减少了导入和注册范围,但会增加显式维护成本。新增图标时需要补充导入,遗漏后页面会出现组件未注册或图标缺失。
是否值得修改,要根据构建分析和执行记录判断,不能只因为按需导入看起来更规范。
六、接口很快,串行请求仍然可能拖慢首屏
资源和脚本执行都正常后,还需要检查首页是否在等待多项数据。
假设页面初始化代码如下:
ts
async function initPage() {
const user = await fetchCurrentUser()
const permissions = await fetchPermissions(user.id)
const summary = await fetchDashboardSummary()
renderDashboard({
user,
permissions,
summary
})
}
这段代码位于首页初始化阶段。三个请求按顺序执行,后一个请求要等待前一个完成。
假设请求耗时分别为:
text
当前用户 180 ms
权限 210 ms
首页统计 240 ms
单个接口都不算慢,但串行等待接近三次请求之和。
如果首页统计不依赖用户和权限,可以调整为并行:
ts
async function initPage() {
const user = await fetchCurrentUser()
const [permissions, summary] = await Promise.all([
fetchPermissions(user.id),
fetchDashboardSummary()
])
renderDashboard({
user,
permissions,
summary
})
}
这段代码改变的是接口调度阶段。用户信息仍然先请求,因为权限依赖用户 ID,权限和首页统计在依赖满足后并行执行。
修改前后的时间关系如下:
| 流程 | 请求关系 | 理论等待方式 |
|---|---|---|
| 修改前 | 用户 → 权限 → 统计 | 三段耗时依次累加 |
| 修改后 | 用户 → 权限,同时请求统计 | 用户耗时加后两者较大值 |
Promise.all 会在任一任务失败时整体进入拒绝状态。如果首页统计失败不应该阻止用户信息和基础框架展示,就需要拆分错误处理,而不是直接把所有请求放进同一个 Promise.all。
例如,可以先渲染页面框架,再让非关键卡片单独加载。这样改变的是首屏完成条件:用户不必等待所有模块成功,才能看到页面主体。
这项调整会增加多个加载状态、空状态和错误状态。页面更早可见的代价,是组件需要独立处理数据尚未返回的情况。
七、优化后怎样证明首屏确实发生了变化
修改代码后,需要在相近条件下对比修改前后的关键数据。
至少可以保留以下记录:
| 检查项 | 修改前 | 修改后 |
|---|---|---|
| 初始脚本 gzip 大小 | 422 KB | 250 KB |
| 主脚本下载时间 | 1.74 s | 0.96 s |
| 脚本执行时间 | 620 ms | 360 ms |
| 首页关键请求完成时间 | 650 ms | 430 ms |
| 首屏可见时间 | 2.3 s | 1.5 s |
这些数字属于假设场景,真实项目应以同一设备、网络条件、构建版本和页面路径下的记录为准。
验证流程可以分为四步:
text
使用生产构建
→ 清理或明确缓存条件
→ 记录同一页面加载
→ 对比网络、执行和渲染阶段
生产构建很重要。开发环境通常包含模块热更新、额外调试代码和不同的依赖加载方式,不能直接代替生产产物评估。
修改完成后,可以执行项目已有的构建命令:
bash
pnpm build
这条命令位于生产产物验证阶段。构建成功说明代码可以生成产物,但不能说明首屏已经变快。
还需要在预览或部署环境中验证:
- 首页能否正常显示;
- 导出按钮第一次点击是否有加载反馈;
- 动态依赖加载失败时是否有提示;
- 缓存命中和未命中时表现是否符合预期;
- 低性能设备上的脚本执行是否仍然过长。
如果只验证初始包变小,可能漏掉导出功能首次点击等待过长的问题。性能调整经常是在不同阶段之间移动成本,验证时应同时检查被优化的阶段和被推迟的阶段。
八、当前排查方式还没有覆盖哪些问题
本文使用资源下载、脚本执行和接口等待三段链路排查首屏,但它不能覆盖所有性能问题。
第一,图片和字体也可能阻塞用户看到完整页面。主脚本正常,不代表大图、Web Font 和样式文件没有影响渲染。
第二,服务端渲染和客户端渲染的首屏链路不同。服务端输出 HTML 后,还可能存在水合时间和客户端数据接管,不能直接套用本文的单页应用流程。
第三,缓存会显著改变结果。首次访问、重复访问、强缓存和 CDN 命中时的资源耗时不同,性能结论必须说明测试条件。
第四,开发机不能代表所有用户设备。相同脚本在高性能电脑上执行很快,在低性能设备上可能占用更长时间。
第五,单次记录可能受网络波动影响。需要多次采样,或者使用项目已有的性能监控数据判断变化趋势。
工程中可以建立一条简单的排查顺序:
| 观察阶段 | 优先检查 |
|---|---|
| 资源未完成 | 文件体积、请求数量、缓存、传输 |
| 资源已完成但页面未响应 | 脚本执行、初始化任务、主线程占用 |
| 页面框架出现但数据未完成 | 请求依赖、串行链路、渲染门槛 |
| 本地正常但用户仍慢 | 设备差异、网络条件、生产监控 |
这张表用于缩小范围,不是固定优化方案。每个项目的首屏内容、用户设备和发布方式都不同。
九、小结
这次问题表面上是新增 Excel 导出后首页变慢。浏览器记录显示首页接口没有明显变化,主要差异来自导出依赖进入初始构建产物,增加了资源下载和脚本处理时间。
项目把低频导出能力从应用入口移到业务模块,并在用户点击时动态加载。这样缩短了首页依赖链,但第一次导出需要等待异步模块加载,按钮状态和失败提示也要一起处理。后续如果脚本下载已经正常,还需要继续检查应用初始化和接口串行等待,不能把所有首屏问题都归到包体积。
这套排查方式没有覆盖图片、字体、服务端渲染、设备差异和长期监控。它提供的是一个定位顺序:先确认时间花在资源、执行还是数据,再修改对应阶段的代码。
下次遇到首屏变慢,可以先保存浏览器网络记录、生产构建结果和脚本执行时间。这个步骤通常比直接开启路由懒加载、压缩图片或重写接口更容易找到当前项目的主要等待点。下一篇会讨论 Loading chunk failed,重点分析旧页面、缓存和新版本构建产物之间怎样产生路径不一致。
配图与流程图建议
图片 1:首屏加载的三段排查路径
插入位置:
放在"## 一、先把'首屏慢'拆成三个可以观察的阶段"中的加载流程之后。
图前过渡句:
图注:
图 1:首屏变慢时,先确认等待发生在资源下载、JavaScript 执行还是数据请求阶段,再进入对应配置和代码排查。
中文绘图提示词:
发布前自查
- 正文所有二级标题均使用中文序号。
- 开头从新增 Excel 导出后首页变慢的具体假设场景进入。
- 构建结果、文件大小和性能记录均明确属于简化示例。
- 代码片段只展示静态导入、动态导入、初始化和请求依赖中的关键部分。
- 每段构建记录和代码都说明了所在流程、改变的判断以及尚未覆盖的边界。
- 没有把动态导入、按需加载或并行请求描述成适用于所有页面的固定答案。
- 小结说明了问题表现、修改机制、被推迟的成本和后续排查方向。