首屏优化实践:如何将 Vue3 + Vite 项目的加载速度提升3x
-
- 问题所在
- v0:最首要的问题,我误把所有把能延后的东西都塞进了首屏
- v1:第一轮:先解决"快不快"
-
- [1. 分包收紧,不让首页替后台页面买单](#1. 分包收紧,不让首页替后台页面买单)
- [2. 压缩和缓存交给 Nginx](#2. 压缩和缓存交给 Nginx)
- [3. 做到避免首页"一上来全拉"](#3. 做到避免首页“一上来全拉”)
- [4. 请求层支持去重和取消](#4. 请求层支持去重和取消)
- v2:性能不可把体验做丑
- v3:能直接清晰就别先糊
- 结果:实测与预估的性能提升
- 总结:在整个过程中,我解决了哪些问题?
项目终于要上线了!
在发布前,我怀揣着喜悦,在我组员的电脑上点开了网址,想着做一下最后的测试。
1 秒、2 秒、3 秒...
寂静,死一般的寂静...
不知道等了多少秒,登录的轮廓才加载出来,而此时的背景图,还不知道在何处。
显而易见,首屏加载存在大问题。而作为主导前端项目的我,难逃其咎。
优化!抓紧优化!
问题所在
起初我以为首页慢主要是接口问题,真往前端链路里拆,才发现拖首屏的不是一个点,而是三条链路叠在一起:资源体积、背景视觉资源、首页初始化请求时序。
为此,我在项目发布前,做了一个四步走计划,我将其命名为 v0/v1/v2/v3。
v1先提速;v2补视觉过渡;v3再把"快"和"顺"之间的平衡做细
目标就是为了让 /login 和 /home 冷启动更快。
冷启动 顾名思义:用户第一次加载,本地没有任何缓存
v0:最首要的问题,我误把所有把能延后的东西都塞进了首屏
我犯的第一个严肃的错误就是把:本来可以延后的资源和请求,被默认都放到了首屏阶段。
| v0 问题 | 用户感知 | 根因 |
|---|---|---|
gzip 没开 |
弱网下 JS/CSS 传输吃亏 | 部署层没把压缩打开 |
manualChunks 过宽 |
首页容易卷进不该首屏加载的依赖 | id.includes('vue') 这类宽匹配误伤第三方库 |
| 背景轮播全局挂载 | 非首页页面也背上背景资源成本 | 背景组件挂在根组件,没有按路由裁剪 |
| 首页初始化请求过多 | 页面刚出来就一直在 loading | 排行榜、组织、曲线、菜单刷新一起抢首屏 |
| 菜单刷新走阻塞链路 | 刷新和首次进入更容易卡住 | 动态路由依赖实时菜单结果 |
v1:第一轮:先解决"快不快"
首屏乃是第一门面。
所以第一轮着重解决影响首屏速度的事:
Vite 分包修正、Nginx gzip + 强缓存、首页请求时序改造、请求去重/取消。
1. 分包收紧,不让首页替后台页面买单
先改的是 vite.config.ts,重点不是把包拆得越碎越好,而是把真正的首屏运行时和后台模块隔开。
ts
const chunkByPackage = (id: string) => {
if (id.includes("/node_modules/vue/") || id.includes("/node_modules/@vue/")) {
return "vue-core";
}
if (
id.includes("/node_modules/vue-router/") ||
id.includes("/node_modules/pinia/") ||
id.includes("/node_modules/@vueuse/")
) {
return "router-pinia";
}
if (id.includes("/node_modules/axios/")) {
return "http";
}
return "vendor";
};
这样之后,登录页和首页不再默认替 console、权限管理、工作台这些模块付首屏成本,当前 dist/index.html 也只预载 vue-core、router-pinia、vendor 和 http。
2. 压缩和缓存交给 Nginx
nginx
# 启用 gzip 压缩,减少文本资源传输体积
gzip on;
# 告诉中间代理:压缩版与非压缩版是不同响应
gzip_vary on;
# 小于 1KB 的响应通常压缩收益不高,跳过
gzip_min_length 1024;
# HTML 入口文件不做强缓存,确保每次发布后都能拿到最新壳文件
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
}
# 带 hash 的静态资源走长期强缓存,提高二次访问命中率
location ~* ^/(?:js|css|avif|woff|woff2|ttf)/ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
3. 做到避免首页"一上来全拉"
第二个问题是请求时序。首页最早不是请求失败,而是请求太积极,什么都想第一时间完成。所以要把链路拆成了两层:
- 菜单刷新改成"缓存优先 + 后台刷新"。
- 组织列表从首页
onMounted移到"切换组织"弹窗打开时再拉。(按需加载) - 排行榜从
refreshAll(scope, orgId)改成refreshPlatform(platform, scope, orgId),只刷当前平台。
附:
setup:组件一开始执行的地方,写状态、方法、监听这些onMounted:组件已经出现在页面上后执行onUnmounted:组件从页面移除后执行,常用来清理定时器、事件监听
setup 是"开始准备",onMounted 是"已经挂上页面",onUnmounted 是"要销毁收尾"。
4. 请求层支持去重和取消
用户快速切平台、切组织时,如果没有取消机制,旧请求回来以后很容易把新状态覆盖掉。现在请求层补了 dedupeKey 和 cancelPrevious,排行榜也从 refreshAll 改成按平台 refreshPlatform:
ts
export interface RequestConfig extends InternalAxiosRequestConfig {
dedupeKey?: string
cancelPrevious?: boolean
}
if (config.cancelPrevious && existingController) existingController.abort()
const refreshPlatform = async (platform, scope = 'current_org', orgId?) => {
if (platform === 'luogu') return fetchLuoguRankingList(1, false, scope, orgId)
if (platform === 'leetcode') return fetchLeetcodeRankingList(1, false, scope, orgId)
return fetchLanqiaoRankingList(1, false, scope, orgId)
}
v2:性能不可把体验做丑
第一轮之后,页面整体的加载速度已经好很多了,但是还有一个非常严肃的问题。
为了使观感最佳,我采用的背景图是 4 张原图一共大约 2.14MB,而第一张单图就接近 396KB。
为了解决这个问题:
所在在 v2 版本中我开始单独处理背景链路:首屏先给一张极轻量的 poster(损率极高的氛围图),后台再预加载 4 张正式轮播图,等它们到齐以后整体淡入轮播层。资源策略也固定下来了:
poster:320px + blur + AVIF q28- 正式轮播图:
1920px + AVIF q52
第一张,只是为了占位,所以出损率高的图片,避免刚加载时,背景就是白屏。
但是这样明显不是解决方案。
所以我把一张极轻量的 poster(损率极高的氛围图)
改变成了一张更加轻量的损率极高的氛围图 (最多只有几十B,降低了进百倍),并且渲染为模糊状态。
等四张原图加载完毕之后,再由模糊变清晰,做了一个过度。
先纠正一句:你现在这版代码不是"等四张原图全加载完再变清晰" ,而是"首图先到就先接管,其他图继续后台加载"。
你可能好奇我是如何做监听的:
- 用
imageStates记录 4 张图各自的状态:idle / loading / loaded / error。 loadImage()里手动new Image(),监听onload/onerror。onload触发后,还会再执行一次image.decode(),等浏览器真正解码完成 ,才把这张图标记成loaded。
也就是说,它检测的不是"请求回来了",而是"这张图已经可以稳定显示了"。watch(...)监听当前哪些图已经进入 loaded 集合。
只要在 poster 状态下发现第 1 张图已 loaded,或者第 1 张失败但别的图有一张 loaded,就调用 startPosterReveal() 开始淡出 poster。
v3:能直接清晰就别先糊
v2 做完以后,却又发现一个体验问题:它太保守了。
缓存命中或者网络较好时,第一张高清图其实能很快就位,这时候还强行先给 poster,反而多了一道流程。
所以 v3 的核心是这样:能直接清晰就别先糊,不能直接清晰再优雅过渡。
ts
// 轮播背景当前所处的展示阶段:
// booting = 启动中,先判断首张高清图能否快速就绪
// poster = 显示低清/模糊占位图
// revealing = 正在从 poster 过渡到高清图
// ready = 高清图已稳定显示,可正常轮播
type CarouselState = 'booting' | 'poster' | 'revealing' | 'ready'
// 首张高清图的"快速就绪探测窗口"。
// 如果在 180ms 内完成解码,就直接显示高清图,跳过 poster。
const FAST_READY_BUDGET_MS = 180
// 从 poster 过渡到高清图的动画时长(单位:毫秒)。
const REVEAL_DURATION_MS = 2200
// 轮播图切换间隔(单位:毫秒)。
const CAROUSEL_INTERVAL_MS = 6700
// 计算"当前真正可参与轮播的图片列表"。
// 只保留那些已经成功加载完成(loaded)的图片,避免等待全部图片就绪。
const visibleCarouselImages = computed(() =>
carouselImages
// 先把原始图片地址数组转成对象数组,顺手保留下标,方便后面按 index 查状态
.map((src, index) => ({ src, index }))
// 只筛出已加载成功的图片;未加载完成或加载失败的图片先不参与轮播
.filter(({ index }) => imageStates.value[index] === 'loaded')
)
v3 主要做了四件事:
- 给第一张高清图一个
180ms的快速探测窗口,能解码就跳过poster。 - 探测失败才回退到绿色
poster。 poster退场统一拉到2.2s的延迟淡出。- 不再等 4 张图全部就绪,只在
visibleCarouselImages这个已加载成功集合里轮播,可用图片数大于 1 才启动轮播。
结果:实测与预估的性能提升
npm run build已通过。src/assets/background/generated/poster.avif当前约764B,这个值会随着背景重新生成有小幅波动。- 4 张正式轮播图合计约
1.10MB。 BackgroundCarouselchunk 约4.43kB / gzip 2.39kB。HomeViewchunk 约10.13kB / gzip 4.38kB。LeaderboardCardchunk 约11.45kB / gzip 3.56kB。- 当前
dist/index.html只预载vue-core、router-pinia、vendor、http。
| 版本 | 关键动作 | 解决什么 | 代价是什么 |
|---|---|---|---|
| v1 | 分包、压缩、缓存、请求时序、请求取消 | 先把首页真正拖慢的链路拆掉 | 需要重排首页数据流和路由守卫 |
| v2 | poster -> 高清轮播 双层策略 |
解决"快了但切换生硬" | 逻辑偏保守,必须等资源更完整 |
| v3 | 首图快速路径、渐进轮播集合 | 兼顾缓存命中和慢网场景 | 背景状态机更复杂 |
/login冷启动:提升2.5x - 4x/home冷启动:提升2.2x - 3.2x- 二次访问或重复进入:提升
5x+
这里要强调一句,真实工程不是神话故事。
当前构建里 ConsoleSidebar 这个 chunk 仍然大约有 1.07MB,Vite 也还会给大 chunk 警告,后台控制台部分还有继续拆的空间。
总结:在整个过程中,我解决了哪些问题?
- 首屏慢不是一个点,而是资源传输、背景图策略和首页请求链路叠加出来的问题。
- 第一轮先降首屏负担,第二轮补视觉过渡,第三轮做快速路径和渐进轮播。
- 难点不是单纯压资源,而是判断哪些数据必须首屏拿,哪些应该延后。
- 结果是首屏体感明显改善了,但我也保留了后续优化点,没有把项目讲成"已经完美"。
在欣赏一下我的首页:

等后续再把https证书补上就完美了( ̄︶ ̄)↗