从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 缓存配置
- 静态资源强缓存:
-
JS、CSS、图片等静态资源设置 14 天缓存,要客户端跟随CDN缓存策略,这样资源请求后可以进行本地缓存
-
长期不变资源(如
.wasm
二进制文件)配置 365 天超长缓存,避免版本未变更时的重复下载。

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

3.1.2 性能优化
-
【必选】: 开启后优化体感明显
-
HTTP/2 协议开启:利用多路复用特性,减少 TCP 连接数,提升资源并行加载效率。
-
智能压缩全开 :启用 Brotli 压缩(压缩级别 6),对 HTML、JS、CSS 文件平均压缩 40%-60%,降低传输体积。
-
-
【可选】:
-
图片预处理 :将大图压缩并转换为 WebP 格式(体积减少 30%+),小图标使用 SVG 或 Base64 内嵌,减少 HTTP 请求。
-
URL 参数精简 :移除静态资源 URL 中的版本参数(如
style.css?v=2.0
→style.css
),避免 CDN 因参数差异误判缓存失效
-


3.1.3 规则引擎和重定向
中英文站点的切换
SSG的页面预渲染只能是英文和中文的,因此中英文站点是两个路由,要实现根据用户选择语言或者识别用户IP地址来决定进入中文页面还是英文页面,可利用CDN的规则引擎去做重定向。
CDN提供了两种方式来配置规则,第一种是规则脚本,第二种是规则引擎,脚本可以自行编写代码,但需要按照CDN规定的语法来写,比较麻烦,适合比较复杂的匹配逻辑,而规则引擎是CLI配置界面,适合规则明确,相对简单。

重定向中文站

重定向英文站

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

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

- 配置重定向规则

3.2 框架层优化: 混合渲染实践(CSG+SSG)
核心策略:静态内容 SSG 预渲染,动态内容 CSG 客户端渲染,平衡体验与开发成本。
Landing和Docs都是以静态内容为主,因此全部选的是SSG,Playground有登录态依赖的页面使用CSG,无登录态依赖的页面使用SSG,比如登录页面,协议页面
为什么Playground不是所有页面都SSG?
不是不能做,只是前端性能优化的实践中,技术方案的选择本质是对「用户体验」「开发成本」「场景适配性」的综合权衡,还是得结合项目的实际情况进行选择。当时我们做完其他性能优化改造后,整体页面性能已经很好了,再去把剩余页面改造成SSG收效比较小,因此就没有投产,但也做过一些技术验证
如果依赖登录态的页面如果要改造成SSG,依赖接口数据的部分还是依赖客户端,只有页面框架部分是预渲染。但在实际加载页面的时候就会出现两个问题
-
登录态校验闪现问题 :
若通过 Nuxt 中间件执行登录态校验,会出现「先渲染页面框架,再因登录态过期跳转」的闪现现象。解决方案是将校验逻辑提取为独立 JS 脚本,内嵌至 HTML 头部,在 DOM 渲染前完成校验,并通过 CSS 隐藏页面直至校验结束。
-
多语言路由匹配问题:
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' },
},
})
逻辑改造: 兼容客户端特有依赖
预渲染的本质也是执行的服务端渲染,但部分代码(如依赖 window
、document
等浏览器 API)只能在客户端运行。若直接执行,会导致构建报错或页面异常,需通过以下方式区分客户端 / 服务端逻辑
客户端逻辑隔离: onMount 与 import.meta.client
-
**onMounted**
钩子 :在 Nuxt 中,onMounted
内的代码默认仅在客户端执行,适合初始化依赖浏览器 API 的逻辑(如 DOM 操作、事件监听): -
**import.meta.client**
判断:通过环境变量显式区分客户端逻辑,适合非生命周期内的代码:

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

