Vite SSR 构建实战从客户端渲染到服务端渲染的演进一、CSR 的性能天花板与 SEO 困境现代前端框架React、Vue、Svelte默认采用客户端渲染CSR模式。浏览器加载 HTML 后通过 JavaScript 在客户端动态生成 DOM。这种架构在交互密集型应用中表现出色但在以下场景中存在明显短板首屏内容到达时间FCP受限于 JavaScript 包体积。用户需要在看到任何内容之前等待框架运行时和组件代码的下载与执行。对于网络条件较差的移动端用户白屏时间可能超过 3 秒导致用户流失。搜索引擎爬虫虽然已经能够执行 JavaScript但对于需要快速索引的内容如新闻、电商商品页SSR 提供的完整 HTML 仍然具有显著的 SEO 优势。服务端渲染SSR的核心思路是在服务器端预先执行组件代码将渲染结果作为完整 HTML 发送给客户端然后在客户端激活这些静态 HTML 使其变为可交互的页面Hydration。graph TB subgraph CSR 流程 A1[浏览器请求] -- B1[返回空 HTML JS] B1 -- C1[下载 JS 包] C1 -- D1[执行 JS 渲染] D1 -- E1[显示内容] end subgraph SSR 流程 A2[浏览器请求] -- B2[服务器渲染] B2 -- C2[返回完整 HTML] C2 -- D2[显示内容br/可交互] D2 -- E2[下载 JS 包] E2 -- F2[Hydrationbr/激活交互] end style D2 fill:#90EE90 style E1 fill:#FFB6C1二、Vite SSR 架构设计与核心原理Vite 天生支持 SSR其底层基于 ESM 模块系统在开发环境下实现按需编译。与生产环境的打包 SSR 不同Vite SSR 在开发模式下可以直接运行源代码无需打包步骤。2.1 基础 SSR 服务器实现一个最小化的 Vite SSR 服务器包含以下核心组件/** * server/ssr.js - Vite SSR 开发服务器 * 支持 HMR 的 SSR 开发环境 */ import { createServer as createViteServer } from vite; import express from express; import fs from fs/promises; import path from path; async function createSSRServer() { const app express(); // 创建 Vite 开发服务器以中间件模式运行 const vite await createViteServer({ server: { middlewareMode: true }, appType: custom, // 重要禁用 Vite 的 HTML 服务 logLevel: info }); // 将 Vite 中间件挂载到 Express app.use(vite.middlewares); // SSR 渲染中间件 app.use(*, async (req, res, next) { try { const url req.originalUrl; // 1. 读取 index.html 模板 let template await fs.readFile( path.resolve(process.cwd(), index.html), utf-8 ); // 2. 使用 Vite 转换 HTML处理 Vite 特有的标签如 script typemodule template await vite.transformIndexHtml(url, template); // 3. 加载服务端入口模块Vite 会按需编译 const { render } await vite.ssrLoadModule(/src/entry-server.js); // 4. 执行服务端渲染获取渲染后的 HTML 和初始状态 const { html: appHtml, initialState } await render(url, req); // 5. 将渲染结果注入到 HTML 模板中 const html template .replace(!--app-html--, appHtml) .replace( !--app-initial-state--, scriptwindow.__INITIAL_STATE__ ${JSON.stringify(initialState)}/script ); res.status(200).set({ Content-Type: text/html }).end(html); } catch (error) { // Vite 错误处理在浏览器中显示错误堆栈 vite.ssrFixStacktrace(error); console.error(SSR 渲染错误:, error); res.status(500).end(error.message); } }); return app; } // 启动服务器 createSSRServer().then(app { const port process.env.PORT || 3000; app.listen(port, () { console.log(✅ SSR 服务器运行在 http://localhost:${port}); }); });2.2 服务端入口模块entry-server.js是 SSR 的服务端入口负责在 Node.js 环境中渲染组件/** * src/entry-server.js - 服务端渲染入口 * 在 Node.js 环境中执行返回渲染后的 HTML 字符串 */ import { renderToString } from react-dom/server; // 或使用 Vue: import { renderToString } from vue/server import App from ./App; import { StaticRouter } from react-router-dom/server; // 或使用 Vue: import { createSSRApp } from vue /** * 服务端渲染函数 * param {string} url - 请求的 URL * param {object} req - Express 请求对象 * returns {Promise{html: string, initialState: object}} */ export async function render(url, req) { try { // 1. 根据 URL 匹配路由预加载数据 const matchedRoutes matchRoutes(routes, url); const initialData {}; for (const match of matchedRoutes) { if (match.route.loader) { // 执行路由级别的 data loader const data await match.route.loader({ params: match.params, request: req }); initialData[match.route.id] data; } } // 2. 创建服务端应用实例 const app ( StaticRouter location{url} App initialData{initialData} / /StaticRouter ); // 3. 渲染为 HTML 字符串 const html renderToString(app); // 4. 返回 HTML 和初始状态 return { html, initialState: initialData }; } catch (error) { console.error(服务端渲染失败:, error); // 降级到 CSR返回一个基础的 HTML 壳 return { html: div idappp加载中.../p/div, initialState: {} }; } } /** * 路由配置示例 */ const routes [ { path: /, element: HomePage /, id: home, loader: async () { // 预加载首页数据 const res await fetch(https://api.example.com/posts?limit10); if (!res.ok) throw new Error(数据加载失败); return res.json(); } }, { path: /posts/:slug, element: PostDetail /, id: post-detail, loader: async ({ params }) { const res await fetch(https://api.example.com/posts/${params.slug}); if (!res.ok) throw new Error(文章不存在); return res.json(); } } ]; function matchRoutes(routeConfig, url) { // 实际实现中使用 react-router 的 matchRoutes // 这里为示意代码 return routeConfig .filter(route url.startsWith(route.path.replace(/:\w/g, ))) .map(route ({ route, params: {} })); }三、生产环境构建双包打包策略开发环境的 SSR 通过 Vite 中间件直接运行源码但生产环境需要将代码打包为两个独立的包客户端包用于 Hydration和 服务端包用于 SSR 渲染。3.0 场景CDN 部署下的客户端包分发当客户端 JS 产物部署到 CDN 时如果文件名仅依赖 contenthash每次构建都会产生全新的文件名。旧版本保留在 CDN 上可能出现 HTML 引用的 JS chunk 在 CDN 上已被清理的情况。合理做法使用稳定的输出目录结构保持/assets/{contenthash}.js的约定路径HTML 中引用完整的 CDN URL通过环境变量注入PUBLIC_CDN_BASE保留最近 5 个版本的构建产物/** * CDN 清理脚本保留最近 N 个版本 */ import { readdir, stat, rm } from fs/promises; import path from path; async function cleanupOldBuilds(buildDir, keepVersions 5) { const entries await readdir(buildDir, { withFileTypes: true }); const builds []; for (const entry of entries) { if (entry.isDirectory() entry.name.startsWith(build_)) { const fullPath path.join(buildDir, entry.name); const stats await stat(fullPath); builds.push({ name: entry.name, path: fullPath, mtime: stats.mtimeMs }); } } builds.sort((a, b) b.mtime - a.mtime); const toDelete builds.slice(keepVersions); for (const build of toDelete) { await rm(build.path, { recursive: true, force: true }); console.log(已清理旧构建: ${build.name}); } }踩坑服务端构建中的原生 Node 模块兼容性问题当项目中使用bcrypt、sharp等含有原生依赖的包时开发环境macOS和部署环境Linux/Alpine之间编译产物可能不兼容。解决方案是使用vercel/ncc的 bundle 模式将代码打包为单一文件但排除原生模块在部署时用npm rebuild在目标环境重新编译原生依赖。另外值得注意的是某些 CI/CD 平台如 GitHub Actions 的 macOS runner构建的 C 扩展在 Linux 容器中即使 rebuild 也可能崩溃正确的做法是在与生产环境一致的 Docker 镜像中运行npm install。3.1 Vite 生产构建配置/** * vite.config.js - 同时支持 CSR 和 SSR 的 Vite 配置 */ import { defineConfig } from vite; import react from vitejs/plugin-react; import path from path; export default defineConfig({ plugins: [react()], // SSR 构建配置通过 mode 区分 resolve: { alias: { : path.resolve(__dirname, ./src) } }, build: { // 生产构建输出目录 outDir: dist, // SSR 构建时需要设置为 server 或 false禁用客户端打包 // 这里通过环境变量动态配置 ssr: process.env.BUILD_TARGET server, // 客户端构建配置 ...(process.env.BUILD_TARGET ! server { target: esnext, manifest: true, // 生成 manifest.json 用于预加载 rollupOptions: { input: index.html, output: { manualChunks: { vendor: [react, react-router-dom], ui: [mui/material, emotion/react] } } } }), // 服务端构建配置 ...(process.env.BUILD_TARGET server { target: node, ssr: src/entry-server.js, outDir: dist/server, rollupOptions: { output: { format: esm } } }) } });3.2 构建脚本// package.json 构建脚本 { scripts: { build:client: BUILD_TARGETclient vite build, build:server: BUILD_TARGETserver vite build, build: npm run build:client npm run build:server, start: node dist/server/entry-server.js } }3.3 生产服务器实现/** * dist/server/entry-server.js - 生产环境 SSR 服务器 * 使用打包后的服务端 bundle */ import express from express; import fs from fs/promises; import path from path; import { fileURLToPath } from url; const __dirname path.dirname(fileURLToPath(import.meta.url)); async function createProductionServer() { const app express(); // 1. 读取客户端构建产物中的 HTML 模板 const template await fs.readFile( path.resolve(__dirname, ../client/index.html), utf-8 ); // 2. 读取客户端 manifest用于资源预加载 const manifest JSON.parse( await fs.readFile( path.resolve(__dirname, ../client/.vite/manifest.json), utf-8 ) ); // 3. 静态文件服务客户端资源 app.use( /assets, express.static(path.resolve(__dirname, ../client/assets), { maxAge: 1y, immutable: true }) ); // 4. SSR 渲染中间件 app.use(*, async (req, res) { try { // 动态导入服务端 bundle已打包 const { render } await import(./entry-server.js); const url req.originalUrl; const { html: appHtml, initialState } await render(url, req); // 注入资源预加载链接 const preloadLinks generatePreloadLinks(manifest); const html template .replace(!--preload-links--, preloadLinks) .replace(!--app-html--, appHtml) .replace( !--app-initial-state--, scriptwindow.__INITIAL_STATE__ ${JSON.stringify(initialState)}/script ); res.status(200).set({ Content-Type: text/html }).end(html); } catch (error) { console.error(生产环境 SSR 错误:, error); if (error.status 404) { res.status(404).end(Not Found); } else { res.status(500).end(Internal Server Error); } } }); return app; } /** * 生成资源预加载链接 */ function generatePreloadLinks(manifest) { const links []; for (const [file, info] of Object.entries(manifest)) { if (info.file.endsWith(.js)) { links.push(link relmodulepreload href/assets/${info.file}); } else if (info.file.endsWith(.css)) { links.push(link relstylesheet href/assets/${info.file}); } } return links.join(\n); } // 启动生产服务器 createProductionServer().then(app { const port process.env.PORT || 8080; app.listen(port, () { console.log( 生产 SSR 服务器运行在 http://localhost:${port}); }); });3.4 生产环境部署注意事项在实际部署时需要特别关注内存管理和错误恢复。Node.js 服务器在长时间运行后可能出现内存泄漏建议配置 PM2 等进程管理器自动重启异常进程。同时SSR 渲染耗时应该设置超时阈值通常 3-5 秒超时后直接返回 CSR 降级页面避免阻塞用户请求。对于高并发场景可以使用集群模式利用多核 CPU并通过 Redis 缓存频繁访问页面的渲染结果。四、Hydration 陷阱与性能优化策略SSR 并非免费午餐。不正确的 Hydration 策略可能导致更差的用户体验。4.1 客户端 Hydration 实现/** * src/entry-client.js - 客户端入口Hydration */ import { hydrateRoot } from react-dom/client; import App from ./App; import { BrowserRouter } from react-router-dom; // 从服务端注入的初始状态 const initialState window.__INITIAL_STATE__ || {}; // 使用 hydrate 而非 render保留服务端渲染的 DOM const root hydrateRoot( document.getElementById(app), BrowserRouter App initialData{initialState} / /BrowserRouter ); // 开发环境下支持热模块替换 if (import.meta.hot) { import.meta.hot.accept(); }4.2 常见 Hydration 陷阱陷阱 1服务端和客户端渲染结果不一致// ❌ 错误时间戳导致服务端和客户端渲染不一致 function Timestamp() { return span{new Date().toLocaleString()}/span; } // ✅ 正确使用 useEffect 仅在客户端设置动态内容 import { useState, useEffect } from react; function Timestamp() { const [time, setTime] useState(); useEffect(() { setTime(new Date().toLocaleString()); }, []); return span{time || ...}/span; }陷阱 2大型组件的 Hydration 成本/** * 渐进式 Hydration延迟加载非关键组件 */ import { lazy, Suspense } from react; // 懒加载评论区首屏不需要 const CommentSection lazy(() import(./CommentSection)); function PostPage() { return ( article h1文章标题/h1 p文章内容.../p {/* 评论区在客户端空闲时再 Hydrate */} Suspense fallback{div评论加载中.../div} CommentSection / /Suspense /article ); }4.3 流式 SSR提升首屏速度Vite 支持流式 SSR允许将 HTML 分块发送到客户端浏览器可以逐步渲染/** * 流式 SSR 渲染React 18 */ import { renderToReadableStream } from react-dom/server; export async function render(url, res) { const app App /; // 创建可读流 const stream await renderToReadableStream(app, { bootstrapScripts: [/assets/client.js], onError(error) { console.error(流式渲染错误:, error); } }); // 等待所有 Suspense 边界解析 await stream.allReady; // 将流管道到响应 stream.pipe(res); }五、总结从 CSR 迁移到 Vite SSR 需要同时处理开发服务器搭建、生产环境双包构建、Hydration 兼容性等多个工程问题。核心收益体现在首屏性能和 SEO 可达性上但引入的架构复杂度需要谨慎评估。落地路线搭建 Vite SSR 开发中间件 → 实现服务端渲染入口和客户端 Hydration → 配置生产环境双包构建 → 解决 Hydration 不一致问题 → 引入流式 SSR 优化首屏体验。