阅读视图

发现新文章,点击刷新页面。

前端渲染:从 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 组件代码替换到这里。同时引入客户端入口脚本。

<!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 替换,点击是不会弹窗的。

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

步骤三:双端入口设计

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

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

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

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 后,在此处把事件监听等前端逻辑“粘”上去。

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 扮演实时编译器。

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 系统的基石。

深入浅出:彻底搞懂 WebSocket、SSE 与心跳机制

在当今的大前端与全栈开发中,实时通信技术已经成为了不可或缺的技能。特别是随着 ChatGPT 等大语言模型(LLM)的爆火,实时流式输出技术再次被推上风口浪尖。

很多前端同学对传统的 HTTP 协议了如指掌,但在面对多协议开发(如 WebSocket、SSE)时,往往会感到一丝陌生。在面试中,面试官也常常借此切入,考察候选人对 408 计算机网络底层协议的理解,以及浏览器(B/S架构)与传统客户端(C/S架构)在通信上的差异。

今天,我们将从业务场景选型出发,带你深入剖析 HTTP、SSE 与 WebSocket 的核心差异,并手把手带你用 Node.js (Koa) 实现一个支持多人在线的 Chat App,最后深入探讨长连接中必不可少的“心跳机制”。

一、 业务场景选型:为什么聊天应用必须用 WebSocket?

假设我们要开发一个多人在线聊天室(Chat App),面对这个需求,我们通常有三种技术栈选择。让我们来看看它们的优劣:

1. HTTP 协议(轮询方案)

HTTP 协议是基于传统的“请求-响应”模型的短连接通信。

  • 机制: 客户端发起请求,服务端返回响应。如果要获取最新消息,前端必须使用 setInterval 等方式不断发起 Fetch 或 Ajax 请求(这种技术称为短轮询)。
  • 痛点: 这种方式性能极差且十分复杂。即使 HTTP 协议可以通过 Connection: keep-alive 复用底层的 TCP/IP 通道,但其应用层依然是单向的“一问一答”模式。这会导致大量无效请求,极大地浪费服务器带宽和性能。

2. SSE (Server-Sent Events)

SSE 是一种轻量级的长连接持久化单向通道技术。

  • 机制: 建立连接后,服务器可以单向、持续地向客户端推送文本数据。
  • 适用场景: 它是 LLM(大语言模型)流式打字机输出的当下业务热点。非常适合“用户 Prompt 一次,LLM 流式输出”的场景。
  • 痛点: 聊天是双向的(既要收消息,又要发消息)。SSE 只能做到服务端持续推送,无法做到用户端向服务端的持续推送,因此不适合全双工的聊天应用。

3. WebSocket 协议(终极方案)

WebSocket 是 HTML5 提供的新特性,用于在 Web 端实现即时通讯。

  • 机制: 它是一种在浏览器和服务器之间建立“长连接”的协议,可以实现真正的双向实时全双工通信。
  • 优势: 一次连接,持续通信。服务器端和用户端都可以随时主动向对方推送数据,完美契合聊天应用实时收发、多人同步的需求。

📊 核心特性全方位对比

为了更直观地理解,我们可以通过下表快速对比这三种通信方式:

对比维度 HTTP SSE (Server-Sent Events) WebSocket
通信方式 单向(客户端发起,服务端响应) 单向(仅服务端向客户端推送) 双向 / 全双工(双方均可主动发送)
连接持久性 基于请求与响应,默认短连接(可 Keep-Alive) 长连接(持久化单向通道) 长连接(持久化双向通道)
数据格式 无限制(文本、二进制、JSON 等) 仅限文本(通常为 JSON 或纯文本) 文本帧或二进制帧
协议类型 HTTP/1.1, HTTP/2, HTTP/3 HTTP 协议 (Content-Type: text/event-stream) 独立协议:ws://wss://
浏览器兼容 完美支持所有浏览器 不支持 IE,现代浏览器支持良好 支持所有现代浏览器

二、 WebSocket 核心原理解析

1. 什么是 WebSocket?

简单来说,WebSocket = Web + Socket。

传统的 Socket 是基于 TCP/IP 的实时通讯双工协议,常用于 QQ、微信、端游等 C/S(客户端/服务器)架构中。而 HTML5 提供的 WebSocket 特性,成功将这种底层的双向通信能力带入了 B/S(浏览器/服务器)架构中。

2. 核心考点:101 状态码与协议升级

很多初学者会有疑问:既然 WebSocket 叫 ws://,那它和 http:// 还有关系吗?

答案是:WebSocket 的第一次握手,依然使用的是 HTTP 协议。

当我们在前端执行 new WebSocket('ws://localhost:3000/ws') 时,浏览器会先发送一个标准的 HTTP 请求,并在请求头中带上特殊标记(Upgrade: websocket)。

服务器收到请求后,如果同意升级,会返回 HTTP 101 Switching Protocols 状态码。在这之后,双方的通信通道正式切换为 WebSocket 协议,不再使用臃肿的 HTTP 请求头。

三、 实战:从零手写基于 Koa 的聊天室

接下来,我们将使用 Node.js 结合 Koa 框架,亲手实现一个极简但五脏俱全的聊天应用。

1. 环境准备

我们需要安装 Koa 以及使 Koa 支持 WebSocket 的中间件:

pnpm i koa koa-websocket

提示:Koa 原生只支持 HTTP 请求,koa-websocket 库的作用是劫持和升级 HTTP 协议,让 Koa 能够处理 WebSocket 通信。

