前端渲染:从 CSR、SSR 到同构与手写 Vite+React SSR 实践

在现代全栈开发的日常中,尤其是当我们着手构建大型 Web 应用或负责 C 端核心业务时,总会不可避免地撞上一座大山:首屏加载性能与 SEO 优化

无论是早期的 jQuery 时代,还是如今由 React、Vue 主导的组件化时代,前端圈一直在围绕一个核心问题进行技术迭代:网页到底是谁组装的? 是用户的浏览器,还是远端的服务器?

本文将由浅入深,带你彻底厘清 CSR、SSR 的前世今生,探讨现代框架的"同构渲染"黑魔法,并最终从零手写一个基于 Vite + Express + React 的 SSR 服务,让你不仅知其然,更知其所以然。

一、 渲染模式的演进:CSR 与 SSR 的较量

要理解现代架构,我们必须先看懂历史。

1. CSR (Client Side Render) 客户端渲染

在单页应用(SPA)横行的今天,CSR 是我们最熟悉的模式。它的本质是:服务器先返回一个"空壳" HTML,所有的页面渲染、路由跳转、状态管理都在用户的浏览器端由 JavaScript 完成。

工作流程(四步走):

  1. 请求 HTML: 用户输入网址,浏览器向服务器发起请求。
  2. 返回空壳: 服务器仅返回一个极简的 HTML(通常只有一个 <div id="root"></div>)和一个打包好的庞大 JavaScript 文件(如 bundle.js)。
  3. JS 解析(白屏期): 浏览器开始下载并执行 JS 文件。在这个过程中,用户只能看到大白屏或骨架屏。
  4. 数据请求与组装: JS 运行时向后端 API 请求数据,拿到 JSON 数据后,在本地动态生成 DOM 元素并插入页面,画面最终呈现。

优缺点剖析:

  • 优势(体验与成本): 服务器压力极小,只负责吐静态资源和 API 数据。"炒菜组装"的算力消耗全部转移给了用户的设备。一旦首屏加载完成,后续交互(如路由切换、弹窗)均在本地计算,体验极其丝滑,媲美原生 APP。
  • 致命劣势(性能与 SEO): 必须等待 JS 下载并执行完毕才能看到内容,网络稍差就会导致严重的首屏白屏 。更致命的是,SEO(搜索引擎优化)极差。由于爬虫大多不会去执行复杂的 JS 代码,它们抓取到的永远只是那个没有实质内容的"空壳 HTML",导致网站毫无自然流量。

适用场景:

后台管理系统、SaaS 工具、重交互的 Web 应用(如在线文档、可视化大屏)。这些内部系统无需考虑 SEO,开发体验和操作流畅度才是第一要务。

2. SSR (Server Side Render) 服务端渲染

为了解决 CSR 的痛点,业界把目光重新投向了服务器。利用 Node.js 环境能够运行 JS 的特性,在服务器端提前把 React/Vue 组件和数据拼接好,直接生成完整的 HTML 字符串返回给浏览器。

工作流程:

  1. 发起请求: 用户访问页面。
  2. 服务端组装(核心): 服务器接收请求后,在后端直接调用接口拉取数据,然后将数据和 React 模板拼接,生成包含完整内容的 HTML。
  3. 返回成品: 将这套完整的 HTML 发送给浏览器。
  4. 直接展示: 浏览器拿到的是现成的 HTML DOM 树,直接渲染展示,用户瞬间就能看到满屏内容。

优缺点剖析:

  • 优势(性能与流量): 首屏加载极快 ,彻底告别白屏。SEO 完美,搜索引擎爬虫能直接抓取到丰富的页面文本,极其利于收录和排名。
  • 劣势(成本与复杂度): 服务器压力剧增 。每个用户的每次访问都需要服务器去实时"炒菜",高并发场景下极易成为性能瓶颈。此外,开发复杂度变高,需要处理 Node.js 环境与浏览器环境的差异(例如在 useEffect 触发前,服务端是没有 windowdocument 对象的)。

适用场景:

官网、新闻门户网站、电商商品详情页等高度依赖搜索引擎引流的 C 端页面。

二、 现代架构的答案:同构渲染 (Isomorphic Rendering)

非黑即白的时代已经过去。小孩子才做选择,现代前端全都要。为了结合 CSR 的极佳交互体验和 SSR 的首屏/SEO 优势,诞生了诸如 Next.js 和 Nuxt.js 这样的现代框架。它们的核心机制就是同构渲染

