Code前端首页关于Code前端联系我们

为什么我把Vue3 SPA改成SSR之后,反而更慢甚至经常崩?

terry 11小时前 阅读数 177 #Vue

你是不是和我身边几个前端朋友一样,刚接触Vue3 SSR就踩了这个坑?原本是听说SSR能解决首屏加载慢、SEO优化不起来的问题,兴冲冲照着官方文档或者零散教程搭了个demo,上线后一测数据,首屏加载居然还不如之前优化过的静态SPA?甚至刚有人多点开几个带大请求量的页面,服务器就直接503?别急,这个问题真的不是SSR本身没用,而是大多数人第一次做Vue3 SSR时,没搞懂它和SPA的核心逻辑差异,踩了一堆隐性的坑。

先搞清楚:Vue3 SSR和SPA的底层逻辑到底差在哪?

很多人以为SSR就是“服务器把页面拼好再给浏览器”这么简单,但如果只停留在这个认知层面,肯定踩坑踩到底,其实可以把它们比作两个不同的“外卖配送员”,配送流程和核心约束完全不一样:

SPA的配送员是“轻装上阵型”:服务器只给你一份“原材料清单”(HTML骨架+空根div+打包好的超大JS/CSS文件),剩下的所有“做菜”工作——比如拉取数据、渲染组件、绑定事件——全是在用户的设备(厨房)里完成的,优点是服务器压力极小,只需要管静态文件分发就行,缺点是用户拿到手的是空白页或者骨架屏,得等JS拉取解析完、数据接口跑通,才能看到完整内容,这就是首屏慢的根源,而且搜索引擎的爬虫大多懒得等你加载完JS,所以自然抓不到内容。

Vue3 SSR的配送员则是“后厨帮厨型”:用户点了单之后,服务器得临时找食材(拉接口数据)、切菜(执行Vue组件的模板渲染逻辑,生成带完整内容的HTML字符串),把半熟或者全熟的菜(首屏可直接渲染的HTML)先送到用户手里,用户拿到就能直接看到闻到(显示完整首屏),然后后厨再把剩下的餐具(事件绑定代码、后续路由切换的JS)慢慢送过来,但这个帮厨的问题也很明显:后厨(服务器)是有上限的,临时找食材和切菜都要花资源和时间,要是用户同时点太多复杂的菜,后厨就忙不过来了;而且帮厨做菜的流程如果有问题(比如组件里有不兼容服务端运行的代码、数据请求重复/冗余/太慢),要么菜慢到凉透(首屏加载反超SPA),要么菜做坏了(服务器崩)。

这个底层差异是所有Vue3 SSR优化或者排坑的基础,下面我们就从这个差异出发,逐个拆解常见的“变慢/崩”原因,顺便给你对应的解决方案。

服务器端渲染时,拉取了太多没必要的数据或者接口太慢

这是新手最容易犯的第一个大错,甚至我见过有3年经验的前端,第一次做Vue3 SSR时也踩了,你想想,SPA里你拉取数据是在用户端,不管拉取多少、接口响应多慢,最多就是单个用户的页面卡一下,不会影响其他人;但在SSR里,拉取数据是在服务器端,所有请求的接口响应时间、数据大小都会直接累加在首屏TTFB(首字节时间,SSR的核心指标之一,TTFB越短越好,一般建议控制在150ms以内)上,要是同时有100个用户请求,服务器还要同时扛100个接口请求的并发压力,接口慢一点或者量大一点,TTFB直接飙升,甚至数据库都有可能先扛不住挂掉,连累服务器也崩。

那怎么解决这个问题呢?核心思路是“精简首屏数据+优化数据请求流程”:

精简首屏数据,只给用户第一眼能看到的

很多SPA里的组件,为了省事,会一次性拉取页面所有相关的静态/动态数据,比如电商商品详情页,可能拉取了商品基本信息、价格、SKU、评论列表、推荐商品、店铺信息……一堆,但其实用户第一眼只会看商品图、标题、价格、加入购物车按钮,剩下的评论、推荐、店铺信息完全可以放到客户端去拉取。

