搭建 Vite + React 服务端渲染(SSR)环境

搭建 Vite + React 服务端渲染(SSR)环境

前言

服务端渲染(SSR),顾名思义,是在服务器端生成 HTML 的技术。对于早期的开发者来说,这并不陌生------过去我们常用模板语言开发,由后端通过模板引擎渲染页面。随着技术发展,SPA(单页应用)流行起来,渲染工作转移到浏览器端由 JavaScript 完成(客户端渲染)。

那么,为什么 SSR 又重新流行起来了呢?主要有两个原因:一是 Next.js、Nuxt.js 等前端框架的发展让 SSR 实现更加简单;二是 SSR 具有客户端渲染无法替代的优势:

  • 首屏性能:客户端渲染需要等待 JavaScript 加载并执行完成后才能显示内容,而服务端渲染在服务端就生成了内容,只需等待 HTML 解析即可看到页面
  • SEO:服务端渲染可以输出完整的内容,而大多数搜索引擎难以解析 JavaScript 渲染的内容,客户端渲染对 SEO 不友好

基于这些优势,SSR 特别适合需要 SEO 和首屏性能的 2C 应用。我最近也因为项目需求开始研究 SSR,虽然目前使用 Next.js 能够快速上手,但我更希望自己动手实现一个完整的 SSR 环境,包括 React、React-Router、CSS 等相关配置。

本文将记录我的完整实现过程,虽然可能有些详细,但都是实实在在的踩坑经验。

初始化项目

首先初始化一个 React 项目。参考 React 官网,我们使用 Vite 进行构建------这是一个以速度闻名的新一代构建工具。

shell 复制代码
npm create vite@latest

按照提示输入项目名称(我命名为 vite-react-ssr),选择 React 框架,然后根据偏好选择是否使用 TypeScript。完成后会生成基础项目结构:

lua 复制代码
vite-react-ssr
├── public
│   └── vite.svg
├── src
│   ├── assets
│   │   └── react.svg
│   ├── App.css
│   ├── App.jsx
│   ├── index.css
│   └── main.jsx
├── .gitignore
├── eslint.config.js
├── index.html
├── package-lock.json
├── package.json
├── README.md
└── vite.config.js

服务端渲染改造