同构渲染的口诀很简单:首次访问 SSR + 后续交互 CSR

  1. SSR 阶段(极速首屏): 当你第一次打开网页时,服务器立刻将组装好的、带有完整内容的 HTML 返回。屏幕瞬间出现画面,爬虫也非常满意。
  2. 下载脚本: 浏览器在展示静态画面的同时,后台开始默默下载包含交互逻辑的 JS 文件。
  3. Hydration(水合/注水): JS 加载完成后,会在浏览器里重新执行一遍,并静默地"附着"到刚才那个静态的 HTML 页面上。它会对比当前的 DOM 树,不重建 DOM,只是给原本静态的按钮绑上事件,给表单注入状态。这个过程就像给干瘪的海绵注入水分,让它变得鲜活可交互。
  4. CSR 接管: "注水"完成后,页面彻底变成 SPA。后续点击链接只会请求数据,不再请求完整 HTML,体验恢复到极致丝滑。

三、 深水区实战:基于 Vite + Express 手写 SSR

纸上得来终觉浅,绝知此事要躬行。接下来,我们将抛开 Next.js 的封装,从底层原理出发,手写一个包含水合机制的 React SSR 应用。

1. 扫清工程化障碍:路径与 Vite 的角色

在编写 Node 服务端代码前,必须厘清两个概念:

A. 路径处理:path.resolve() vs path.join()

在处理静态资源时经常踩坑:

  • path.join():简单的字符串路径拼接。把 / 看作普通字符,结果可能是相对路径。
  • path.resolve():将路径解析为绝对路径 。它会从右向左解析,把 / 看作根目录并丢弃左侧路径。若参数不足以构成绝对路径,则默认拼接当前工作目录(CWD)。

注意:在 ESM 中,不支持 CommonJS 的 __dirname,可以使用 path.resolve()(不传参时即为 CWD)来替代。

B. Vite 在 SSR 中的角色:包工头

在普通的 CSR 中,浏览器负责解析 JS。但在 SSR 中,Node 不认识 .jsx 语法。我们需要通过 createViteServer 将 Vite 以中间件的形式嵌入到 Express 中,让 Vite 接管编译工作。

Vite 提供的三大绝招:

  1. vite.middlewares:共享中间件,处理静态资源和热更新(HMR)。
  2. vite.transformIndexHtml:HTML 转换,将 HMR 客户端脚本注入原始模板。
  3. vite.ssrLoadModuleSSR 的灵魂 API。突破 Node 限制,在后台瞬时编译 React 组件,返回 Node 可直接运行的 ESM 模块对象。

2. 实战代码拆解

我们的目标是实现一套完整的同构渲染架构。

步骤一:HTML 骨架与挂载点 (index.html)

准备一个包含占位符的 HTML 文件。注意这里的 `` 标记,服务器等下会将渲染好的 React 组件代码替换到这里。同时引入客户端入口脚本。

HTML 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>SSR 实战</title>
</head>
<body>
    <div id="root"><!--app-html--></div>
    <script type="module" src="/src/entry-client.jsx"></script>
</body>
</html>

步骤二:编写 React 组件 (App.jsx)

写一个极简的组件。加入 onClick 事件是为了验证后续的**水合(Hydration)**是否成功。如果只是单纯的静态 HTML 替换,点击是不会弹窗的。

JavaScript 复制代码
export default function App() {
    return <h1 onClick={() => alert('水合成功!Hello Vite SSR')}>Hello Vite SSR</h1>
}

步骤三:双端入口设计

同构应用需要两个入口:一个给服务器执行,一个给浏览器执行。

服务端入口 (entry-server.jsx):

职责:将 React 组件渲染成纯粹的 HTML 字符串。不涉及任何 DOM 操作,不执行生命周期(如 useEffect)和事件绑定。

JavaScript 复制代码
import React from 'react';
// react-dom/server 提供将组件渲染为 HTML 字符串的能力
import { renderToString } from 'react-dom/server';
import App from './App';

export function render() {
    console.log('Server is rendering the App...');
    return renderToString(<App />);
}

客户端入口 (entry-client.jsx):

职责:Hydration(水合)。浏览器接收到 HTML 后,在此处把事件监听等前端逻辑"粘"上去。

JavaScript 复制代码
console.log('Client script is running...');
import React from 'react';
import { hydrateRoot } from 'react-dom/client';
import App from './App';

// 水合渲染:让服务器端的 HTML 字符串变成可交互的页面
// React 在前端再执行一次,与现有 DOM 对比,注入事件和逻辑
hydrateRoot(document.getElementById('root'), <App />);