那怎么判断哪些数据是“首屏必需”的呢?你可以用浏览器的开发者工具里的“Performance”面板,先测一下优化后的SPA的首屏“首次内容绘制(FCP)”和“最大内容绘制(LCP)”,看看在FCP之前或者LCP对应的元素渲染之前,必须要有哪些数据,把这些数据划到SSR端拉取的范围,剩下的全部用Vue的onMounted钩子或者条件渲染放到客户端。

还有一个细节要注意:电商类或者内容类的页面,可能会有分页组件,千万不要在SSR端拉取所有分页数据,只拉取第一页的就行,后续分页点击全是客户端操作。

优化数据请求的流程

除了精简数据,请求流程的优化也很关键,能直接降低TTFB和服务器压力:

  1. 并行拉取首屏必需的接口,不要串行:很多人写SSR数据拉取代码时,会习惯性地用async/await把所有接口串起来,比如先拉取商品ID,再拉取商品信息,再拉取库存……但如果这些接口之间没有依赖关系(比如商品ID是从路由参数里拿到的,不是从其他接口里获取的),一定要用Promise.all把它们并行起来,能省不少时间。 比如这样写是错误的:
    // 错误示范:串行请求
    const { data: productId } = await axios.get('/api/get-current-product-id');
    const { data: productInfo } = await axios.get(`/api/product/${productId}`);
    const { data: stock } = await axios.get(`/api/stock/${productId}`);

    这样写才对:

    // 正确示范:并行请求无依赖的接口
    const productId = route.params.id;
    const [productRes, stockRes] = await Promise.all([
      axios.get(`/api/product/${productId}`),
      axios.get(`/api/stock/${productId}`)
    ]);
    const productInfo = productRes.data;
    const stock = stockRes.data;
  2. 给接口加Redis缓存,优先用缓存:如果接口的数据不是实时变化的(比如商品信息一天可能只变一次,或者文章内容永远不变),一定要给它加一层Redis缓存,缓存时间根据业务场景来定,比如商品信息缓存1小时,文章内容缓存24小时,这样的话,第一个用户请求时,服务器会拉取接口并把数据存到Redis里,后面99个用户在缓存有效期内请求,直接从Redis里拿数据就行,TTFB能降到几十毫秒甚至更低,服务器的并发压力也会小很多。 这里要注意:缓存的key一定要唯一,要包含路由参数、用户的身份权限(比如会员看到的价格和普通用户不一样,缓存key就要加上会员等级的标识)等所有会影响数据的因素,不然会出现“会员看到普通用户价格”或者“看到旧数据”的问题。
  3. 和后端约定“首屏专用接口”,不要复用SPA的大接口:很多SPA的接口会返回一堆前端用不到的字段,比如数据库的ID、创建时间、更新时间、内部状态码……这些字段在SSR端拉取时,不仅会增加数据传输的大小,还会增加数据库的查询压力,可以和后端商量一下,专门做几个“首屏专用接口”,只返回首屏必需的字段,能省不少事儿。
  4. 限制接口的超时时间:万一后端的接口挂了或者响应特别慢,总不能让SSR服务器一直等下去吧?一定要给SSR端的接口请求加一个超时时间,比如100ms或者200ms,超时之后直接返回一个默认的空数据或者骨架屏对应的占位数据,然后在客户端再重新拉取一次接口,这样的话,就算后端接口出问题,至少用户的页面不会一直白屏或者503。

组件里有不兼容服务端运行的代码

Vue3的SSR是基于Node.js环境的,和浏览器环境有很多不一样的地方,比如浏览器里有windowdocumentnavigator这些全局对象,Node.js里没有;浏览器里有setTimeoutsetInterval这些定时器,但SSR里执行定时器根本没用(因为组件渲染完就会生成HTML字符串,不会等定时器触发),还可能导致内存泄漏;还有一些第三方库,比如jQuery、ECharts(旧版本或者没有SSR兼容版本的)、swiper(旧版本),它们的内部代码直接用了浏览器的全局对象,在Node.js里运行会直接报错,导致服务器返回500。

这也是新手经常踩的坑,很多人直接把SPA里的代码复制粘贴过来,根本没检查过有没有不兼容的地方,结果上线后一运行就崩,那怎么排查和解决这个问题呢?

