构建可用于生产的 SSR React 应用程序

在这个每一毫秒都至关重要的世界里,服务器端渲染已经成为前端应用程序的必备功能。

本指南将引导您了解使用 React 构建可用于生产的 SSR 的基本模式。您将了解内置 SSR 的基于 React 的框架(如 Next.js)背后的原理,并学习如何创建自己的自定义解决方案。

提供的代码已准备好投入生产,具有完整的客户端和服务器构建流程,包括 Dockerfile。在此实现中,Vite 用于构建客户端和 SSR 代码,但您可以使用您选择的任何其他工具。Vite 还在客户端的开发模式下提供热重载功能。

如果您对不带 Vite 的版本感兴趣,请随时联系我们。

目录

  • 什么是 SSR
  • 创建应用程序初始化 Vite 更新 React 组件创建服务器配置构建
  • 路由
  • Docker
  • 结论
  • 什么是 SSR

    **服务器端渲染 (SSR)** 是一种 Web 开发技术,服务器在将网页的 HTML 内容发送到浏览器之前会生成该网页的 HTML 内容。与传统的客户端渲染 (CSR)(JavaScript 在加载空的 HTML shell 后在用户设备上构建内容)不同,SSR 直接从服务器提供完全渲染的 HTML。

    SSR 的主要优点:

  • 改进的 SEO:由于搜索引擎爬虫接收完全呈现的内容,SSR 可确保更好的索引和排名。
  • 更快的首次绘制:由于服务器处理了繁重的渲染工作,用户几乎可以立即看到有意义的内容。
  • 增强性能:通过减少浏览器上的渲染工作负载,SSR 为旧设备或性能较弱的设备用户提供更流畅的体验。
  • 无缝服务器到客户端数据传输:SSR 允许您将动态服务器端数据传递到客户端,而无需重建客户端包。
  • 创建应用程序

    使用 SSR 的应用程序的流程遵循以下步骤:

  • 读取模板 HTML 文件。
  • 初始化 React 并生成应用程序内容的 HTML 字符串。
  • 将生成的 HTML 字符串注入模板。
  • 将完整的 HTML 发送到浏览器。
  • 在客户端,匹配 HTML 标签并充实应用程序,使其具有交互性。
  • 初始化 Vite

    我更喜欢使用“pnpm”和“react-swc-ts”Vite 模板,但您可以选择任何其他设置。

    pnpm create vite react-ssr-app --template react-swc-ts

    安装依赖项:

    pnpm install

    更新 React 组件

    在典型的 React 应用程序中,`index.html` 有一个 `main.tsx` 入口点。使用 SSR,您需要两个入口点:一个用于服务器,一个用于客户端。

    服务器入口点

    Node.js 服务器将运行您的应用程序并通过将您的 React 组件渲染为字符串(renderToString)来生成 HTML。

    // ./src/entry-server.tsx
    import { renderToString } from 'react-dom/server'
    import App from './App'
    
    export function render() {
      return renderToString()
    }

    客户端入口点

    浏览器将会整合服务器生成的 HTML,并将其与 JavaScript 连接起来,使页面具有交互性。

    **Hydration** 是将事件监听器和其他动态行为附加到服务器渲染的静态 HTML 的过程。

    // ./src/entry-client.tsx
    import { hydrateRoot } from 'react-dom/client'
    import { StrictMode } from 'react'
    import App from './App'
    
    import './index.css'
    
    hydrateRoot(
      document.getElementById('root')!,
      
        
      ,
    )

    更新 index.html

    更新项目根目录中的 `index.html` 文件。``占位符是服务器将注入生成的 HTML 的位置。

    
    
      
        
        
        
        Vite + React + TS
      
      
        

    创建服务器

    首先,安装依赖项:

    pnpm install -D express compression sirv tsup vite-node nodemon @types/express @types/compression

    服务器所需的所有依赖项都应作为开发依赖项(“devDependencies”)安装,以确保它们不包含在客户端包中。

    接下来,在项目根目录中创建一个名为“./server”的文件夹并添加以下文件。

    重新导出主服务器文件

    重新导出主服务器文件。这使得运行命令更加方便。

    // ./server/index.ts
    export * from './app'

    定义常量

    `HTML_KEY` 常量必须与 `index.html` 中的占位符注释匹配。其他常量管理环境设置。

    // ./server/constants.ts
    export const NODE_ENV = process.env.NODE_ENV || 'development'
    export const APP_PORT = process.env.APP_PORT || 3000
    
    export const PROD = NODE_ENV === 'production'
    export const HTML_KEY = ``

    创建 Express 服务器

    为开发和生产环境设置具有不同配置的 Express 服务器。

    // ./server/app.ts
    import express from 'express'
    import { PROD, APP_PORT } from './constants'
    import { setupProd } from './prod'
    import { setupDev } from './dev'
    
    export async function createServer() {
      const app = express()
    
      if (PROD) {
        await setupProd(app)
      } else {
        await setupDev(app)
      }
    
      app.listen(APP_PORT, () => {
        console.log(`http://localhost:${APP_PORT}`)
      })
    }
    
    createServer()

    开发模式配置

    在开发中,使用 Vite 的中间件处理请求,并通过热重载动态转换 `index.html` 文件。服务器将在每次请求时加载 React 应用程序并将其渲染为 HTML。

    // ./server/dev.ts
    import { Application } from 'express'
    import fs from 'fs'
    import path from 'path'
    import { HTML_KEY } from './constants'
    
    const HTML_PATH = path.resolve(process.cwd(), 'index.html')
    const ENTRY_SERVER_PATH = path.resolve(process.cwd(), 'src/entry-server.tsx')
    
    export async function setupDev(app: Application) {
      // Create a Vite development server in middleware mode
      const vite = await (
        await import('vite')
      ).createServer({
        root: process.cwd(),
        server: { middlewareMode: true },
        appType: 'custom',
      })
    
      // Use Vite middleware for serving files
      app.use(vite.middlewares)
    
      app.get('*', async (req, res, next) => {
        try {
          // Read and transform the HTML file
          let html = fs.readFileSync(HTML_PATH, 'utf-8')
          html = await vite.transformIndexHtml(req.originalUrl, html)
    
          // Load the entry-server.tsx module and render the app
          const { render } = await vite.ssrLoadModule(ENTRY_SERVER_PATH)
          const appHtml = await render()
    
          // Replace the placeholder with the rendered HTML
          html = html.replace(HTML_KEY, appHtml)
          res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
        } catch (e) {
          // Fix stack traces for Vite and handle errors
          vite.ssrFixStacktrace(e as Error)
          console.error((e as Error).stack)
          next(e)
        }
      })
    }

    生产模式配置

    在生产中,使用“压缩”来优化性能,使用“sirv”来提供静态文件和预构建的服务器包来呈现应用程序。

    // ./server/prod.ts
    import { Application } from 'express'
    import fs from 'fs'
    import path from 'path'
    import compression from 'compression'
    import sirv from 'sirv'
    import { HTML_KEY } from './constants'
    
    const CLIENT_PATH = path.resolve(process.cwd(), 'dist/client')
    const HTML_PATH = path.resolve(process.cwd(), 'dist/client/index.html')
    const ENTRY_SERVER_PATH = path.resolve(process.cwd(), 'dist/ssr/entry-server.js')
    
    export async function setupProd(app: Application) {
      // Use compression for responses
      app.use(compression())
      // Serve static files from the client build folder
      app.use(sirv(CLIENT_PATH, { extensions: [] }))
    
      app.get('*', async (_, res, next) => {
        try {
          // Read the pre-built HTML file
          let html = fs.readFileSync(HTML_PATH, 'utf-8')
    
          // Import the server-side render function and generate HTML
          const { render } = await import(ENTRY_SERVER_PATH)
          const appHtml = await render()
    
          // Replace the placeholder with the rendered HTML
          html = html.replace(HTML_KEY, appHtml)
          res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
        } catch (e) {
          // Log errors and pass them to the error handler
          console.error((e as Error).stack)
          next(e)
        }
      })
    }

    配置构建

    为了遵循构建应用程序的最佳实践,您应该排除所有不必要的包,并仅包含应用程序实际使用的包。

    更新 Vite 配置

    更新您的 Vite 配置以优化构建过程并处理 SSR 依赖项:

    // ./vite.config.ts
    import { defineConfig } from 'vite'
    import react from '@vitejs/plugin-react-swc'
    import { dependencies } from './package.json'
    
    export default defineConfig(({ mode }) => ({
      plugins: [react()],
      ssr: {
        noExternal: mode === 'production' ? Object.keys(dependencies) : undefined,
      },
    }))

    更新 tsconfig.json

    更新你的 `tsconfig.json` 以包含服务器文件并适当地配置 TypeScript:

    {
      "include": [
        "src",
        "server",
        "vite.config.ts"
      ]
    }

    创建 tsup 配置

    使用 TypeScript 捆绑器“tsup”来构建服务器代码。“noExternal”选项指定要与服务器捆绑的软件包。**请务必包含服务器使用的任何其他软件包。**

    // ./tsup.config.ts
    import { defineConfig } from 'tsup'
    
    export default defineConfig({
      entry: ['server'],
      outDir: 'dist/server',
      target: 'node22',
      format: ['cjs'],
      clean: true,
      minify: true,
      external: ['lightningcss', 'esbuild', 'vite'],
      noExternal: ['express', 'sirv', 'compression'],
    })

    添加构建脚本

    {
      "scripts": {
        "dev": "nodemon --exec vite-node server --watch server --ext ts",
        "start": "NODE_ENV=production node dist/server/index.cjs",
        "build": "tsc -b && npm run build:client && npm run build:ssr && npm run build:server",
        "build:client": "vite build --outDir dist/client",
        "build:ssr": "vite build --outDir dist/ssr --ssr src/entry-server.tsx",
        "build:server": "tsup"
      }
    }

    运行应用程序

    **开发**:使用以下命令以热重加载启动应用程序:

    pnpm dev

    **生产**:构建应用程序并启动生产服务器:

    pnpm build && pnpm start

    要验证 SSR 是否正常工作,请检查对服务器的第一个网络请求。响应应包含应用程序的完全渲染的 HTML。

    路由

    要向您的应用添加不同的页面,您需要正确配置路由并在客户端和服务器入口点处理它。

    pnpm install react-router

    添加客户端路由

    在客户端入口点使用“BrowserRouter”包装您的应用程序以启用客户端路由。

    // ./src/entry-client.tsx
    import { hydrateRoot } from 'react-dom/client'
    import { StrictMode } from 'react'
    import { BrowserRouter } from 'react-router'
    import App from './App'
    
    import './index.css'
    
    hydrateRoot(
      document.getElementById('root')!,
      
        
          
        
      ,
    )

    添加服务器端路由

    在服务器入口点使用 `StaticRouter` 来处理服务器端路由。将 `url` 作为 prop 传递,以根据请求呈现正确的路由。

    import { renderToString } from 'react-dom/server'
    import { StaticRouter } from 'react-router'
    import App from './App'
    
    export function render(url: string) {
      return renderToString(
        
          
        ,
      )
    }

    更新服务器配置

    更新开发和生产服务器设置,将请求 URL 传递给 `render` 函数:

    // ./server/dev.ts
    // ./server/prod.ts
    const appHtml = await render(req.url)
    
    // Replace the placeholder with the rendered HTML
    html = html.replace(HTML_KEY, appHtml)
    res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
    //...

    通过这些更改,您现在可以在 React 应用中创建与 SSR 完全兼容的路由。但是,这种基本方法不能处理延迟加载的组件(“React.lazy”)。有关管理延迟加载模块,请参阅我的另一篇文章《使用流和动态数据的高级 React SSR 技术》,链接在底部。

    Docker

    这是一个用于容器化你的应用程序的 Dockerfile:

    # Build App
    FROM node:22-alpine as builder
    
    WORKDIR /app
    COPY . .
    
    RUN corepack enable && pnpm install && pnpm build
    
    # Production
    FROM node:22-alpine
    
    WORKDIR /app
    COPY --from=builder /app/dist ./dist
    COPY --from=builder /app/package.json ./package.json
    
    EXPOSE 3000
    
    CMD ["npm", "run", "start"]

    构建并运行Docker镜像

    docker build -t react-ssr-app .
    docker run -p 3000:3000 react-ssr-app

    结论

    在本指南中,我们为使用 React 创建可用于生产的 SSR 应用程序奠定了坚实的基础。您已经了解了如何设置项目、配置路由和创建 Dockerfile。此设置非常适合高效构建登录页面或小型应用程序。

    探索代码

  • 示例:react-ssr-basics-example
  • 模板:react-ssr-template
  • Vite 额外模板:template-ssr-react-ts
  • 相关文章

    这是我关于使用 React 进行 SSR 系列文章的一部分。敬请期待更多文章!

  • 构建可用于生产的 SSR React 应用程序
  • 具有流和动态数据的高级 React SSR 技术
  • 在 SSR React 应用程序中设置主题
  • React 服务器端渲染应用程序的顶级工具