1. SSR 基本概念与优势
服务端渲染 (Server-Side Rendering,SSR) 是指在服务器端将 React/Vue 等框架的组件渲染为 HTML 字符串,然后发送给客户端的技术。与客户端渲染 (CSR) 相比,SSR 具有以下优势:
- 更好的 SEO:搜索引擎可以直接抓取完整的 HTML 内容
- 更快的首屏加载速度:客户端无需等待 JavaScript 下载和执行即可看到内容
- 更好的用户体验:减少了白屏时间,提升了用户感知的页面加载速度
- 支持无障碍访问:对于不支持 JavaScript 的浏览器或设备,仍能正常显示内容
2. Vite+Koa SSR 架构设计
基于对现有代码库的分析,我们的 Vite+Koa SSR 架构采用了以下核心组件:
flowchart TD
A[ 客户端请求] --> B[Koa 服务器]
B --> C{开发/生产环境?}
C -->|开发环境| D[Vite 开发服务器]
C -->|生产环境| E[ 静态资源服务]
D --> F[ 入口文件处理]
E --> F
F --> G[ 路由匹配]
G --> H[ 数据预取]
H --> I[React 服务端渲染]
I --> J[ 生成 HTML 响应]
J --> K[ 客户端 Hydration]
2.1 核心组件职责
| 组件 | 职责 | 实现文件 |
|---|---|---|
| Koa 服务器 | 处理 HTTP 请求,集成 Vite | src/server.js |
| Vite | 开发服务器、模块编译、构建 | src/server.js |
| React 渲染引擎 | 服务端渲染 React 组件 | src/entry-server.tsx |
| 路由系统 | 路由匹配、数据预取 | src/main.tsx, src/ssr/util.ts |
| 数据预取机制 | 服务端数据加载 | src/entry-server.tsx |
| 客户端 Hydration | 激活客户端交互 | src/entry-client.tsx |
3. 核心流程分析
3.1 请求处理流程
- 客户端发送请求:用户访问某个 URL,浏览器发送 HTTP 请求到 Koa 服务器
- Koa 服务器接收请求:根据环境类型 (开发/生产) 执行不同的处理逻辑
- 开发环境:通过 Vite 中间件处理请求,实时编译和提供资源
- 生产环境:直接提供静态资源或执行 SSR 渲染
3.2 服务端渲染流程
sequenceDiagram
participant Client as 客户端
participant Koa as Koa 服务器
participant Vite as Vite
participant React as React 渲染引擎
participant Router as 路由系统
Client->>Koa: HTTP 请求
Koa->>Vite: 处理请求
Vite->>React: 加载 entry-server.tsx
React->>Router: 路由匹配
Router->>Router: 执行 serverLoader
Router->>Router: 执行 metaLoader
Router->>React: 返回预取数据
React->>React: 渲染组件为 HTML
React->>Koa: 返回 HTML 、初始数据和元信息
Koa->>Client: 发送完整 HTML 响应
Client->>Client: 执行 hydration
3.3 关键代码解析
3.3.1 服务器入口文件 (src/server.js)
// 核心服务器创建函数
async function createServer(isProd = process.env.NODE_ENV === 'production') {
const app = new Koa()
const root = path.resolve(__dirname, '..')
let vite
if (!isProd) {
// 开发环境:创建 Vite 开发服务器
vite = await createViteServer({
root,
server: { middlewareMode: true },
appType: 'custom'
})
app.use(c2k(vite.middlewares))
} else {
// 生产环境:提供静态资源服务
app.use(serve(path.resolve(root, 'dist/client'), {
index: false, // 不自动服务 index.html,交给 SSR 处理
setHeaders: (res, path, stats) => {
// 静态资源缓存策略配置
if (path.includes('assets/') || path.match(/\.(js|css|png|jpg|jpeg|gif|svg|ico|woff2?)$/)) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
} else {
res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate')
}
}
}))
}
// SSR 处理中间件
app.use(async (ctx) => {
// 忽略 favicon 请求
if (ctx.path === '/favicon.ico') return
try {
const url = ctx.request.url
let template, render
let manifest = {}
if (!isProd) {
// 开发环境:转换 HTML 并加载服务端入口
template = fs.readFileSync(path.resolve(root, 'index.html'), 'utf-8')
template = await vite.transformIndexHtml(url, template)
render = (await vite.ssrLoadModule('/src/entry-server.tsx')).render
} else {
// 生产环境:加载构建后的 HTML 和服务端入口
template = fs.readFileSync(path.resolve(root, 'dist/client/index.html'), 'utf-8')
const serverEntryPath = path.resolve(root, './dist/server/entry-server.js')
render = (await import(pathToFileURL(serverEntryPath).href)).render
// 读取构建生成的 manifest.json
const manifestPath = path.resolve(root, 'dist/client/.vite/manifest.json')
if (fs.existsSync(manifestPath)) {
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))
}
}
// 执行 React 渲染逻辑
const { html, head, initialData, initialMeta } = await render(url, {
request: ctx.request,
manifest
})
// 注入数据到 HTML
const finalHtml = template
.replace(`<!--app-head-->`, head)
.replace(`<!--app-html-->`, html)
.replace(
`<!--app-state-->`,
`<script>
window.__INITIAL_DATA__ = ${serialize(initialData)};
window.__INITIAL_META__ = ${serialize(initialMeta)};
</script>`
)
ctx.type = 'text/html'
ctx.body = finalHtml
} catch (e) {
// 错误处理
if (!isProd && vite) {
vite.ssrFixStacktrace(e)
}
console.error(e)
ctx.status = 500
ctx.body = e.stack
}
})
return app
}
3.3.2 服务端渲染入口 (src/entry-server.tsx)
export async function render(url: string, context: any = {}) {
const { manifest } = context
// 1. 路由匹配
const matches = matchRoutes(routeConfigs, url) || []
const loadDataPromises: Promise<any>[] = []
const metaPromises: Promise<any>[] = []
// 2. 遍历匹配的路由,执行预取
matches.forEach((m) => {
const route = m.route as any
// A. 执行独立的 serverLoader
if (route.serverLoader) {
loadDataPromises.push(
route.serverLoader({
params: m.params as Record<string, string>,
request: context.request
})
)
}
// B. 执行 metaLoader
if (route.metaLoader) {
metaPromises.push(route.metaLoader())
}
})
// 3. 等待所有数据完成
const [dataResults, metaResults] = await Promise.all([Promise.all(loadDataPromises), Promise.all(metaPromises)])
// 4. 合并数据
const initialData = dataResults.reduce((acc, curr) => ({ ...acc, ...curr }), {})
const initialMeta = metaResults[metaResults.length - 1] || {} // 取最深层匹配的 Meta
// 5. 渲染
const helmetContext = {}
const appHtml = renderToString(
<HelmetProvider context={helmetContext}>
<StaticRouter location={url}>
<App serverData={initialData} serverMeta={initialMeta} />
</StaticRouter>
</HelmetProvider>
)
// 6. 提取 SEO 头部
const { helmet } = helmetContext as any
// 7. 收集当前路由对应的 CSS
let preloadLinks = ''
if (manifest) {
// 获取所有匹配路由的文件路径
const matchedFiles = matches.map((m) => (m.route as any).filePath).filter(Boolean)
// 别忘了入口文件也包含全局样式
matchedFiles.unshift('src/entry-client.tsx')
// 生成 Links
preloadLinks = renderPreloadLinks(matchedFiles, manifest)
}
return {
html: appHtml,
head: `
${helmet.title.toString()}
${helmet.meta.toString()}
${helmet.link.toString()}
${preloadLinks}
`,
initialData,
initialMeta
}
}
3.3.3 客户端入口文件 (src/entry-client.tsx)
// 注水 (Hydrate)
ReactDOM.hydrateRoot(
document.getElementById('root')!,
<HelmetProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</HelmetProvider>
)
3.3.4 主应用文件 (src/main.tsx)
// 导入所有页面
const modules = import.meta.glob('./pages/**/index.tsx', { eager: true })
const routeConfigs = generateRoutes(modules)
// 将配置转换为 react-router-dom 可用的对象
const routes = routeConfigs.map((conf) => ({
path: conf.path,
element: <conf.component />
}))
interface AppProps {
serverData?: any
serverMeta?: any
}
const App: React.FC<AppProps> = ({ serverData, serverMeta }) => {
const element = useRoutes(routes)
// 客户端:尝试从 window 读取数据 (Hydrate 阶段)
const initialData = serverData || (typeof window !== 'undefined' ? (window as any).__INITIAL_DATA__ : {})
const initialMeta = serverMeta || (typeof window !== 'undefined' ? (window as any).__INITIAL_META__ : {})
return (
<SSRProvider data={initialData}>
{/* 注入默认的/服务端的 Meta */}
{initialMeta && (
<Helmet>
{initialMeta.title && <title>{initialMeta.title}</title>}
{initialMeta.description && <meta name="description" content={initialMeta.description} />}
{initialMeta.keywords && <meta name="keywords" content={initialMeta.keywords} />}
</Helmet>
)}
<TrackerLayout>{element}</TrackerLayout>
</SSRProvider>
)
}
4. 关键技术点解析
4.1 Vite 的 SSR 支持
Vite 提供了强大的 SSR 支持,主要体现在以下几个方面:
- 开发环境热更新:通过
middlewareMode: true和appType: 'custom'配置,将 Vite 作为 Koa 中间件集成,实现开发环境的热更新 - SSR 模块加载:通过
vite.ssrLoadModule()方法,可以在运行时动态加载和编译 SSR 入口文件 - HTML 转换:通过
vite.transformIndexHtml()方法,可以对 HTML 模板进行转换,注入必要的脚本和样式 - 构建支持:Vite 提供了
build.rollupOptions.input配置,可以同时构建客户端和服务端代码
4.2 基于文件系统的路由生成
我们的架构采用了基于文件系统的路由生成机制,通过 import.meta.glob()动态导入所有页面组件,并根据文件路径自动生成路由配置:
export function generateRoutes(pageModules: Record<string, any>): RouteConfig[] {
const routes: RouteConfig[] = []
for (const path in pageModules) {
let routePath = path
.replace(/\.\/pages/, '')
.replace(/\/index\.tsx$/, '')
.replace(/\[\.{3}(.+?)]/g, '*')
.replace(/\[(.+?)]/g, ':$1')
.replace(/\\/g, '/')
if (['/home', ''].includes(routePath)) routePath = '/'
// 跳过组件、工具等目录
if (routePath.match(/[A-Z]/)) continue
if (routePath.match(/component|components|util|utils|context/)) continue
const module = pageModules[path]
const OriginalComponent = module.default
// 统一 Meta 处理
const rawMeta = module.meta || metaConfig[metaConfigKeyMap.HOME]
const metaLoader = typeof rawMeta === 'function' ? rawMeta : () => Promise.resolve(rawMeta)
const serverLoader = module.serverLoader
if (!OriginalComponent) continue
// 包装组件
const ComponentWithParams = withRouteParams(OriginalComponent)
routes.push({
path: routePath,
component: ComponentWithParams,
metaLoader,
serverLoader,
filePath: path.replace(/^\.\//, 'src/')
})
}
// 路由排序 (更长的路径优先匹配)
routes.sort((a, b) => {
return b.path.length - a.path.length
})
return routes
}
4.3 服务端数据预取
服务端数据预取是 SSR 的核心特性之一,我们通过 serverLoader 机制实现:
- 在页面组件中导出
serverLoader函数,用于获取页面所需的数据 - 在服务端渲染时,遍历匹配的路由,执行所有的
serverLoader函数 - 等待所有数据获取完成后,将数据传递给组件或注入到全局状态
- 客户端渲染时,从全局状态中读取初始数据,避免重复请求
4.4 Hydration 机制
Hydration 是指客户端 JavaScript 接管服务端渲染的 HTML,使其成为可交互的应用:
- 服务端渲染生成完整的 HTML,包含初始数据
- 客户端加载 JavaScript 文件
- React 调用
hydrateRoot()方法,将客户端组件与服务端渲染的 HTML 进行匹配 - 激活事件监听器,使页面变为可交互状态
5. 架构优势与设计理念
5.1 架构优势
- 开发体验优秀:利用 Vite 的快速热更新,提升开发效率
- 性能优异:服务端渲染加快首屏加载,客户端激活实现交互
- SEO 友好:完整的 HTML 内容便于搜索引擎抓取
- 灵活性高:基于文件系统的路由,易于扩展和维护
- 可扩展性强:支持代码分割、懒加载等优化
5.2 设计理念
- 关注点分离:将路由、数据预取、渲染等逻辑分离,便于维护
- 自动化:自动生成路由配置,减少手动配置
- 渐进式增强:服务端渲染确保基础功能可用,客户端激活提供丰富交互
- 环境一致性:开发环境和生产环境使用相同的代码路径
- 性能优先:从设计之初就考虑性能优化
6. 总结
Vite+Koa SSR 架构结合了 Vite 的开发体验优势和 Koa 的灵活性,为现代 Web 应用提供了高效、高性能的服务端渲染解决方案。通过自动路由生成、服务端数据预取、客户端 Hydration 等核心机制,实现了更好的 SEO 、更快的首屏加载速度和更优的用户体验。

Comments NOTHING