排查不兼容代码的方法

  1. 开发环境用SSR模式运行,仔细看控制台报错:Vue3的官方脚手架(Vite或者Vue CLI)都支持SSR模式的开发环境,你可以先在开发环境里跑一下,看看有没有报错,ReferenceError: window is not defined”、“ReferenceError: document is not defined”,这些都是明显的不兼容浏览器代码的提示。
  2. 检查组件里的钩子函数:Vue3的钩子函数里,只有beforeCreatecreated是在服务器端和客户端都会执行的,其他的钩子(比如beforeMountmountedbeforeUpdateupdatedbeforeUnmountunmounted)都只在客户端执行,所有涉及浏览器全局对象、定时器、DOM操作、第三方库初始化的代码,都不能放在beforeCreatecreated里,必须放到onMounted里,或者用onBeforeMount?不对不对,onBeforeMount在SSR模式下也不会执行哦,官方文档里写得很清楚,只有setup()里的同步代码、beforeCreatecreated会在服务端执行,其他的客户端钩子全跳过。
  3. 检查第三方库:在引入第三方库之前,一定要先去它的官方文档或者GitHub仓库里看看,有没有明确支持Vue3 SSR的说明,如果没有的话,尽量不要用,或者用Vue的<ClientOnly>组件把它包起来,只在客户端渲染。
  4. 检查setup()里的代码setup()里的代码如果是同步的,也会在服务端执行,所以这里也不能直接用浏览器的全局对象,必须做判断。

解决不兼容代码的方法

  1. <ClientOnly>组件:最常用的方法:Vue3的官方SSR插件(比如Vite的vite-plugin-ssr或者Vue官方的@vitejs/plugin-vue-ssr)一般都会内置<ClientOnly>组件,你可以直接用它把那些不兼容服务端的组件或者代码包起来,只在客户端渲染。 比如这样:

    <template>
      <div>
        <!-- 首屏必需的内容,服务端渲染 -->
        <h1>{{ productInfo.title }}</h1>
        <img :src="productInfo.image" alt="">
        <!-- 第三方库或者不兼容的内容,只在客户端渲染 -->
        <ClientOnly>
          <Swiper :slides="productInfo.slides" />
          <ECharts :data="productInfo.chartData" />
          <div id="client-only-div"></div>
        </ClientOnly>
      </div>
    </template>

    这里要注意:<ClientOnly>组件里面的内容,在服务端渲染时会被替换成空或者一个占位的div,所以如果里面的内容是首屏必需的,就不能用这个方法,必须找兼容SSR的替代方案。

  2. 判断环境,只在客户端执行代码:如果不想用<ClientOnly>组件,或者代码是逻辑层面的(不是组件或者DOM操作),可以判断一下当前是服务端环境还是客户端环境,只在客户端执行。 判断环境的方法有很多种,

    // 方法一:用Vue的import.meta.env.SSR(Vite专用,开发和生产环境都能用)
    if (!import.meta.env.SSR) {
      // 这里的代码只在客户端执行
      document.getElementById('client-only-div').innerHTML = 'Hello Client!';
      window.addEventListener('scroll', handleScroll);
    }
    // 方法二:用process.client(Nuxt3专用)
    if (process.client) {
      // 这里的代码只在客户端执行
    }
    // 方法三:用typeof判断全局对象(通用,但要注意顺序,先判断window再判断document)
    if (typeof window !== 'undefined' && typeof document !== 'undefined') {
      // 这里的代码只在客户端执行
    }

    这里推荐用第一种或者第二种方法,因为第三种方法有时候会被一些Node.js的库或者插件模拟出window对象,导致判断错误。

  3. 找兼容SSR的第三方库替代方案:如果某个第三方库是首屏必需的,不能用<ClientOnly>组件,就必须找兼容Vue3 SSR的替代方案。 比如ECharts的话,可以用echarts-for-vue3-ssr或者官方的echarts + ssr-json;swiper的话,最新版本(Swiper 9+)已经支持Vue3 SSR了,但要注意配置;jQuery的话,尽量不要用,用Vue的原生指令或者方法替代就行。

  4. 避免在服务端执行定时器:不管是setTimeout还是setInterval,在服务端执行都没用,还可能导致内存泄漏,所以一定要把它们放到onMounted里,或者加上环境判断。

服务器端没有合理配置,并发能力太差

