一次真实项目中的前端性能优化实践与关键技巧
谁更灵活?谁更省事?移动端首屏加载性能的三个方案实测
上个月上线一个活动页,用户反馈「点开白屏两秒」,PM当场甩来一张竞品页的Lighthouse截图:首屏渲染 0.8s,我们是 2.6s。我盯着控制台看了三分钟,最后发现不是接口慢,也不是图片没压缩——是首页的 Vue 组件树太重,createApp 走完都快 400ms 了。
于是翻出压箱底的三套首屏优化方案:服务端渲染(SSR)、静态预渲染(Prerender)、以及我最近半年在项目里疯狂用的「轻量级客户端水合 + 骨架屏懒加载」。今天不讲理论,就聊真实项目里谁好使、谁坑多、谁改起来最省事。结论先放这儿:我基本不用 SSR 做活动页,也不碰 prerender 工具链;90% 的场景,我选骨架屏 + 客户端分块水合,代码少、调试快、上线不慌。
方案一:Vue SSR(Vite + vue-server-renderer)
我试过两次 SSR,一次是公司官网,一次是去年双十一大促页。第一次搭完发现 build 出来的 server bundle 居然要 12MB,Node 进程一跑就内存溢出;第二次换 Vite 插件 vite-plugin-ssr,倒是跑起来了,但热更新巨慢,改个 CSS 要等 8 秒,团队里新人直接放弃本地调试。
核心问题不是技术不行,而是「成本不对等」:为了把首屏从 2.3s 压到 1.1s,我得多维护一份 Node 服务、加一套构建时直出逻辑、处理所有 window/document 的兼容判断、还要给所有 API 加代理层防跨域……折腾完上线那天,CI 流水线卡在 SSR 构建环节,运维同学微信问我:「你这个 server entry 是不是又没导出 render 函数?」
代码示例?确实简单,但只是「看起来」简单:
import { createSSRApp } from 'vue'
import App from './App.vue'
export function createApp() {
const app = createSSRApp(App)
// 注意!这里不能调用任何 window 相关逻辑
return { app }
}
踩坑提醒:如果你的组件里有 mounted() { this.$nextTick(() => console.log(window.innerWidth)) },SSR 会直接报错。我改了 7 个组件才跑通,其中 3 个是因为用了第三方 UI 库的弹窗,它们内部写了 document.body.appendChild……
方案二:Prerender(vite-plugin-prerender)
这个我本来挺期待的——不用改服务端,build 时生成 HTML,还能保留 Vue 的响应式。结果第一次执行 vite build,跑了 4 分钟,生成 89 个 HTML 文件,其中 32 个是空的(因为路由守卫里写了异步权限校验,prerender 拿不到 token 就跳走了)。
更麻烦的是数据时效性。我们活动页有个倒计时,写死在 prerender 的 HTML 里,用户早上 10 点打开看到的是「距开始还剩 2 小时」,到了中午 12 点还是那句……最后只能在 mounted 里强行重置倒计时,但页面会闪一下,体验反而更差。
配置看着清爽:
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { vitePluginPrerender } from 'vite-plugin-prerender'
export default defineConfig({
plugins: [
vue(),
vitePluginPrerender({
routes: ['/', '/product', '/about'],
headless: true,
})
]
})
但实际要用?得自己 mock 所有接口返回值,还得写脚本定期 re-prerender。我们后端连 Swagger 都没配全,我哪来的 mock 数据?最后那个活动页,我手动写了 3 个 JSON 文件硬塞进去,上线前夜还在改路径别名……
方案三:骨架屏 + 客户端分块水合(我的主力方案)
这才是我在真实项目里反复验证过的路子。不碰服务端,不改构建链,就靠几行 JS 和一点 CSS 控制时机。核心思路就两句:先让骨架屏撑住视觉,再等关键数据回来,只水合真正需要的模块。
比如首页分三块:顶部 Banner(强依赖 API)、商品列表(可 fallback 到本地 mock)、底部导航(纯静态)。我就只对 Banner 和列表做水合,导航直接 SSR 输出 HTML(用 vite-plugin-html 注入)。
关键代码就这几行:
<!-- index.html -->
<div id="app">
<!-- 骨架屏,纯 CSS 实现,无 JS 依赖 -->
<div class="skeleton-banner"></div>
<div class="skeleton-list"></div>
</div>
<script type="module">
// 等数据 ready 后才启动 Vue
fetch('https://jztheme.com/api/home')
.then(res => res.json())
.then(data => {
// 移除骨架屏
document.querySelector('.skeleton-banner').remove()
document.querySelector('.skeleton-list').remove()
// 启动应用,只挂载需要的部分
import('./main-client.js').then(({ createApp }) => {
createApp().mount('#app')
})
})
</script>
好处太实在了:开发时完全不用切环境,本地 vite dev 就是最终效果;上线只要丢静态文件到 CDN;改需求时,加个新模块?复制粘贴骨架结构 + 改 fetch 地址就行。上个月临时加了个「限时红包」浮层,我 15 分钟搞定,连 Git 提交记录都懒得写详细描述。
当然也有小缺点:首屏白屏时间其实没变,只是「看起来」不白了。不过用户感知就是「内容来了」,比干等强太多。而且真遇到网络极差的情况,我还会 fallback 到 localStorage 缓存的旧数据——这点 SSR 和 prerender 根本做不到。
我的选型逻辑
看场景选,不是看文档吹。如果是长期运营的后台系统,我会推 SSR,毕竟 SEO 和初始加载都要顾;如果是企业官网,prerender 其实够用,只要后端能提供稳定 mock 接口;但如果是活动页、落地页、节日专题这种「生命周期短、迭代快、老板随时要改文案」的玩意儿——我闭着眼都选骨架屏 + 客户端水合。
原因很实在:我一个人负责前端,没后端排期配合,没 DevOps 搭 SSR 环境,也没时间写 mock 数据脚本。我要的是「改完代码,npm run build,拖到服务器,刷新页面就生效」。它不完美,但它让我下班不加班。
顺便说一句,现在连骨架屏我都懒得手写了,用 Tailwind 写个 animate-pulse + bg-gray-200 组合,10 行 CSS 解决所有场景。上周还顺手封装了个 useSkeleton Composable,传个 loading 状态就能自动切换 DOM……这些细节,后面博客再展开。
结尾
以上是我踩坑后的总结,希望对你有帮助。这个技巧的拓展用法还有很多,比如怎么结合 IntersectionObserver 做分屏水合、怎么用 AbortController 防止重复请求,后续会继续分享这类博客。
有更优的实现方式欢迎评论区交流。特别是如果你在 SSR 场景下有特别丝滑的工程化方案,求甩链接——我真想抄作业。
