✨ Nuxt 混合渲染实践: MemOS前端体验深度优化指南

从0到1的项目,可复用的性能优化手段以及全球化策略, memos首页地址: memos.openmem.net

1.背景

1.1 CSG,SSG,SSR的区别

首先得了解前端三种渲染模式的区别,前端渲染模式的选择直接影响用户体验、开发成本和运维压力。

维度 CSG SSG SSR
全称 Client-Side Generation Static Site Generation Server-Side Rendering
渲染时机 客户端运行时渲染 构建时预渲染 用户请求时服务器实时渲染
首屏加载速度 ⚠️ 慢(需下载JS后渲染) ✅ 极快(直接返回HTML) 🟡 中(需服务器处理时间)
SEO支持 ❌ 差(初始HTML为空) ✅ 优秀(完整静态HTML) ✅ 优秀(实时生成完整HTML)
服务器压力 ✅ 低(纯静态文件) ✅ 低(预生成文件) ❌ 高(每次请求需计算)
动态内容 ✅ 灵活(客户端随时获取) ❌ 需结合CSR(如**getStaticProps** ✅ 实时(服务端获取数据)
运维复杂度 ✅ 低 ✅ 低 ❌ 高(依赖服务器)
适用场景 后台系统、强交互应用 博客、文档、营销页 电商详情页、用户仪表盘

总结下, 从体验角度考虑

  • 静态内容为主 :选择 SSG+CDN,兼顾速度与成本(如企业官网、帮助文档)。

  • 动态内容为主 :SSR > CSR > SSG,但更多是采用 混合模式,如果选SSR需权衡服务器成本。

  • 轻量化交互场景 :纯 CSG 即可(如简单工具类页面)

2.MemOS 怎么做

2.1 决策逻辑

  • 排除SSR:全球化的考虑,做SSR的对于机器运维的成本太高,跨地域访问成本更高

  • 静态内容「极致优化」:用 SSG+CDN 组合拳解决「速度」与「成本」的矛盾;

  • 动态内容「适度妥协」:仅依赖用户信息的强交互页面保留 CSG,进行传统的框架优化,加载时序优化,避免过度工程化。

项目名称 项目特性 方案 SSG CSG
MemOS Landing 静态站点,内容为主 SSG /
MemOS Docs 静态站点,内容为主 SSG /
MemOS Playground 登录态依赖的页面CSG 无登录态依赖的页面SSG SSG + CSG

2.2 分析过程

项目是从0到1的过程,起初的性能指标并没有,更多是从输入url到DOM展现,思考哪一些是我们可以去优化的,整个优化过程可以抽象成三个部分,网络层加载优化,框架层渲染优化,逻辑层处理优化

2.3 体验指标对比

2.3.1 正常网络环境(无缓存)

结论:正常网络下,SSG 和优化后的 CSG 均能达到优秀体验,用户无明显感知差异。

官方的体验指标评定标准里FCP在1.8s以内就是优秀的,对比了MemOS三个站点,Mem0以及SurveyX,如果是在正常的网络环境下都达到了优秀的标准,从使用体感来看,加载过程中也不会感觉到慢或者明显的白屏,因此,在正常网络环境下0.5s,1s,还是1.5s并没有什么区别

网络环境 项目 渲染模式 评分 FCP LCP 截图
无缓存,正常wifi网络 MemOS-Landing SSG 97 0.6s 0.8s
MemOS-Docs SSG 98 0.6s 1s
MemOS-Playground - SSG页面 SSG 100 0.6 0.7
MemOS-Playground - CSG 页面 CSG 97 0.6 1.2
Mem0 SSG/SSR 82 1.8s 1.8s
Surveyx CSG 99 0.4s 0.9s

2.3.2 弱网络环境(以低速4G为例)

结论: 弱网络下,SSG 的优势显著 ------ 只需加载静态 HTML即可展示页面, 无需等待 JS 加载再渲染,可快速展示核心内容,无白屏等待时间

网络环境 项目 渲染模式 屏幕截图
低速4G MemOS-Landing SSG
MemOS-Docs SSG
Mem0 SSG
MemOS-Playground CSG
Surveyx CSG

3.三阶段优化

3.1 网络层优化: CDN全球化加速的策略

核心目标:通过 CDN 配置减少网络请求耗时,提升资源加载效率。

3.1.1 缓存配置

  1. 静态资源强缓存
  • JS、CSS、图片等静态资源设置 14 天缓存,要客户端跟随CDN缓存策略,这样资源请求后可以进行本地缓存

  • 长期不变资源(如 .wasm 二进制文件)配置 365 天超长缓存,避免版本未变更时的重复下载。

  1. HTML 动态更新
  • 不缓存 HTML 文件(Cache-Control: no-cache),通过 CDN 规则优先级(低于静态资源)确保每次请求获取最新页面结构,避免因缓存导致内容滞后。

3.1.2 性能优化

  • 【必选】: 开启后优化体感明显

    1. HTTP/2 协议开启:利用多路复用特性,减少 TCP 连接数,提升资源并行加载效率。

    2. 智能压缩全开 :启用 Brotli 压缩(压缩级别 6),对 HTML、JS、CSS 文件平均压缩 40%-60%,降低传输体积。

  • 可选】:

    1. 图片预处理 :将大图压缩并转换为 WebP 格式(体积减少 30%+),小图标使用 SVG 或 Base64 内嵌,减少 HTTP 请求。

    2. URL 参数精简 :移除静态资源 URL 中的版本参数(如 style.css?v=2.0style.css),避免 CDN 因参数差异误判缓存失效

3.1.3 规则引擎和重定向

中英文站点的切换

SSG的页面预渲染只能是英文和中文的,因此中英文站点是两个路由,要实现根据用户选择语言或者识别用户IP地址来决定进入中文页面还是英文页面,可利用CDN的规则引擎去做重定向。

CDN提供了两种方式来配置规则,第一种是规则脚本,第二种是规则引擎,脚本可以自行编写代码,但需要按照CDN规定的语法来写,比较麻烦,适合比较复杂的匹配逻辑,而规则引擎是CLI配置界面,适合规则明确,相对简单。

重定向中文站

重定向英文站

配置重定向规则,这里要注意的是执行规则一定要选择break,break的意思是匹配到这一条规则后,下一次进入不会再匹配其他规则,可避免反复重定向

无登录态时的重定向

未登录用户访问受限页面时,通过 CDN 规则直接重定向至登录页,避免服务端介入,减少延迟。

  1. 配置规则引擎
  1. 配置重定向规则

3.2 框架层优化: 混合渲染实践(CSG+SSG)

核心策略:静态内容 SSG 预渲染,动态内容 CSG 客户端渲染,平衡体验与开发成本。

Landing和Docs都是以静态内容为主,因此全部选的是SSG,Playground有登录态依赖的页面使用CSG,无登录态依赖的页面使用SSG,比如登录页面,协议页面

为什么Playground不是所有页面都SSG?

不是不能做,只是前端性能优化的实践中,技术方案的选择本质是对「用户体验」「开发成本」「场景适配性」的综合权衡,还是得结合项目的实际情况进行选择。当时我们做完其他性能优化改造后,整体页面性能已经很好了,再去把剩余页面改造成SSG收效比较小,因此就没有投产,但也做过一些技术验证

如果依赖登录态的页面如果要改造成SSG,依赖接口数据的部分还是依赖客户端,只有页面框架部分是预渲染。但在实际加载页面的时候就会出现两个问题

  1. 登录态校验闪现问题

    若通过 Nuxt 中间件执行登录态校验,会出现「先渲染页面框架,再因登录态过期跳转」的闪现现象。解决方案是将校验逻辑提取为独立 JS 脚本,内嵌至 HTML 头部,在 DOM 渲染前完成校验,并通过 CSS 隐藏页面直至校验结束。

  2. 多语言路由匹配问题

SSG 预渲染时需生成固定语言的 HTML 文件(如英文站/en、中文站/cn),但用户语言偏好可能动态变化。若直接通过路由匹配语言,会导致预渲染内容与用户选择不一致。最终通过 CDN 规则引擎,根据请求携带的 Cookie(如MEMOS_LANG=cn)动态重定向至对应语言的预渲染页面,实现语言与 HTML 的精准匹配。

3.2.1 SSG 项目改造

在 Nuxt 中实现 SSG 改造可分为配置调整逻辑适配两大步骤, SSG的改造适用于混合渲染模式以及纯SSG渲染模式,三个项目都是用的以下方式

配置改造: nuxt.config.ts

SSG 的核心是在构建阶段生成静态 HTML 文件,Nuxt 提供了灵活的配置方式,可根据路由需求精细化控制预渲染范围。配置的核心逻辑是:通过 **ssr** 启用服务端渲染能力,通过 **prerender** 指定需要生成静态文件的路由

  • ssr: true:允许该路由在构建 / 请求时执行服务端逻辑(预渲染的前提)。

  • prerender: true:指定该路由在构建阶段生成静态 HTML 文件(SSG 核心)。

  • 未配置的路由默认继承全局 ssr 配置,可按需组合实现 "部分页面静态化、部分页面动态化"。

可参考 Nuxt 官方文档 实践,以下是两种常用配置方案:

方案一: 预渲染 + 自动爬取链接(适合内容关联紧密的站点)

typescript 复制代码
// nuxt.config.ts
export default defineNuxtConfig({
 ssr: true, // 开启服务端渲染能力(预渲染依赖服务端逻辑)
 nitro: {
   prerender: {
     routes: ['/'], // 从根路由开始爬取
     crawLinks: true, // 自动爬取页面中的  链接并预渲染
   }
 }
})

方案二: 路由规则精准控制(适合混合渲染场景)

通过 routeRules 为不同路由单独配置渲染策略(SSG/SSR/CSR),灵活度更高:

typescript 复制代码
// nuxt.config.ts
export default defineNuxtConfig({
  ssr: true, // 全局开启服务端渲染能力
// 路由规则:配置混合渲染模式
  routeRules: {
    // 预渲染的页面(SSG)
    '/login': { ssr: true, prerender: true },
    '/agreement': { ssr: true, prerender: true },
    '/cn/login': { ssr: true, prerender: true },
    '/cn/agreement': { ssr: true, prerender: true },

    // 客户端渲染(CSG)
    '/main/**': { ssr: false, prerender: true },
    '/console/**': { ssr: false, prerender: true },
    '/message/**': { ssr: false, prerender: true },
    '/cn/main/**': { ssr: false, prerender: true },
    '/cn/console/**': { ssr: false, prerender: true },
    '/cn/message/**': { ssr: false, prerender: true },

    // 不渲染的页面
    '/console/explicit/Chart/**': { ssr: false, prerender: false },
    '/cn/console/explicit/Chart/**': { ssr: false, prerender: false },
    '/console/settings/LabelWithDescription/**': { ssr: false, prerender: false },
    '/cn/console/settings/LabelWithDescription/**': { ssr: false, prerender: false },

    // 重定向
    '/': { redirect: '/main/service' },
    '/cn/': { redirect: '/cn/main/service' },
    '/main': { redirect: '/main/service' },
    '/cn/main': { redirect: '/cn/main/service' },
    '/console': { redirect: '/console/explicit' },
    '/cn/console': { redirect: '/cn/console/explicit' },
  },
})
逻辑改造: 兼容客户端特有依赖

预渲染的本质也是执行的服务端渲染,但部分代码(如依赖 windowdocument 等浏览器 API)只能在客户端运行。若直接执行,会导致构建报错或页面异常,需通过以下方式区分客户端 / 服务端逻辑

客户端逻辑隔离: onMount 与 import.meta.client

  1. **onMounted** 钩子 :在 Nuxt 中,onMounted 内的代码默认仅在客户端执行,适合初始化依赖浏览器 API 的逻辑(如 DOM 操作、事件监听):

  2. **import.meta.client** 判断:通过环境变量显式区分客户端逻辑,适合非生命周期内的代码:

客户端组件隔离: 组件

对于完全依赖客户端环境的组件(如包含 window 操作的第三方组件),可使用 Nuxt 内置的 <ClientOnly> 组件包装,确保其仅在客户端渲染:

混合渲染:可以指定逻辑使用SSG,CSG,精细化控制页面的预渲染或者组件的预渲染

  1. <NuxtLayout >布局组件兼容:NuxtLayout依赖了客户端的一个插件,不能用于服务端渲染,因此如果要用<NuxtLayout /> 需要用ClientOnly组件进行包装
  1. 指定组件预渲染,如需对页面局部内容控制渲染方式(如顶部和侧边菜单 SSG + 主体内容 CSR),可参考下图逻辑
调试技巧
  1. 使用 nuxt dev --prerender 命令在开发环境预览预渲染效果,提前发现问题。

  2. 执行 nuxt generate生成构建产物后,要查看构建产物目录.output/public下面的html的生成效果是否符合预期,有没有提前渲染好的内容。

  3. 执行 nuxt generate如果有server error的报错,一般都是某一部分代码在不能在服务端运行,可以按模块注释并调试,确定是哪一部分代码影响

3.2.2 CSG 资源加载优化

主要的分析手段有两个

  1. 使用chrome的Performance分析资源加载情况与白屏问题分析 ,使用Lighthouse进行FCP分析

  2. 基于Nuxt的Analyze并结合Cursor进行首屏资源的拆包分析和优化 ,首屏CSS从200KB -> 40KB, 入口JS从458KB,拆为三个150KB并行加载

非首屏资源动态加载

非首屏资源,使用import实现动态加载,包括G6,Markdown解析依赖

typescript 复制代码
let _markdownIt: any = null

function loadMarkdownIt() {
  if (_markdownIt || !import.meta.client) {

    return;
  }
  
  const { default: MarkdownIt } = await import('https://statics.memtensor.com.cn/files/md/markdown-it.esm.min.js')

  _markdownIt = MarkdownIt({
      html: true,
      linkify: true,
      typographer: true,
    })

    // 使用自定义的katex插件,只启用LaTeX风格分隔符
  _markdownIt.use(markdownItKatex, {
    throwOnError: false,
    errorColor: '#cc0000',
    strict: false,
    delimiters: [
      { left: '$$', right: '$$', display: true },
      { left: '$', right: '$', display: false },
      { left: '\(', right: '\)', display: false },
      { left: '\[', right: '\]', display: true },
    ],
  })

  return _markdownIt
}

onMounted(() => {
  loadMarkdownIt()
})
关键资源进行拆包优化

在nuxt项目里,本身自带了一些拆包优化以及tree-shaking的策略,比如

  • 从app.vue里的进入的代码都会打到入口文件,entry.js,每个路由文件都会有一个单独的js文件

  • 项目里引用的组件库自带tree-shaking,entry或者路由文件都只会加载用到的组件代码

因此,代码分割的主要策略是

  • 将入口文件entry.js进行拆包优化,将不常用的lib库单独抽出来,并行加载提高速度,同时利用好CDN的缓存优势,避免每次打包都产生新的hash,导致每次都是重新加载

  • 针对阻塞性CSS进行分析,删除冗余样式

第一步,使用cursor进行拆包分析

在执行打包时,将每个文件依赖的chunk以及chunk大小都打印出来写到file.analyze.log里,然后通过cursor进行包分析,确定拆包范围

第二步,nuxt.config.ts添加build配置,进行拆包

typescript 复制代码
const vueLibs = ['@vue/shared', '@vue/reactivity', '@vue/runtime-core', '@vue/runtime-dom', 'vue-router']
const i18nLibs = ['@intlify/message-compiler', '@intlify/core-base', 'vue-i18n', '@nuxtjs/i18n']
const tailwindLibs = ['tailwind-merge', 'tailwind-variants']

export default defineNuxtConfig({
  ...,
  vite: {
    rollupOptions: {
      plugins: [
        // chunkSizeLogger(buildLogFile),
      ],
      output: {
        // 重定义文件名
        chunkFileNames: () => {
          return '_nuxt/[name].[hash].js'
        },
        entryFileNames: '_nuxt/[name].[hash].js',

        // 🎯 优化的 chunk 分离策略
        manualChunks(id) {
          if (id.includes('node_modules')) {
            // Vue 核心库
            if ([...vueLibs].some(lib => id.includes(lib))) {
              return 'vue-lib'
            }

            // 国际化库
            if ([...i18nLibs].some(lib => id.includes(lib))) {
              return 'i18n-lib'
            }

            // Tailwind 相关
            if ([...tailwindLibs].some(lib => id.includes(lib))) {
              return 'tailwind-lib'
            }
          }
        },
      },
    },
  }
})

第三步,修改html中的lib文件加载顺序

typescript 复制代码
export default defineNuxtConfig({
  ...,
hooks: {
    'nitro:build:public-assets': (_nitro) => {
      const publicDir = `${process.cwd()}/.output/public`

      // 获取所有预渲染的路由
      const routes = prerenderRoutes.map((route) => {
        // 移除开头的斜杠,并添加 index.html
        const htmlPath = route === '/'
          ? 'index.html'
          : `${route.slice(1)}/index.html`
        return path.join(publicDir, htmlPath)
      })

      // 处理每个 HTML 文件
      routes.forEach((htmlPath) => {
        try {
          if (!fs.existsSync(htmlPath)) {
            console.log(`File not found: ${htmlPath}`)
            return
          }

          const htmlContent = readFileSync(htmlPath, 'utf-8')

          const preloadLinks = htmlContent.match(/<link[^>]*as="script"[^>]*>/g) || []

          const sortedLinks = [...preloadLinks].sort((a, b) => {
            // 优先级
            const getPriority = (link: string) => {
              if (link.includes('vue-lib'))
                return 1
              if (link.includes('i18n-lib'))
                return 2
              if (link.includes('tailwind-lib'))
                return 3
              if (link.includes('entry.js'))
                return 4
              return 5 
            }

            return getPriority(a) - getPriority(b)
          })

          let newHtml = htmlContent.replace(/<link[^>]*as="script"[^>]*>\s*/g, '')

          const headEndMatch = newHtml.match(/<\/head>/)
          const insertionPoint = headEndMatch ? headEndMatch.index : newHtml.indexOf('</head>')

          if (insertionPoint !== -1) {
            const sortedLinksString = `${sortedLinks.map(link => `  ${link}`).join('\n')}\n`
            newHtml = newHtml.slice(0, insertionPoint) + sortedLinksString + newHtml.slice(insertionPoint)
          }
          else {
            console.warn(`Could not find </head> tag in ${htmlPath}`)
          }

          fs.writeFileSync(htmlPath, newHtml, 'utf-8')
        }
        catch (error) {
          console.error(`Error processing ${htmlPath}:`, error)
        }
      })
    },
  },
})
图片优化

大的背景图尽量使用webp格式,压缩效果更好,并放到CDN上进行管理

3.3 逻辑层优化: 用户体验的细节打磨

核心目标:消除加载卡顿,提升交互流畅度,强化设备兼容性。

3.3.1 登录页整出

登录页面是预渲染的,1个html文件大概是4kb,整体渲染很快,但登录页面有一些图片,一开始使用的是CDN,虽然已经有缓存优化了,但毕竟是网络请求,而且背景图很大,有70KB,访问登录页的时候背景图总是会有个请求过程再出现,体验不是很好。

因此,针对登录页的优化是

  1. 页面SSG整出

  2. 依赖图片全部用base 64加载,也就是和html一起加载

按照上述方式改完后,html虽然增加到了80KB,但首次访问不会出现图片加载的过程,整体页面就是整出的体验

3.3.2 登录态校验前置

如果登录token不存在,需要重定向到Landing页

如果登录token存在,就需要调用接口进行登录态校验,登录态有效再进入对应页面,如果登录态失效则跳转到登录页

无登录态时利用CDN进行重定向

参考网络层优化

有登录态前置进行校验

登录态校验原先是在Nuxt的中间件里执行,但就需要entry和lib的相关JS加载完后,才能执行,这势必会导致首次访问的速度变慢。

因此,针对登录态校验的逻辑前置到所有逻辑前面

  1. 单独提成一个JS文件,放到所有JS前面,并内敛到html里,随html一起下发并执行

  2. 设置async进行异步执行,避免阻塞html渲染

  3. 要求后端提供一个单独的checkToken接口提升整体响应速度,从50ms提升到18ms

在这18ms的响应过程中,并行做了两件事,一方面是执行了登录态校验,另一方面也是执行了渲染逻辑

3.3.3 骨架屏

部分页面采用的是CSG渲染,不管优化到什么程度,客户端渲染势必需要等待JS加载完成再进行渲染,,首次加载或者是弱网环境下,依然会有个卡顿现象,因此添加了个利用Nuxt的SpaLoadingTemplate设置骨架屏

nuxt.com/docs/4.x/ap...

3.3.4 不适配的设备的提示

MemOS的宣发渠道主要是微信,因此Landing和Docs都是移动端适配的,但是Playground是不支持移动端访问,为了避免从移动端入口进入导致Playground展示异常,因此在进入页面如果识别是移动端就会进行提示引导到web端进行访问

3.3.5 不适配的浏览器提示

Playground本身是一个Preview版本,并没有做低端浏览器适配,低端浏览器不支持es6,因此以是否支持es6来判断,如果不支持,给与提示

4.总结

按优先级和收效进行排序

  1. 网络优化 : 充分利用CDN的能力,收效最大,缓存配置 | 资源压缩 | 规则引擎

  2. 渲染模式:SSG预加载体验,在弱网模式下以及首次加载远远好于CSG

  3. 动态加载: 非关键资源动态加载,大幅减少首屏加载js大小

  4. 代码分割: 不常用的css和js要抽离出来,利用缓存优势,避免每次打包都产生新的hash,重新加载

  5. 体验优化: 骨架屏

技术方案的选择本质是对「用户体验」「开发成本」「场景适配性」的综合权衡,还是得结合项目的实际情况进行选择。脱离具体场景的「极致优化」,往往是技术炫技而非真实需求。就像我们在项目中纠结于将 FCP 从 0.6s 压到 0.5s 时,更该思考:这个差值对用户而言,真的体验的出来吗。

因此,性能优化还是要回归用户体验的本质,当用户打开页面时,不会注意到 SSG 如何预渲染 HTML,也不会关心 CDN 节点离自己多近,他们只会觉得「页面自然而然就出来了」,当他感受不到性能时才是好的体验,这是就会专注在产品功能本身,这种「无感知的流畅」,才是体验的终极目标。

说到底,体验优化是一种「克制的修行」:既要用技术突破边界,也要用同理心划定边界。让性能服务于体验,让体验服务于用户。

相关推荐
北'辰30 分钟前
DeepSeek智能考试系统智能体
前端·后端·架构·开源·github·deepseek
前端历劫之路1 小时前
🔥 1.30 分!我的 JS 库 Mettle.js 杀入全球性能榜,紧追 Vue
前端·javascript·vue.js
爱敲代码的小旗2 小时前
Webpack 5 高性能配置方案
前端·webpack·node.js
Murray的菜鸟笔记2 小时前
【Vue Router】路由模式、懒加载、守卫、权限、缓存
前端·vue router
苏格拉没有底了3 小时前
由频繁创建3D火焰造成的内存泄漏问题
前端
阿彬爱学习3 小时前
大模型在垂直场景的创新应用:搜索、推荐、营销与客服新玩法
前端·javascript·easyui
橙序员小站3 小时前
通过trae开发你的第一个Chrome扩展插件
前端·javascript·后端
Lazy_zheng3 小时前
一文掌握:JavaScript 数组常用方法的手写实现
前端·javascript·面试
是晓晓吖3 小时前
关于Chrome Extension option的一些小事
前端·chrome
MrSkye3 小时前
🔥从菜鸟到高手:彻底搞懂 JavaScript 事件循环只需这一篇(下)
前端·javascript·面试