我们团队最近把一个Vue项目从客户端渲染(CSR)改成了服务端渲染(SSR),目的是提升首屏加载速度和SEO。
Vue SSR的原理是:在服务端把Vue组件渲染成HTML字符串,然后直接返回给浏览器,浏览器拿到HTML就能直接渲染,不需要等JS下载和执行完。这样,首屏加载速度更快,也有利于搜索引擎爬虫抓取内容。
我们用的是Nuxt.js,一个基于Vue的SSR框架,它封装了Vue SSR的很多细节,用起来比较方便。开发过程还算顺利,但是上线之后,我们发现SSR的性能很差:服务端渲染的平均耗时要800毫秒,有时候甚至要2秒以上,首屏加载速度反而比客户端渲染还慢。而且,Node.js服务器的CPU使用率很高,并发稍微大一点就扛不住。
这显然不对。SSR的目的是提升性能,结果反而更慢了。我们开始排查问题,做性能优化。经过一番努力,我们把服务端渲染的平均耗时从800毫秒降到了150毫秒左右,首屏加载速度也提升了50%以上,Node.js服务器的CPU使用率也降了下来。
今天,我想分享这次Vue SSR性能优化的实战经历,包括问题定位、优化措施、具体代码、以及最终效果。希望能给正在做或者准备做Vue SSR的朋友一些参考。
一、问题现象
先说说我们遇到的问题现象。
上线之后,我们通过监控和日志,发现了以下问题:
- 服务端渲染耗时长:平均耗时800毫秒,P95耗时1.5秒,P99耗时2秒以上。而我们预期的是,服务端渲染应该在200毫秒以内。
- 首屏加载慢:虽然SSR直接返回HTML,但是因为服务端渲染耗时长,加上HTML体积大,首屏加载速度反而比CSR还慢。用户反馈页面打开慢,白屏时间长。
- Node.js CPU使用率高:Node.js服务器的CPU使用率经常在80%以上,并发稍微大一点就到100%,响应变慢,甚至超时。
- 内存占用高,有内存泄漏:Node.js进程的内存占用很高,而且随着时间推移,内存占用持续增长,有内存泄漏的迹象。每隔几天,进程就会因为内存溢出而崩溃,需要重启。
- 并发能力差:单台Node.js服务器,只能支持几十的并发,再高就扛不住了。而我们的预期是,单台至少能支持几百的并发。
这些问题,严重影响了用户体验和系统稳定性。我们必须尽快优化,否则SSR就失去了意义,还不如改回CSR。
二、问题定位
遇到性能问题,第一步不是瞎优化,而是先做性能分析,定位瓶颈在哪里。
我们用了以下几种方法来定位问题:
方法1:加耗时统计,分析每个环节的耗时
我们在SSR的流程中,加了详细的耗时统计,包括:
- 接收请求到开始渲染的时间
- 创建Vue实例的时间
- 服务端渲染(renderToString)的时间
- 获取数据(API调用)的时间
- 生成HTML的时间
- 发送响应的时间
通过耗时统计,我们发现:
- 获取数据(API调用)的耗时最长,平均400毫秒,占总耗时的50%
- 服务端渲染(renderToString)的耗时次之,平均250毫秒,占总耗时的30%
- 创建Vue实例和生成HTML的耗时,平均100毫秒,占总耗时的12.5%
- 其他环节耗时较少
所以,主要的瓶颈在两个地方:数据获取和服务端渲染。
方法2:用Node.js的profiler分析CPU使用
我们用Node.js的内置profiler(--prof参数)和Chrome DevTools,分析了Node.js进程的CPU使用情况。
分析发现:
- CPU主要消耗在Vue的服务端渲染上,特别是虚拟DOM的创建和diff算法
- 其次是数据的序列化和反序列化
- 还有一部分消耗在模板编译上(虽然Nuxt.js已经在构建时预编译了模板,但是有些动态组件还是运行时编译)
方法3:用heap snapshot分析内存使用
我们用Chrome DevTools的heap snapshot功能,分析了Node.js进程的内存使用情况。
分析发现:
- 内存主要被Vue组件实例占用,而且很多组件实例在渲染完成后没有被释放,导致内存泄漏
- 其次是缓存的数据,有些数据缓存了但是没有设置过期时间,越积越多
- 还有一部分是模块缓存,Node.js的require会缓存模块,这个是正常的
方法4:对比测试,排除变量
我们做了一系列对比测试,排除变量:
- 用最简单的页面(只有一个div)测试,发现渲染耗时只有几毫秒,说明框架本身没问题
- 用没有数据请求的页面测试,发现渲染耗时降到了200毫秒,说明数据获取确实是大瓶颈
- 用不同复杂度的页面测试,发现页面越复杂,组件越多,渲染耗时越长,说明组件复杂度也是影响因素
通过这些分析,我们对性能问题有了比较清晰的认识,然后开始针对性地优化。
三、优化措施
下面说说我们具体做了哪些优化措施。
优化1:优化数据获取,减少API调用耗时
数据获取是最大的瓶颈,我们首先优化这个。
措施1.1:并行请求数据
原来的代码,数据请求是串行的:先请求A,再请求B,再请求C。总耗时是三个请求的耗时之和。
我们改成了并行请求:用Promise.all,同时请求A、B、C。总耗时是三个请求中最慢的那个的耗时。
// 原来:串行
async asyncData({ app }) {
const a = await app.$api.getA();
const b = await app.$api.getB();
const c = await app.$api.getC();
return { a, b, c };
}
// 优化后:并行
async asyncData({ app }) {
const [a, b, c] = await Promise.all([
app.$api.getA(),
app.$api.getB(),
app.$api.getC(),
]);
return { a, b, c };
}这个优化,效果很明显,数据获取的平均耗时从400毫秒降到了200毫秒。
措施1.2:减少不必要的API调用
我们审查了每个页面的API调用,发现有些API调用是不必要的:
- 有些数据,多个页面都要用到,但是每个页面都单独请求一次,重复请求
- 有些数据,页面上根本没用到,但是代码里还是请求了
- 有些数据,可以在客户端请求,不需要在服务端请求
我们做了以下优化:
- 把公共数据(比如用户信息、导航菜单、配置等)放到Vuex的store里,在服务端初始化的时候请求一次,后续页面直接从store里取,不需要重复请求
- 删除了页面上没用到的API调用
- 把非首屏必需的数据,改成在客户端请求(mounted的时候请求),减少服务端渲染的耗时
通过这些优化,平均每个页面的API调用次数从5次降到了2次,数据获取的耗时又降了一些。
措施1.3:API响应缓存
对于一些不经常变化的数据(比如商品分类、文章列表、配置等),我们在Node.js层加了缓存,缓存API的响应结果。下次请求同样的数据,直接从缓存里取,不需要再调用后端API。
我们用的是lru-cache,一个轻量级的缓存库,支持设置最大缓存数量和过期时间。
const LRU = require('lru-cache');
const cache = new LRU({
max: 500, // 最大缓存数量
maxAge: 1000 * 60 * 5, // 过期时间5分钟
});
async function getDataWithCache(key, fetchFn) {
const cached = cache.get(key);
if (cached) {
return cached;
}
const data = await fetchFn();
cache.set(key, data);
return data;
}加了缓存之后,很多API调用直接命中缓存,耗时从几百毫秒降到了几毫秒。而且,也减轻了后端API的压力。
措施1.4:优化后端API的性能
有些API调用耗时很长,是因为后端API本身性能差。我们和后端团队一起,优化了一些慢API:
- 给数据库查询加索引
- 优化SQL语句,避免全表扫描
- 加缓存,减少数据库查询
- 对一些复杂的查询,做预计算或者异步处理
后端API优化之后,API调用的平均耗时又降了一些。
优化2:优化服务端渲染,减少renderToString耗时
服务端渲染(renderToString)是第二大瓶颈,我们接着优化这个。
措施2.1:减少组件复杂度
我们分析了渲染耗时最长的几个页面,发现这些页面的组件都很复杂,嵌套很深,组件数量很多。
我们做了以下优化:
- 拆分大组件,把一个大组件拆成多个小组件,但是只在需要的时候渲染(v-if)
- 减少不必要的嵌套,有些包装组件是多余的,可以去掉
- 把非首屏必需的组件,改成客户端渲染(用<client-only>标签包裹),服务端不渲染,减少服务端渲染的耗时
- 优化列表渲染,用v-for的时候加key,避免不必要的重新渲染
特别是<client-only>标签,效果很明显。很多页面上的非核心组件(比如侧边栏、推荐列表、广告等),不需要在服务端渲染,用<client-only>包裹之后,服务端渲染的耗时降了很多。
<template>
<div>
<!-- 首屏核心内容,服务端渲染 -->
<div class="content">{{ article.content }}</div>
<!-- 非核心内容,客户端渲染 -->
<client-only>
<sidebar />
<recommend-list />
</client-only>
</div>
</template>措施2.2:避免运行时编译模板
Vue的模板,需要编译成render函数才能执行。如果在运行时编译模板,会很耗性能。Nuxt.js默认在构建时预编译模板,但是有些情况还是会运行时编译:
- 用了动态组件,而且组件的模板是字符串
- 用了v-html,而且内容里包含Vue模板
- 某些第三方组件,用了运行时编译
我们检查了代码,把所有运行时编译的情况都改掉了,确保所有模板都在构建时预编译。这样,服务端渲染的时候,直接执行render函数,不需要编译模板,性能提升了不少。
措施2.3:优化Vuex store
Vuex store在服务端渲染的时候,每个请求都要创建一个新的store实例,避免数据污染。如果store很复杂,创建实例和初始化数据的耗时也会很长。
我们做了以下优化:
- 精简store,把不需要在服务端用的module,放到客户端才加载
- 优化store的初始化,只初始化首屏必需的数据,其他数据在客户端加载
- 避免在store的getter里做复杂的计算,getter会被频繁调用,复杂计算会影响性能
- 用mapState、mapGetters等辅助函数,减少重复代码
措施2.4:用render函数替代模板
对于一些性能敏感的组件,我们用render函数替代了模板。render函数比模板更高效,因为它不需要编译,直接就是JavaScript代码,执行速度更快。
当然,render函数的可读性和可维护性比模板差,所以我们只在少数性能敏感的组件里用,大部分组件还是用模板。
优化3:优化HTML输出,减少体积
服务端渲染生成的HTML体积,如果太大,传输和解析都会慢。我们也优化了HTML输出。
措施3.1:压缩HTML
Nuxt.js支持压缩HTML输出,我们开启了这个功能。压缩之后,HTML体积减少了30%左右,传输速度更快。
在nuxt.config.js里配置:
module.exports = {
render: {
compressor: { threshold: 0 }, // 开启gzip压缩
},
};措施3.2:减少内联的数据
Nuxt.js默认会把服务端获取的数据,内联到HTML里(通过window.NUXT),供客户端hydration使用。如果数据量很大,内联的数据会让HTML体积变大。
我们做了以下优化:
- 只内联首屏必需的数据,其他数据在客户端重新请求
- 对内联的数据做精简,去掉不需要的字段
- 对于大数据量的列表,只内联前几页,其他页在客户端加载
措施3.3:优化CSS输出
Nuxt.js默认会把CSS内联到HTML里,这样首屏不需要等CSS文件下载,渲染更快。但是,如果CSS体积太大,内联也会让HTML变大。
我们做了以下优化:
- 精简CSS,去掉没用的样式
- 用PurgeCSS,自动去掉没用到的CSS
- 把公共的、大的CSS,抽成单独的文件,用link标签引入,不内联(首屏必需的CSS还是内联)
优化4:优化Node.js服务器,提升并发能力
除了SSR本身的优化,我们也优化了Node.js服务器的配置,提升并发能力。
措施4.1:用cluster模式
Node.js是单线程的,一个进程只能用一个CPU核心。我们的服务器是多核的,所以用了cluster模式,启动多个Node.js进程,充分利用多核CPU。
Nuxt.js内置了cluster模式的支持,用pm2启动的时候,配置instances为max,就会自动根据CPU核心数启动多个进程。
pm2 start npm --name "nuxt-app" -- start -i max用了cluster模式之后,并发能力提升了好几倍,CPU使用率也更均衡了。
措施4.2:优化内存使用,修复内存泄漏
我们之前发现有内存泄漏,通过heap snapshot分析,找到了泄漏的原因:
- 有些全局变量,缓存了数据但是没有清理
- 有些事件监听器,没有正确移除
- 有些Vue组件实例,在渲染完成后没有被正确销毁
我们做了以下修复:
- 全局缓存用lru-cache,设置最大数量和过期时间,自动清理
- 事件监听器,在组件销毁的时候(beforeDestroy)正确移除
- 确保每个请求的Vue实例,在渲染完成后被正确销毁,不要持有引用
- 定期重启进程,作为兜底方案(用pm2的maxmemoryrestart配置,内存超过阈值自动重启)
修复了内存泄漏之后,Node.js进程的内存占用稳定了,不再持续增长,也不会因为内存溢出而崩溃了。
措施4.3:加反向代理和缓存
我们在Node.js服务器前面,加了Nginx反向代理,做了以下优化:
- 静态资源(JS、CSS、图片等)由Nginx直接处理,不经过Node.js
- 开启gzip压缩,减少传输体积
- 对一些不经常变化的页面,做页面缓存,缓存HTML响应,下次请求直接返回缓存,不需要Node.js渲染
- 限流和熔断,保护Node.js服务器不被大流量打垮
页面缓存的效果特别明显。对于一些不经常变化的页面(比如文章详情、商品详情),我们缓存了渲染好的HTML,缓存时间5分钟。这样,大部分请求都直接命中缓存,不需要Node.js渲染,Node.js的压力大大减轻。
措施4.4:合理设置超时和错误处理
我们给SSR的每个环节都设置了超时时间:
- API调用超时:5秒
- 服务端渲染超时:3秒
- 总超时:8秒
如果超时了,就降级到客户端渲染,返回一个空的HTML骨架,让客户端去渲染。这样,即使SSR出问题了,页面也能打开,只是首屏慢一点,不会白屏。
同时,我们也加了完善的错误处理,SSR出错的时候,记录错误日志,并且降级到客户端渲染,不会因为一个页面的错误导致整个进程崩溃。
优化5:其他优化
除了上面的优化,我们还做了一些其他的优化:
措施5.1:升级Nuxt.js和Vue的版本
我们把Nuxt.js和Vue都升级到了最新的稳定版本,新版本有很多性能优化和bug修复,升级之后性能有一定提升。
措施5.2:用HTTP/2
我们在Nginx上开启了HTTP/2,HTTP/2支持多路复用,能更快地加载多个资源,首屏加载速度有提升。
措施5.3:优化图片
页面上的图片,我们做了优化:
- 用WebP格式,体积更小
- 用响应式图片,根据屏幕大小加载不同尺寸的图片
- 懒加载,非首屏的图片延迟加载
- 压缩图片,减少体积
图片优化之后,页面加载速度又提升了一些。
措施5.4:预加载和预获取
我们用了<link rel="preload">和<link rel="prefetch">,预加载首屏必需的资源,预获取可能用到的资源,提升加载速度。
四、优化效果
经过以上一系列的优化,我们的Vue SSR性能有了大幅提升。下面是优化前后的对比数据:
| 指标 | 优化前 | 优化后 | 提升幅度 |
|---|---|---|---|
| 服务端渲染平均耗时 | 800ms | 150ms | 降低81% |
| 服务端渲染P95耗时 | 1500ms | 300ms | 降低80% |
| 首屏加载时间 | 3.5s | 1.5s | 降低57% |
| Node.js CPU使用率 | 80%+ | 30%左右 | 降低60%+ |
| 单台并发能力 | 50 | 300+ | 提升500%+ |
| 内存泄漏 | 有,几天崩溃一次 | 无,稳定运行 | 彻底解决 |
| HTML平均体积 | 200KB | 80KB | 降低60% |
可以看到,各项指标都有了大幅提升。特别是服务端渲染耗时,从800毫秒降到了150毫秒,降了80%以上。首屏加载时间也从3.5秒降到了1.5秒,用户体验提升明显。
而且,Node.js服务器的稳定性也大大提升,不再有内存泄漏,不再频繁崩溃,运维成本降低了很多。
现在,我们的SSR真正发挥了它的价值:首屏加载快,SEO友好,同时性能也很好,能支撑较大的并发。
五、经验总结
最后,总结一下这次Vue SSR性能优化的经验。
经验一:先定位,再优化
遇到性能问题,不要上来就瞎优化,先做性能分析,定位瓶颈在哪里。不同的项目,性能瓶颈可能不一样,有的是数据获取慢,有的是渲染慢,有的是服务器配置问题。只有定位了瓶颈,才能针对性地优化,事半功倍。
经验二:数据获取往往是最大的瓶颈
在SSR项目中,数据获取(API调用)往往是最大的瓶颈,因为网络IO是最慢的。优化数据获取,通常能带来最大的性能提升。
优化数据获取的方法:
- 并行请求,不要串行
- 减少不必要的API调用
- 加缓存,减少重复请求
- 优化后端API的性能
- 非首屏必需的数据,放到客户端请求
经验三:合理利用客户端渲染,不要什么都在服务端渲染
SSR的目的是提升首屏加载速度和SEO,但是不是所有内容都需要在服务端渲染。非首屏必需的内容、交互复杂的内容、对SEO不重要的内容,都可以放到客户端渲染,减少服务端渲染的耗时和压力。
Nuxt.js的<client-only>标签,就是用来做这个的。合理使用,能大幅降低服务端渲染的复杂度。
经验四:缓存是提升性能的利器
缓存是提升性能最有效的手段之一。在SSR项目中,可以在多个层级加缓存:
- API响应缓存:缓存后端API的响应
- 组件缓存:缓存Vue组件的渲染结果
- 页面缓存:缓存整个页面的HTML
- 静态资源缓存:缓存JS、CSS、图片等静态资源
合理使用缓存,能大幅减少重复计算和重复请求,提升性能,降低服务器压力。
经验五:Node.js服务器的优化也很重要
SSR的性能,不仅仅是渲染代码的性能,Node.js服务器的配置和优化也很重要。用cluster模式充分利用多核CPU,优化内存使用避免泄漏,加反向代理和页面缓存,合理设置超时和错误处理,这些都能大幅提升SSR的整体性能和稳定性。
经验六:做好降级方案,确保稳定性
SSR可能会出问题,比如API超时、渲染出错、服务器过载等。一定要做好降级方案,SSR出问题的时候,降级到客户端渲染,确保页面能打开,不会白屏。这样,即使SSR出问题了,也不会影响用户的基本使用。
经验七:持续监控和优化
性能优化不是一次性的,而是持续的。上线之后,要持续监控SSR的性能指标(渲染耗时、首屏时间、CPU使用率、内存使用率等),发现问题及时优化。而且,随着业务的发展,页面会越来越复杂,新的性能问题会不断出现,需要持续优化。
结语
Vue SSR是一个很好的技术,能提升首屏加载速度和SEO,但是它也带来了新的性能挑战。如果不做优化,SSR可能反而比CSR还慢,失去了它的意义。
我们这次的性能优化,从800毫秒降到150毫秒,降了80%以上,效果很明显。其实,大部分优化措施都不是什么高深的技术,都是一些基础的最佳实践:并行请求、减少不必要的调用、加缓存、合理利用客户端渲染、优化服务器配置等。关键是要先定位瓶颈,然后针对性地优化,持续迭代。
如果你正在做或者准备做Vue SSR,希望我们的经验能给你一些参考,让你少踩坑,少走弯路。
最后,用一句话总结:"SSR性能优化,没有银弹,只有持续的分析、优化和迭代。先定位瓶颈,再针对性优化,缓存和降级是两大法宝,持续监控是长期保障。"
愿每一个做SSR的前端工程师,都能打造出高性能、高稳定性的服务端渲染应用,让用户享受更快、更好的浏览体验。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录