步骤四:编写 Express 核心调度服务 (server.js)

这是整个架构的枢纽。Express 扮演 Web 服务器(洋葱模型式的中间件处理请求),Vite 扮演实时编译器。

JavaScript 复制代码
import fs from 'fs';
import path from 'path';
import express from 'express';
import { createServer as createViteServer } from 'vite';

// 获取当前目录的绝对路径
const __dirname = path.resolve();
const app = express();

async function start() {
    console.log('SSR Server starting...');
    
    // 1. 以中间件模式创建 Vite 服务器
    const vite = await createViteServer({
        server: { middlewareMode: true }, // 关键:告诉 Vite 不要自己启动 HTTP 服务
        appType: 'custom'                 // 告诉 Vite 页面 HTML 的渲染由 Express 接管
    });
    
    // 2. 将 Vite 作为中间件注入到 Express
    // 处理静态资源、热更新逻辑
    app.use(vite.middlewares);

    // 3. 拦截所有请求,手写 SSR 逻辑
    app.use(async (req, res) => {
        try {
            // A. 同步读取原始的 index.html 模板
            let template = fs.readFileSync(
                path.resolve(__dirname, 'index.html'),
                'utf-8'
            );

            // B. 让 Vite 接管 HTML 转换
            // 这一步至关重要,它会注入 Vite 的 HMR 热更新脚本
            template = await vite.transformIndexHtml(req.url, template);

            // C. 加载服务器端入口文件
            // vite.ssrLoadModule 突破 Node 限制,动态编译 jsx 并返回模块对象
            const { render } = await vite.ssrLoadModule('/src/entry-server.jsx')
            
            // D. 在服务端执行 render,将 React 组件渲染成完整的 HTML 字符串
            const html = await render();
            
            // E. 将渲染出的 HTML 字符串替换到模板的占位符中
            template = template.replace('<!--app-html-->', html);
            
            // F. 将组装好的完整 HTML 返回给浏览器
            res.status(200).set({'Content-Type': 'text/html'}).end(template);
        } catch (error) {
            // 捕获编译错误,通过 Vite 修复堆栈追踪
            vite.ssrFixStacktrace(error);
            res.status(500).end(error.message);
        }
    })
}

app.listen(3000, () => {
    console.log('Server is running on port 3000');
});

start();

四、 总结

通过上述实践,我们走通了现代前端渲染的闭环:

  1. Express 接收到用户的 URL 请求。
  2. 读取本地 index.html,并通过 Vite 中间件 注入热更新代码。
  3. Vite 在后端即时编译 entry-server.jsx,调用 renderToString,快速炒出一盘"没有灵魂"(无交互)的完整 HTML 页面,发给浏览器。这就是 SSR 阶段,爬虫狂喜,用户秒看首屏。
  4. 浏览器解析 HTML,遇到 <script src="/src/entry-client.jsx"> 开始下载前端逻辑。
  5. 前端逻辑加载完毕,调用 hydrateRoot 进行水合 ,页面瞬间拥有了灵魂,点击事件生效。自此,页面由 CSR 接管

理解了这些底层 API 的流转,再去审视像 Next.js 这种高度封装的生产级 SSR 框架时,便能做到知根知底。现代全栈不仅仅是 API 搬运,深入掌控应用的生命周期和渲染管线,才是构建高性能 Web 系统的基石。

相关推荐
Lee川2 小时前
React Router 实战指南:构建现代化前端路由系统
前端·react.js·架构
M ? A3 小时前
Vue3 转 React 工具 VuReact v1.6.0 更新:useAttrs 完美兼容,修复模板迁移 / 类型错误
前端·javascript·vue.js·react.js·开源·vureact
低保和光头哪个先来3 小时前
解决 ios 使用 video 全屏未铺满页面问题
前端·javascript·vue.js·ios·前端框架
M ? A4 小时前
Vue3 转 React:组件透传 Attributes 与 useAttrs 使用详解|VuReact 实战
前端·javascript·vue.js·经验分享·react.js·开源·vureact
San30.5 小时前
前端渲染:从 CSR、SSR 到同构与手写 Vite+React SSR 实践
前端·react.js·前端框架
FrontAI18 小时前
Next.js从入门到实战保姆级教程:环境配置与项目初始化
react.js·typescript·学习方法
用户31532477954521 小时前
React19项目中 FormEdit / FormEditModal 组件封装设计说明
前端·react.js
Ruihong1 天前
Vue3 转 React:组件透传 Attributes 与 useAttrs 使用详解|VuReact 实战
vue.js·react.js