就算你把数据请求优化好了,把不兼容的代码都改了,如果服务器端没有合理配置,还是会慢甚至崩,很多人第一次做Vue3 SSR时,可能就用Node.js的原生http模块或者Express/Koa开了一个单进程的服务器,上线后一遇到并发请求,单进程就忙不过来了,直接卡壳。

那怎么合理配置服务器端呢?这里分两个层面:Node.js层面和服务器硬件层面。

Node.js层面的配置

Node.js是单线程单进程的,虽然它的事件循环机制能处理很多并发的I/O请求(比如接口请求、文件读取),但如果遇到CPU密集型的任务(比如复杂的组件模板渲染逻辑、图片压缩),单线程就会被占满,其他请求只能排队,导致TTFB飙升,Node.js层面的配置核心是“充分利用多核CPU,开启多进程”:

  1. 用PM2开启多进程:PM2是Node.js最常用的进程管理工具,它能帮你开启和CPU核心数一样多的Node.js进程,每个进程处理一部分请求,充分利用多核CPU的性能。 你需要安装PM2:
    npm install pm2 -g

    创建一个PM2的配置文件ecosystem.config.js

    module.exports = {
      apps: [{
        name: 'vue3-ssr-app', // 应用名称
        script: './server.js', // 服务器入口文件
        instances: 'max', // 开启的进程数,max表示和CPU核心数一样多
        exec_mode: 'cluster', // 集群模式,开启负载均衡
        env: {
          NODE_ENV: 'development'
        },
        env_production: {
          NODE_ENV: 'production'
        },
        error_file: './logs/error.log', // 错误日志路径
        out_file: './logs/out.log', // 输出日志路径
        log_date_format: 'YYYY-MM-DD HH:mm:ss', // 日志时间格式
        max_memory_restart: '1G', // 单个进程占用内存超过1G就自动重启
        watch: false, // 生产环境不要开启watch,不然每次修改文件都会重启
        autorestart: true // 进程崩溃后自动重启
      }]
    }

    用PM2启动应用:

    pm2 start ecosystem.config.js --env production

    这里要注意:如果你的服务器硬件配置比较低(比如只有1核2G内存),instances不要设成max,设成1或者2就行,不然进程之间会互相争抢内存,反而更慢。

  2. 开启静态文件缓存:虽然SSR的核心是生成动态HTML,但应用里还是有很多静态文件的,比如JS、CSS、图片、字体……这些静态文件不需要每次都从服务器磁盘里读取,可以用Nginx(后面会讲)或者Express/Koa的静态文件中间件开启缓存,缓存时间可以设得久一点,比如JS/CSS缓存7天,图片字体缓存30天。
  3. 开启Gzip或者Brotli压缩:Gzip或者Brotli压缩能把HTML、JS、CSS等文本文件的大小压缩到原来的30%甚至更小,能大幅降低数据传输的时间,提升首屏加载速度,可以用Nginx开启(推荐),或者用Express/Koa的中间件开启。
  4. 优化内存占用,避免内存泄漏:SSR的Node.js进程很容易出现内存泄漏,比如<ClientOnly>组件里的事件监听器没有在组件卸载时移除、定时器没有清除、Redis的连接没有及时释放……这些都会导致内存占用越来越大,最后进程崩溃。 怎么排查内存泄漏呢?你可以用PM2的pm2 monit命令查看各个进程的内存占用情况,如果某个进程的内存占用一直在上升,没有下降的趋势,就说明有内存泄漏,也可以用Chrome DevTools的Memory面板,连接到Node.js进程,进行内存快照分析。

服务器硬件和架构层面的配置

