我们团队最近把一个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的朋友一些参考。

一、问题现象

先说说我们遇到的问题现象。

上线之后,我们通过监控和日志,发现了以下问题:

  1. 服务端渲染耗时长:平均耗时800毫秒,P95耗时1.5秒,P99耗时2秒以上。而我们预期的是,服务端渲染应该在200毫秒以内。
  2. 首屏加载慢:虽然SSR直接返回HTML,但是因为服务端渲染耗时长,加上HTML体积大,首屏加载速度反而比CSR还慢。用户反馈页面打开慢,白屏时间长。
  3. Node.js CPU使用率高:Node.js服务器的CPU使用率经常在80%以上,并发稍微大一点就到100%,响应变慢,甚至超时。
  4. 内存占用高,有内存泄漏:Node.js进程的内存占用很高,而且随着时间推移,内存占用持续增长,有内存泄漏的迹象。每隔几天,进程就会因为内存溢出而崩溃,需要重启。
  5. 并发能力差:单台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性能有了大幅提升。下面是优化前后的对比数据:

指标优化前优化后提升幅度
服务端渲染平均耗时800ms150ms降低81%
服务端渲染P95耗时1500ms300ms降低80%
首屏加载时间3.5s1.5s降低57%
Node.js CPU使用率80%+30%左右降低60%+
单台并发能力50300+提升500%+
内存泄漏有,几天崩溃一次无,稳定运行彻底解决
HTML平均体积200KB80KB降低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的前端工程师,都能打造出高性能、高稳定性的服务端渲染应用,让用户享受更快、更好的浏览体验。