2. 服务端代码解析 (server.js)

以下是完整的服务端代码与深度解析:

// 引入 Koa 框架与 koa-websocket 库
const Koa = require('koa');
const WebSocket = require('koa-websocket');

// 初始化 Koa 实例,并立即用 WebSocket() 将其包裹
const app = WebSocket(new Koa());

// 创建一个 Set 集合,用来保存所有当前连接到服务器的客户端
// 使用 Set 可以天然保证元素的唯一性,防止同一客户端被重复添加
const clients = new Set();

// ==========================================
// 1. HTTP 路由部分:给浏览器下发前端页面
// ==========================================
app.use(async (ctx) => {
    // 第一次与服务器通信使用 HTTP 协议,拿到前端 HTML 页面
    // 采用简单的服务端渲染 (SSR) 做法
    ctx.body = `
    <!DOCTYPE html>
    <html>
    <body>
        <div id="messages" style="height:300px;overflow-y:scroll;"></div>
        <input type="text" id="messageInput"/>
        <button onclick="sendMessage()">发送</button>
        <script>
        // 利用 HTML5 原生的 WebSocket API 发起连接
        // 协议变为 ws://
        const ws = new WebSocket('ws://localhost:3000/ws')

        // 监听来自服务端的推送消息
        ws.onmessage = function(event) {
            const messages = document.getElementById('messages');
            messages.innerHTML += '<div>' + event.data + '</div>';
        }

        // 发送消息函数
        function sendMessage() {
            const input = document.getElementById('messageInput');
            ws.send(input.value); // 通过 WebSocket 通道发给服务端
            input.value = '';
        }
        </script>
    </body>
    </html>
    `;
})

// ==========================================
// 2. WebSocket 路由部分:处理实时双向通信
// ==========================================
app.ws.use(async (ctx, next) => {
    // 客户端连接成功,将当前专属的 websocket 实例存入 Set 集合
    clients.add(ctx.websocket);

    // 监听客户端发来的 'message' 事件
    ctx.websocket.on('message', (message) => {
        // 【核心逻辑:广播 Broadcast】
        for(const client of clients) {
            // 遍历所有连接的人,将消息发给所有人(包括发送者自己)
            client.send(message.toString());
        }
    })

    // 监听断开连接事件(如用户关闭 Tab)
    ctx.websocket.on('close', () => {
        // 必须从集合中移除失效连接,防止广播报错与内存泄漏
        clients.delete(ctx.websocket);
    })
})

// ==========================================
// 3. 启动服务
// ==========================================
app.listen(3000, () => {
    console.log('Server is running on port 3000');
})

通过这段代码,我们清晰地看到了 HTTP 到 WebSocket 的演进:用户首先通过传统的 HTTP 请求获取页面,页面加载后,脚本中的 new WebSocket() 发起协议升级请求,彻底切换到高效的 ws 协议进行实时通信。

四、 进阶:深入理解心跳机制(Heartbeat)与断线重连

做完了基本的聊天功能,我们就算是掌握 WebSocket 了吗?还不够!在真实的生产环境中,网络情况极其复杂。WebSocket 和 SSE 都是长连接,既然是长连接,就必须面对一个致命问题:连接假死

1. 为什么需要心跳机制?

心跳机制是指客户端和服务端定期互相报平安,用来检测连接是否还活着的一种技术。我们需要它的主要原因包括:

  • 网络断开与掉线: 当用户突然断网(如走进电梯)、拔掉网线等,底层的 TCP 可能无法正常发出挥手包。服务端以为连接还在,客户端却已经掉线了。
  • 主动监测: 必须主动监测连接状态,通过发送 ping/pong 来测试链路是否通畅。

💡 延伸思考:TCP 不是自带 Keep-Alive 吗?

很多同学知道 HTTP Connection: keep-alive 底层复用 TCP 通道。TCP 协议确实也有自己的 Keep-Alive 探活机制,但它的默认探测周期极长(通常以小时计),且只能探测网络层面的死活,无法判断应用层进程是否卡死。因此,在业务代码中实现应用层的心跳机制是业界标准做法。

2. 心跳机制的核心实现思路

一个健壮的心跳机制通常包含以下经典的“三步曲”:

  • 第一步:定时发送 Ping

    客户端通过 setInterval 定期(例如每 30 秒)向服务端发送探测包。

    setInterval(() => {
        ws.send(JSON.stringify({type: 'ping'}));
    }, 30000);
    
  • 第二步:接收并响应 Pong

    服务器端收到消息后,解析判断如果 type === 'ping',则立即回传一个类型为 pong 的消息。

    if(msg.type === 'ping') {
        ws.send(JSON.stringify({type: 'pong'}));
    }
    
  • 第三步:超时判断 + 重连机制

    客户端在发送 Ping 之后,会启动一个超时定时器。如果在规定时间内没有收到服务端的 Pong 响应,客户端就可以判定当前连接已断开,进而触发前端的 UI 提示,并执行重连逻辑(Reconnection)。

五、 总结

回顾整篇文章,我们从业务痛点出发,明白了为什么在即时通讯场景下,轮询太慢、SSE 不适用,而 WebSocket 是最终解。我们剖析了 WebSocket 101 状态码 的底层升级原理,并用极简的代码手撸了一个基于 Koa 的全双工广播聊天室。最后,我们补齐了长连接应用走向生产环境的最后一块拼图——心跳机制。

❌