如果你的应用访问量特别大(比如每天几百万甚至几千万的PV),光靠PM2开启多进程可能还不够,还需要在硬件和架构层面做优化:

  1. 升级服务器硬件配置:这个是最直接的方法,比如从1核2G升级到4核8G,或者从2核4G升级到8核16G,CPU和内存越多,能处理的并发请求就越多。
  2. 用Nginx做反向代理和负载均衡:Nginx是最常用的Web服务器和反向代理服务器,它能处理静态文件分发、Gzip/Brotli压缩、负载均衡等工作,性能比Node.js强很多。 就是用户的请求先打到Nginx上,Nginx判断一下是静态文件还是动态HTML请求:如果是静态文件,直接从Nginx的缓存里或者服务器磁盘里返回,不用经过Node.js;如果是动态HTML请求,再转发给后端的多个Node.js进程(负载均衡)。 这样的话,Node.js只需要处理动态HTML的生成工作,压力会小很多,而且Nginx的负载均衡能把请求均匀地分配给各个Node.js进程,充分利用资源。
  3. 用CDN加速静态文件:如果你的应用有很多图片、视频、字体等大文件,可以把它们放到CDN上,CDN会把这些文件分发到全国各地的节点上,用户访问时直接从最近的节点获取,能大幅降低数据传输的时间。
  4. 用Redis做会话存储和数据缓存:刚才讲数据请求的时候已经提到过Redis做数据缓存,除此之外,Redis还可以做会话存储(比如用户登录的token),因为Node.js的多进程集群模式下,多个进程之间不能共享内存里的会话数据,必须用Redis或者数据库来共享。

没有合理使用流式渲染

流式渲染是Vue3 SSR的一个新特性,很多人可能没听说过,或者听说过但不知道怎么用,它的作用是“边生成HTML边发送给浏览器”,而不是等整个HTML字符串生成完再一起发送,能让用户的首屏显示时间更早,提升用户体验。

传统的非流式渲染,服务器需要先拉取所有首屏必需的数据,然后生成整个HTML字符串,最后再一起发送给浏览器,用户拿到的是完整的HTML,但TTFB会比较长;而流式渲染,服务器可以先发送HTML的头部(比如<head>标签里的meta、title、CSS链接),然后边拉取数据边生成HTML的主体部分,边生成边发送,用户拿到头部后就能先解析CSS,显示一些背景色或者占位符,然后逐步显示完整内容,TTFB会非常短(几乎是立即返回),用户的感知首屏加载速度会快很多。

那怎么开启Vue3的流式渲染呢?不同的SSR框架有不同的方法:

  1. 用Vite的vite-plugin-ssrvite-plugin-ssr默认就支持流式渲染,你只需要在服务器入口文件里用renderToStream代替renderToString就行。
  2. 用Vue官方的@vue/server-renderer:直接调用renderer.renderToStream(app)方法,然后把返回的流管道到HTTP响应对象里。
  3. 用Nuxt3:Nuxt3默认也支持流式渲染,你只需要在nuxt.config.ts里开启ssr: trueexperimental: { renderJsonPayloads: false }就行(不过最新版本的Nuxt3可能已经默认开启了,不需要手动配置)。

这里要注意:流式渲染也有一个小缺点,就是如果HTML的头部和主体部分之间有依赖关系(比如主体部分的某个组件需要用到头部里的某个CSS变量),可能会出现样式闪烁的问题,不过这个问题可以通过内联首屏必需的CSS来解决。

写在最后:Vue3 SSR不是万能药,要不要用得看业务场景

最后想跟大家说一句:Vue3 SSR不是万能药,不是所有的应用都适合改成SSR,要不要用SSR,得看你的业务场景:

  • 如果你的应用是内容类(比如新闻网站、博客、电商商品详情页)、企业官网,对首屏加载速度和SEO优化要求很高,那SSR非常适合你;
  • 如果你的应用是工具类(比如在线文档、在线PS、后台管理系统)、社交类的动态页面(比如用户的个人中心,需要登录才能看到),对首屏加载速度和SEO优化要求不高,或者大部分内容是登录后才能看到的,那用SPA加上预渲染(Prerender)就行,不需要用SSR,因为预渲染比SSR简单很多,服务器压力也小很多。

预渲染是什么呢?就是在打包应用的时候,提前把一些静态页面(比如首页、关于我们页、联系我们页)生成好完整的HTML字符串,然后和其他静态文件一起部署到服务器上,用户访问这些页面时,直接返回提前生成好的HTML,和SSR的效果差不多,但不需要服务器实时生成,压力极小。

好了,以上就是我对“为什么我把Vue3 SPA改成SSR之后,反而更慢甚至经常崩?”这个问题的全部解答,希望能帮到你,如果你还有其他关于Vue3 SSR的问题,欢迎在评论区留言讨论。

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门