实现 React 的服务端渲染,核心是两步:

  1. 服务器接收请求,根据请求信息渲染 HTML 并返回(主要 API:renderToString
  2. 客户端获取渲染后的 HTML 进行水合(主要 API:hydrateRoot

当然,实际应用中还需要考虑 React Router 接入、按需加载、数据预取等功能的支持。

服务器搭建

我们使用 Express 搭建服务器:

shell 复制代码
yarn add express

在根目录下创建 server.js,代码主要参考 Vite 官网 SSR 指南

javascript 复制代码
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import express from 'express'
import { createServer as createViteServer } from 'vite'

const __dirname = path.dirname(fileURLToPath(import.meta.url))

async function createServer() {
  const app = express()

  // 以中间件模式创建 Vite 应用,禁用 Vite 自身的 HTML 服务逻辑
  const vite = await createViteServer({
    server: { middlewareMode: true },
    appType: 'custom'
  })

  // 使用 vite 的 Connect 实例作为中间件
  app.use(vite.middlewares)

  app.use('*all', async (req, res, next) => {
    const url = req.originalUrl
    try {
      // 1. 读取 index.html
      let template = fs.readFileSync(
        path.resolve(__dirname, 'index.html'),
        'utf-8'
      )

      // 2. 应用 Vite HTML 转换,注入 Vite HMR 客户端等
      template = await vite.transformIndexHtml(url, template)

      // 3. 加载服务器入口
      const { render } = await vite.ssrLoadModule('/src/entry-server.jsx')

      // 4. 渲染应用的 HTML
      const appHtml = await render(url)

      // 5. 注入渲染后的应用程序 HTML 到模板中
      const html = template.replace(`<!--ssr-outlet-->`, appHtml.html)
      
      // 6. 返回渲染后的 HTML
      res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
    } catch (e) {
      // 错误处理:让 Vite 修复堆栈跟踪,映射回实际源码
      vite.ssrFixStacktrace(e)
      next(e)
    }
  })

  app.listen(5173)
  console.log('http://localhost:5173')
}

createServer()

接下来实现 render 方法。在 src 目录下创建 entry-server.jsx

javascript 复制代码
import { StrictMode } from 'react'
import { renderToString } from 'react-dom/server'
import App from './App'

export function render() {
  const html = renderToString(
    <StrictMode>
      <App />
    </StrictMode>
  )
  return {
    html
  }
}

server.js 中,我们将 render 方法返回的字符串替换到 index.html<!--ssr-outlet--> 位置。因此需要在 index.html 中添加这个占位符:

html 复制代码
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>vite-react-ssr</title>
  </head>
  <body>
    <div id="root"><!--ssr-outlet--></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

客户端水合

现在运行 node server.js 可以成功访问 http://localhost:5173,可以看到返回的 HTML 内容符合预期。但在执行 main.jsx 时,createRoot 方法检测到 root 元素内已有 DOM 时会删除重新生成,相当于又执行了一次客户端渲染。

这时就需要水合(hydrate)------React 在现有 DOM 元素的基础上进行数据和事件绑定。调整 main.jsx

javascript 复制代码
import { StrictMode } from 'react'
import { hydrateRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'

hydrateRoot(
  document.getElementById('root'),
  <StrictMode>
    <App />
  </StrictMode>
)

package.json 中的 dev 命令更改为:

json 复制代码
"dev": "node server.js",

至此,我们完成了一个基础版本的服务端渲染项目(仅限开发环境)。接下来看看如何接入 React Router。

接入 React Router

实际开发中,React 应用通常会使用 React Router。在服务端渲染时,需要区分服务端和客户端的 Router:

  • 服务端使用 StaticRouter
  • 客户端使用 BrowserRouter

改造步骤

  1. 改造 App.jsx,添加路由配置:
javascript 复制代码
<div className="children">
  <Routes>
    <Route path="/" Component={() => <div>parent</div>}></Route>
    <Route path="/child" Component={Child}></Route>
  </Routes>
</div>
  1. 改造 main.jsx ,使用 BrowserRouter 包裹组件:
javascript 复制代码
import { BrowserRouter } from 'react-router-dom'

hydrateRoot(
  document.getElementById('root'),
  <StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </StrictMode>
)
  1. 改造 entry-server.jsx ,使用 StaticRouter 包裹组件并传入 location:
javascript 复制代码
import { StaticRouter } from 'react-router-dom/server'

export function render(url) {
  const html = renderToString(
    <StrictMode>
      <StaticRouter location={url}>
        <App />
      </StaticRouter>
    </StrictMode>
  )
  return {
    html
  }
}
  1. 新增 Child.jsx 文件:
javascript 复制代码
const Child = () => {
  return <div> this is Child </div>
}

export default Child

现在访问 http://localhost:5173/child,可以看到 Child 组件的内容也被一同渲染返回。

实现按需加载

SSR 应用通常对性能有较高要求,按需加载是必不可少的优化手段。目前我们使用的 renderToString 是同步方法,不支持异步加载。需要使用 renderToPipeableStream 进行渲染,并通过 React.lazySuspense 改造为异步加载应用。

改造步骤

  1. 使用 React.lazy 改造组件导入
javascript 复制代码
// 原来的导入方式
// import Child from './Child'

// 改为懒加载
const Child = React.lazy(() => import('./Child'))
  1. 使用 Suspense 包裹路由
javascript 复制代码
<div className='children'>
  <Suspense fallback={'加载中'}>
    <Routes>
      <Route path='/' Component={() => <div>parent</div>}></Route>
      <Route path='/child' Component={Child}></Route>
    </Routes>
  </Suspense>
</div>

现在直接访问 child 路由会出现错误,提示需要使用 renderToPipeableStream

vbscript 复制代码
Uncaught Error: Switched to client rendering because the server rendering aborted due to:

The server used "renderToString" which does not support Suspense. If you intended for this Suspense boundary to render the fallback content on the server consider throwing an Error somewhere within the Suspense boundary. If you intended to have the server wait for the suspended component please switch to "renderToPipeableStream" which supports Suspense on the server
  1. 切换到 renderToPipeableStream

改造 entry-server.jsx

javascript 复制代码
import { renderToPipeableStream } from 'react-dom/server'

export const render = async (url) => {
  return new Promise(function (resolve, reject) {
    const stream = renderToPipeableStream(
      <html lang="zh">
        <head>
          <meta charSet="UTF-8"></meta>
          <link rel="icon" href="/vite.svg" />
          {/* React 热更新相关代码 */}
          <script type="module" dangerouslySetInnerHTML={{
            __html: `import {injectIntoGlobalHook} from "/@react-refresh";
            injectIntoGlobalHook(window);
            window.$RefreshReg$ = () => { };
            window.$RefreshSig$ = () => (type) => type;`
          }}></script>
          <script type="module" src="/@vite/client"></script>
        </head>
        <body>
          <div id="root">
            <StrictMode>
              <StaticRouter location={url}>
                <App />
              </StaticRouter>
            </StrictMode>
          </div>
        </body>
      </html>,
      {
        bootstrapModules: ['/src/main.jsx'],
        onShellReady() {
          resolve(stream)
        },
        onShellError(error) {
          reject(error)
        },
        onAllReady() {
          console.log('All content is ready')
        }
      }
    )
  })
}

由于直接输出流到浏览器,无法进行字符串拼接,所以将整个 HTML 结构写入组件中。这里引入了 React 热更新相关的代码,如果不加入会出现以下错误:

vbnet 复制代码
Uncaught Error: @vitejs/plugin-react can't detect preamble. Something is wrong.

替代方案:直接在应用中引入 @vitejs/plugin-react/preamble 也可以解决这个报错。

改造 server.js

javascript 复制代码
app.use('*all', async (req, res, next) => {
  const url = req.originalUrl
  try {
    const { render } = await vite.ssrLoadModule('/src/entry-server.jsx')
    const stream = await render(url)
    res.status(200).set({ 'Content-Type': 'text/html' })
    stream.pipe(res)
  } catch (e) {
    vite.ssrFixStacktrace(e)
    next(e)
  }
})

现在启动应用,可以看到 child 路由按预期返回。

数据预取:React Router 的 Data API

服务端渲染中的数据预取,指在服务端提前获取数据并渲染后再返回。例如商品详情页面,需要在服务端获取商品标题、价格等信息并渲染到 HTML 中。

React Router 在 6.4 版本引入了 Data API,可以将请求定义在路由中,在组件内通过 useLoaderData 获取。这让我们无需自己封装数据预取逻辑。

Data API 最重要的意义可能不仅是数据预取,还解决了数据请求瀑布问题。具体可参考官网文档

改造步骤

  1. 提取路由配置 ,创建 routes/index.js
javascript 复制代码
import React, { Suspense } from 'react'
const App = React.lazy(() => import('../App'))
const Child = React.lazy(() => import('../Child'))

export default [
  {
    path: '/',
    Component: (
      <Suspense fallback="加载中">
        <App />
      </Suspense>
    ),
    children: [
      {
        path: '/child',
        Component: (
          <Suspense fallback="加载中">
            <Child />
          </Suspense>
        ),
        async loader() {
          // 模拟数据获取
          return fetch('http://127.0.0.1:8080/data.json').then(res => res.json())
        }
      }
    ]
  }
]
  1. 客户端使用 RouterProvider 和 createBrowserRouter
javascript 复制代码
import { RouterProvider, createBrowserRouter } from 'react-router-dom'
import routes from './routes/index.jsx'

const router = createBrowserRouter(routes)

hydrateRoot(
  document.getElementById('root'),
  <StrictMode>
    <RouterProvider router={router}></RouterProvider>
  </StrictMode>
)
  1. 服务端配置 ,使用 createStaticRouterStaticRouterProvidercreateStaticHandler
javascript 复制代码
import { createStaticRouter, StaticRouterProvider, createStaticHandler } from 'react-router-dom/server'

const handler = createStaticHandler(routes)

export const render = async (req) => {
  const request = createFetchRequest(req)
  const context = await handler.query(request)
  
  if (context instanceof Response) {
    return context
  }
  
  const router = createStaticRouter(handler.dataRoutes, context)
  
  // ... 在 JSX 中使用
  <div id="root">
    <StrictMode>
      <StaticRouterProvider router={router} context={context}></StaticRouterProvider>
    </StrictMode>
  </div>
  // ...
}
  1. 实现 createFetchRequest(针对 Express 框架):
javascript 复制代码
import { Readable } from 'stream'

function createFetchRequest(req) {
  let origin = `${req.protocol}://${req.get('host')}`
  let url = new URL(req.originalUrl || req.url, origin)
  let body
  
  if (req.method !== 'GET' && req.method !== 'HEAD') {
    body = Readable.toWeb(req)
  }
  
  let headers = new Headers()
  for (let [key, value] of Object.entries(req.headers)) {
    if (Array.isArray(value)) {
      value.forEach(v => headers.append(key, v))
    } else {
      headers.set(key, value)
    }
  }

  return new Request(url.href, {
    method: req.method,
    headers: headers,
    body: body,
  })
}
  1. 在组件中使用 useLoaderData 获取数据 ,改造 Child.jsx
javascript 复制代码
import { useLoaderData } from "react-router-dom"

const Child = () => {
  const { name, content } = useLoaderData()
  return <div>
    <h1>{name}</h1>
    <p>{content}</p>
  </div>
}

export default Child

这里我使用 http-serve 启动本地服务访问 data.json 文件:

json 复制代码
{
  "name": "服务端渲染",
  "content": "服务端渲染好得很"
}

启动应用,访问 child 路由,可以看到 name 和 content 都已被预渲染到页面上。

遇到的问题与解决方案

Chrome DevTools 报错

启动应用后,Chrome 浏览器控制台会出现以下报错:

arduino 复制代码
No routes matched location "/.well-known/appspecific/com.chrome.devtools.json" 

这不是项目问题,而是 Chrome DevTools 的双向编辑功能导致的。解决方法:

  1. 在 Chrome 中关闭该功能
  2. 更好的方案:引入 vite-plugin-devtools-json
javascript 复制代码
// vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import devtoolsJson from 'vite-plugin-devtools-json'

export default defineConfig({
  plugins: [
    react(),
    devtoolsJson()
  ],
})

配置后,可以在 DevTools 的 Sources → Workspace 中直接编辑项目源码。

总结

本文记录了我从零搭建 React SSR 环境的完整过程,循序渐进地解决了遇到的问题。虽然实际生产中建议使用成熟的框架(我自己也是这么做的),但了解搭建过程能帮助我们更好地理解框架原理。

文中所有代码都放在 GitHub 仓库 vite-react-ssr 中,供大家参考。

为什么要写这篇文章?因为在查阅官方文档时,发现文档只提供了最简单的 demo,React Router 接入、按需加载等功能需要查找多方资料才能拼凑完整。希望这篇详细的记录能帮助到正在探索 SSR 的开发者们。

相关推荐
上车函予6 小时前
点击即扩散:使用 View Transition API 实现 UnoCSS 官网同款主题切换动画
前端·javascript·css
星链引擎6 小时前
生成式 AI 驱动下的智能聊天机器人 技术架构核心实现与场景落地
前端
Asort6 小时前
React框架深度剖析:设计理念、核心机制与现代生态对比
前端·javascript·react.js
charlie1145141917 小时前
从模仿到掌握:尝试一下Native CSS手写一个好看的按钮
前端·css·学习·ui
阿登林7 小时前
Unity3D与Three.js构建3D可视化模型技术对比分析
开发语言·javascript·3d
时间的情敌7 小时前
Vue3+CSS 实现3D卡片动画
前端·css·3d
吃饺子不吃馅7 小时前
Canvas 如何渲染富文本、图片、SVG 及其 Path 路径?
前端·svg·canvas
王六岁7 小时前
🐍 前端开发 0 基础学 Python 入门指南:f-strings 篇
前端·javascript·python
一道雷7 小时前
🚀 Vue Router 插件系统:让路由扩展变得简单优雅
前端·javascript·vue.js