混合渲染:可以指定逻辑使用SSG,CSG,精细化控制页面的预渲染或者组件的预渲染
- <NuxtLayout >布局组件兼容:NuxtLayout依赖了客户端的一个插件,不能用于服务端渲染,因此如果要用<NuxtLayout /> 需要用ClientOnly组件进行包装

- 指定组件预渲染,如需对页面局部内容控制渲染方式(如顶部和侧边菜单 SSG + 主体内容 CSR),可参考下图逻辑

调试技巧
-
使用
nuxt dev --prerender
命令在开发环境预览预渲染效果,提前发现问题。 -
执行
nuxt generate
生成构建产物后,要查看构建产物目录.output/public下面的html的生成效果是否符合预期,有没有提前渲染好的内容。 -
执行
nuxt generate
如果有server error的报错,一般都是某一部分代码在不能在服务端运行,可以按模块注释并调试,确定是哪一部分代码影响
3.2.2 CSG 资源加载优化
主要的分析手段有两个
-
使用chrome的Performance分析资源加载情况与白屏问题分析 ,使用Lighthouse进行FCP分析
-
基于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,访问登录页的时候背景图总是会有个请求过程再出现,体验不是很好。
因此,针对登录页的优化是
-
页面SSG整出
-
依赖图片全部用base 64加载,也就是和html一起加载
按照上述方式改完后,html虽然增加到了80KB,但首次访问不会出现图片加载的过程,整体页面就是整出的体验
3.3.2 登录态校验前置
如果登录token不存在,需要重定向到Landing页
如果登录token存在,就需要调用接口进行登录态校验,登录态有效再进入对应页面,如果登录态失效则跳转到登录页
无登录态时利用CDN进行重定向
参考网络层优化
有登录态前置进行校验
登录态校验原先是在Nuxt的中间件里执行,但就需要entry和lib的相关JS加载完后,才能执行,这势必会导致首次访问的速度变慢。
因此,针对登录态校验的逻辑前置到所有逻辑前面
-
单独提成一个JS文件,放到所有JS前面,并内敛到html里,随html一起下发并执行
-
设置async进行异步执行,避免阻塞html渲染
-
要求后端提供一个单独的checkToken接口提升整体响应速度,从50ms提升到18ms
在这18ms的响应过程中,并行做了两件事,一方面是执行了登录态校验,另一方面也是执行了渲染逻辑
3.3.3 骨架屏
部分页面采用的是CSG渲染,不管优化到什么程度,客户端渲染势必需要等待JS加载完成再进行渲染,,首次加载或者是弱网环境下,依然会有个卡顿现象,因此添加了个利用Nuxt的SpaLoadingTemplate设置骨架屏


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

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

4.总结
按优先级和收效进行排序
-
网络优化 : 充分利用CDN的能力,收效最大,缓存配置 | 资源压缩 | 规则引擎
-
渲染模式:SSG预加载体验,在弱网模式下以及首次加载远远好于CSG
-
动态加载: 非关键资源动态加载,大幅减少首屏加载js大小
-
代码分割: 不常用的css和js要抽离出来,利用缓存优势,避免每次打包都产生新的hash,重新加载
-
体验优化: 骨架屏
技术方案的选择本质是对「用户体验」「开发成本」「场景适配性」的综合权衡,还是得结合项目的实际情况进行选择。脱离具体场景的「极致优化」,往往是技术炫技而非真实需求。就像我们在项目中纠结于将 FCP 从 0.6s 压到 0.5s 时,更该思考:这个差值对用户而言,真的体验的出来吗。
因此,性能优化还是要回归用户体验的本质,当用户打开页面时,不会注意到 SSG 如何预渲染 HTML,也不会关心 CDN 节点离自己多近,他们只会觉得「页面自然而然就出来了」,当他感受不到性能时才是好的体验,这是就会专注在产品功能本身,这种「无感知的流畅」,才是体验的终极目标。
说到底,体验优化是一种「克制的修行」:既要用技术突破边界,也要用同理心划定边界。让性能服务于体验,让体验服务于用户。