首屏变慢后,应该先查资源下载、脚本执行还是接口请求

本文是「前端工程现场」系列的第 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

构建工具可以拆分文件,但只要入口在启动阶段需要这个模块,浏览器初始加载时仍可能需要获取对应依赖。

当前设计解决的问题是任何页面都能直接调用导出工具,代价是所有用户都为一个低频功能承担初始加载成本。

这类全局注册还会带来两个维护问题:

  1. 业务代码很难从导入语句看出导出能力来自哪里;
  2. 替换导出库时,应用入口和全局类型声明也可能一起修改。

如果导出功能只在订单页使用,更直接的做法是让订单模块自己管理依赖。

四、按需加载怎样改变首屏的依赖链

导出功能通常在用户点击按钮后才需要执行,所以我们可以把依赖加载移动到导出动作中:

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 导出后首页变慢的具体假设场景进入。
  • 构建结果、文件大小和性能记录均明确属于简化示例。
  • 代码片段只展示静态导入、动态导入、初始化和请求依赖中的关键部分。
  • 每段构建记录和代码都说明了所在流程、改变的判断以及尚未覆盖的边界。
  • 没有把动态导入、按需加载或并行请求描述成适用于所有页面的固定答案。
  • 小结说明了问题表现、修改机制、被推迟的成本和后续排查方向。
相关推荐
shawn03261 小时前
《疯狂动物城 2》背后的 Hybrid BVH:迪士尼如何让复杂场景跑进交互式 GPU 光追
前端·计算机图形学
圣光SG1 小时前
web操作题练习(五)
前端
杉氧1 小时前
弹性滚动的奥秘:在 Flutter 中使用 CustomScrollView 与 Sliver 打造极致流畅列表
android·前端·flutter
jinshw1 小时前
自己实现GIS配图软件(二)
前端·后端·rust
肥胖小羊1 小时前
企业微信自建应用多环境联调与路由代理方案
服务器·前端·网络
林间码客1 小时前
全栈视角下的 Mock 指南:从后端单元测试到前端 Vue 3 联调
前端·vue.js·单元测试
Cobyte2 小时前
手写 Rollup 插件实现解析 Vue 文件
前端·javascript·vue.js
IT_陈寒2 小时前
Vue的这个响应式陷阱,我调试了一整天才爬出来
前端·人工智能·后端
莪_幻尘2 小时前
手把手书写你的第一个 AI Skill:从原则到工程化
前端·agent·ai编程