好,各位同学,把你们手里的保温杯先放一放,咱们今天不聊“如何优雅地写出三行代码搞定人生”,咱们聊聊那个让人又爱又恨的话题——Express 中间件与 React SSR 的 Context 同步。
这可是个技术圈的“罗密欧与朱丽叶”,是一段注定要经历九九八十一难的姻缘。
想象一下,你的服务器是一台轰鸣的机器,Express 是这台机器的操作系统,中间件就是那些乱七八糟的插件。而 React SSR 呢,它是机器里那个看起来精致但极其挑剔的艺术家。艺术家要画画,得先知道他在哪、他是谁、他穿什么颜色的衣服。Express 需要把这些信息喂给 React,否则 React 就只能对着空气画图。
但问题来了,如果你喂错了,或者喂了上一个人的信息,那这画作——也就是你惨不忍睹的页面——就废了。这叫什么?这就叫请求级变量污染。
今天,我就带大家拆解一下这个令人头疼的过程,顺便教大家怎么让 Express 和 React 乖乖地坐下来,心平气和地交换数据。
场景一:这就是所谓的“变量污染”灾难现场
首先,咱们得承认,很多新手(也就是咱们经常调侃的“变量搬运工”)在写 SSR 的时候,喜欢干一件极其危险的事情——滥用全局变量。
在 Node.js 的单线程世界里,全局变量确实方便,但方便个鬼啊!就像你把洗面奶、牙膏和洗发水全塞进同一个杯子里喝,最后的结果只有一种:你的味蕾将面临生不如死的体验。
假设你有一个中间件,专门负责获取用户信息:
// app.js
let currentUser = null;
function getUser(req) {
// 模拟数据库查询
return { id: req.userId, name: "张三" };
}
// 第一个中间件
app.use((req, res, next) => {
console.log("中间件 1 启动");
currentUser = getUser(req); // 危险!这里把数据塞进了全局变量
console.log(`当前获取用户: ${currentUser.name}`);
next();
});
// 第二个中间件
app.use((req, res, next) => {
console.log("中间件 2 启动");
console.log(`当前中间件看到的是谁? ${currentUser.name}`);
next();
});
这看起来没问题,对吧?但如果你现在有两个请求同时进来:
- 请求 A:进来,设置
currentUser = { id: 1, name: "王五" }。 - 请求 B:进来,设置
currentUser = { id: 2, name: "赵六" }。
好,这时候请求 A 还在处理,请求 B 偷偷把全局变量覆盖了。当请求 A 继续执行 console.log 时,你猜猜它会打印谁?没错,是赵六。这就是变量污染!这就像是你在餐厅端菜,端着满汉全席去了张三的桌,张三还没开吃呢,服务员已经把菜撤了,换成了路边摊。
而在 SSR 中,这个问题会放大十倍。React 需要依赖 Context 来决定渲染什么。如果 Context 里的数据是“赵六”,而页面却是“张三的个人主页”,那用户体验简直是在侮辱智商。
场景二:Express 的“洗脚水”理论
Express 处理数据的方式很有趣。它有一个很隐蔽的机制叫 res.locals。
你可以把 res 对象想象成服务端给你的一个一次性饭盒。中间件们可以往这个饭盒里加菜,最终这个饭盒会被打包送给 React。
而 req 对象呢?它是那个去取饭盒的小弟。小弟不能留在桌上,客人(React)不需要知道小弟是谁,客人只需要知道饭盒里有什么。
所以,我们的核心策略非常明确:绝对不要在全局作用域存数据,绝对不要在闭包里玩消失,我们要把数据乖乖地塞进 res.locals。
让我们看看怎么用 res.locals 重写上面的灾难现场:
// app.js
// 第一个中间件:负责获取用户
app.use(async (req, res, next) => {
try {
// 假设这里有个数据库查询
const user = await db.queryUser(req.userId);
// 关键步骤:注入到 locals
res.locals.user = user;
res.locals.timestamp = new Date().toISOString();
next();
} catch (error) {
next(error);
}
});
// 第二个中间件:负责处理权限
app.use((req, res, next) => {
// 这里可以安全地读取 res.locals
if (!res.locals.user) {
return res.status(401).send("未授权");
}
next();
});
你看,现在的逻辑清晰多了。每个请求都有自己的 res.locals,互不干扰。这就像是每个人手里都有自己的饭盒,张三吃张三的,李四吃李四的,谁也污染不了谁。
场景三:React Context 的“原生语言”
接下来,咱们得聊聊 React。React 上下文 API 是用来干嘛的?它就像是一个私密的俱乐部,允许组件树里的成员共享数据,而不用一层层地通过 Props 层层传递。
如果你不熟悉 React Context,你可以把它想象成皇宫里的传令官。React 组件树就是皇宫里的各位大臣,他们虽然层级不同,但如果想找皇上(也就是共享状态)拿个东西,不需要每次都翻墙递纸条,直接告诉守门的传令官就行。
在 SSR 场景下,Context 的作用更加关键。因为我们在服务端渲染 HTML 时,必须提前知道内容是什么。如果我们在服务端不知道 Context 里的数据,React 渲染出来的就是一堆空壳子。
场景四:如何让 Express 和 React “通婚”
好了,现在我们有了一个装满用户数据的 res.locals,我们有一个 React Context Provider,我们需要把这两者连接起来。
这一步是整个技术点的精髓。我们要写一个自定义的组件,或者一个包装器,把这个 Context Provider 挂载到整个应用的最外层,并把 res.locals 的数据注入进去。
这里有个大坑,很多人容易在这里栽跟头。我们得区分清楚,Context 是在服务端渲染时注入的,还是在客户端渲染时注入的?
答案其实是一致的,但实现方式略有不同。
1. 核心代码示例:搭建桥梁
假设我们有一个简单的 SSR 应用结构。
Express 端 (server.js):
const express = require('express');
const React = require('react');
const { renderToString } = require('react-dom/server');
const { StaticRouter } = require('react-router-dom/server');
const App = require('./components/App').default;
const UserContext = require('./contexts/UserContext').default;
const app = express();
app.get('*', async (req, res) => {
try {
// 1. 模拟异步获取数据
// 在真实场景中,这里可能是一个复杂的中间件链
const userData = await fetchUserData(req.path);
const pageData = await fetchPageData(req.path);
// 2. 将数据注入 res.locals
// 这一步是同步的,必须确保在渲染前完成
res.locals = {
...res.locals, // 保留之前的 locals
user: userData,
pageData: pageData,
url: req.url,
};
// 3. 开始渲染 React
const content = renderToString(
<UserContext.Provider value={res.locals.user}>
<StaticRouter location={req.url} context={{}}>
<App />
</StaticRouter>
</UserContext.Provider>
);
// 4. 发送 HTML
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>SSR Demo</title>
</head>
<body>
<div id="root">${content}</div>
<script>
window.__INITIAL_STATE__ = ${JSON.stringify(res.locals)};
</script>
<script src="/client.js"></script>
</body>
</html>
`);
} catch (error) {
res.status(500).send("服务器内部错误");
}
});
app.listen(3000);
注意看上面代码中的第 3 步。我们在 renderToString 的时候,直接把 res.locals.user 传给了 UserContext.Provider。
这就好比我们在服务端盖房子的时候,就把装修图纸(UserContext)发给施工队了,这样施工队盖出来的房子才是符合要求的。
2. React 端:如何消费 Context
接下来,我们在客户端怎么接手这个活儿呢?
components/App.js:
import React from 'react';
import UserContext from '../contexts/UserContext';
const App = () => {
return (
<div>
<h1>我是 App 组件</h1>
<UserInfo /> {/* 嵌套组件 */}
</div>
);
};
// 这个组件专门用来展示用户信息
const UserInfo = () => {
// 使用 useContext 钩子
const user = React.useContext(UserContext);
if (!user) {
return <div>加载中...</div>;
}
return (
<div>
<p>用户 ID: {user.id}</p>
<p>用户昵称: {user.name}</p>
<p>当前时间: {new Date().toLocaleString()}</p>
</div>
);
};
export default App;
看起来很简单,对吧?但如果仅仅是这样做,还有一个问题:客户端的水合(Hydration)。
当浏览器加载页面时,它会读取 HTML。如果 HTML 里的用户信息和 window.__INITIAL_STATE__ 里的数据不一致,React 就会报警,甚至重新渲染(导致闪烁)。
场景五:客户端的状态接管与避免闪烁
为了让体验完美,我们必须确保服务端渲染的 HTML 和客户端的初始状态是一模一样的。
在上一段代码里,我已经把 res.locals 转成了 JSON 字符串塞进了 window.__INITIAL_STATE__。
现在,在客户端的入口文件中,我们需要把这个状态重新注入到 Context 中。但这又有一个问题:我们不能直接把 res.locals.user 赋值给 Context,因为服务端渲染时,Context 是在 <App> 组件外面的一层 Provider 里的。
我们需要把 App 包裹在一个 Provider 里,这个 Provider 的 value 来自 window。
client.js (客户端入口):
import React from 'react';
import { hydrateRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './components/App';
import UserContext from './contexts/UserContext';
// 1. 从全局变量中获取初始状态
const initialState = window.__INITIAL_STATE__;
// 2. 创建 Provider 的包装器
const Root = () => {
return (
<UserContext.Provider value={initialState.user}>
<BrowserRouter>
<App />
</BrowserRouter>
</UserContext.Provider>
);
};
// 3. 执行水合
hydrateRoot(
document.getElementById('root'),
<Root />
);
// 4. 清理全局变量(可选,为了安全)
delete window.__INITIAL_STATE__;
这就完成了闭环!服务端渲染时,数据来自 res.locals;客户端水合时,数据来自 window。
场景六:那些让人头秃的进阶问题
好了,前面的代码虽然能跑,但那是“温室里的花朵”。在真正的生产环境里,你会发现事情远比这复杂。比如并发请求、数据缓存、复杂的中间件嵌套,还有 React 19 的更新。
1. 并发与上下文传递的陷阱
Express 默认是单线程的事件循环。但在 Node.js v17+ 中,支持 --experimental-multi-threaded,这意味着你可能会使用多线程来处理高并发。
如果你的中间件代码写得不规范,比如使用了闭包捕获了外部变量,那么在多线程环境下,你的数据会像野火一样乱窜。但在最常见的服务器架构中,我们通常还是单线程或单进程。所以,保证所有中间件都使用 res.locals 而不是全局变量,依然是预防污染的第一道防线。
2. 动态路由与 res.locals 的生命周期
在 SSR 中,路由往往是由服务器端解析的,而不是客户端。
如果你的路由是这样定义的:app.get('/user/:id', ...)。
当请求进来时,Express 解析出 :id。你的中间件需要基于这个 id 查数据库。
- 错误示范: 把查询结果存在
res.locals之外的地方。app.get('/user/:id', async (req, res, next) => { const user = await db.getUser(req.params.id); // 这里的 user 是局部变量,渲染完这个请求,它就没了。 // 但如果你在 next() 之后的某个地方试图读取它... 哎呀,没东西了。 next(); }); - 正确示范: 必须塞进
res.locals。app.get('/user/:id', async (req, res, next) => { const user = await db.getUser(req.params.id); res.locals.user = user; // 好孩子,这样做 next(); });
3. 异步中间件与渲染的时机
这是一个非常微妙的时间差问题。
app.use(async (req, res, next) => {
const data = await fetchSomething(); // 异步操作
res.locals.data = data;
next(); // 这里 next() 是同步调用的吗?不,如果是 async,它是一个 Promise。
});
在 Express 中,如果中间件是 async 的,next() 会返回一个 Promise。如果这个 Promise 没有被正确处理,或者中间件抛出了错误没被 catch,整个渲染流程就会卡住。
在 SSR 中,你必须确保在 renderToString 执行之前,所有的 await 都已经完成。Express 的 next() 只是传递控制权,它不负责等待中间件的异步操作完成。
所以,最安全的写法是:
app.get('/dashboard', async (req, res, next) => {
try {
// 获取用户
const user = await db.getUser(req.session.userId);
if (!user) throw new Error("用户不存在");
// 获取权限
const permissions = await getPermissions(user.role);
// 存入 locals
res.locals = { user, permissions };
// 继续执行后续中间件和路由
next();
} catch (err) {
// 错误处理在这里,不要直接 next(err) 然后让下一层处理,因为数据还没准备好
console.error(err);
res.status(500).send("内部错误");
}
});
场景七:高级模式——Context 拦截器
随着你的应用变大,你可能会发现 Context 里的数据越来越多:用户信息、主题配置、多语言设置、甚至是一些临时的 UI 状态。如果每次渲染都要把所有数据都传给 Context Provider,性能会受损,代码也会变得臃肿。
这时候,我们就需要一个上下文拦截器。
思路是:Context Provider 持有一个函数或者一个简单的 Store,中间件只负责更新这个 Store,渲染组件时才去读取。
但为了保持代码的“无状态”和“易预测”,SSR 领域还是推荐直接传递不可变的 res.locals 数据给 Context。除非你的数据量极大(比如一个包含 10MB 图片数据的用户画像),否则不要为了性能牺牲代码的可读性。
场景八:实战演练——一个完整的 SSR 数据流
让我们来个实战,不玩虚的。假设我们要做一个博客详情页。
1. 路由定义:
const router = express.Router();
router.get('/post/:slug', async (req, res, next) => {
try {
// 1. 查询文章
const post = await PostModel.findOne({ slug: req.params.slug });
if (!post) return res.status(404).end();
// 2. 查询作者
const author = await UserModel.findById(post.authorId);
// 3. 收集数据
const viewData = {
post,
author,
title: post.title,
currentUser: req.session.user // 也可以是空的
};
// 4. 关键:塞进 locals
res.locals.viewData = viewData;
next();
} catch (err) {
next(err);
}
});
2. 渲染逻辑:
app.use(router); // 假设 router 已经挂载到主 app 上
app.get('*', async (req, res, next) => {
// 检查 locals 里是否有数据
if (res.locals.viewData) {
const { viewData } = res.locals;
const html = renderToString(
<App>
<ThemeProvider theme={viewData.theme}>
<PostProvider data={viewData}> {/* 自定义 Provider */}
<BlogPost />
</PostProvider>
</ThemeProvider>
</App>
);
// 注入数据到 HTML
res.send(`
<html>
<body>
<div id="root">${html}</div>
<script>
window.__SSR_DATA__ = ${JSON.stringify(viewData)};
</script>
<script src="/bundle.js"></script>
</body>
</html>
`);
} else {
// 没有数据,说明路由没匹配上或者中间件挂了,交给 404 处理
next();
}
});
3. 客户端水合:
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import { ThemeProvider } from './context/ThemeContext';
import { PostProvider } from './context/PostContext';
const container = document.getElementById('root');
const root = createRoot(container);
// 初始状态
const initialData = window.__SSR_DATA__;
root.render(
<BrowserRouter>
<ThemeProvider theme={initialData.theme}>
<PostProvider initialData={initialData}>
<App />
</PostProvider>
</ThemeProvider>
</BrowserRouter>
);
场景九:关于“水合不匹配”的终极心法
最后,我想聊聊大家在实战中最容易遇到的坑——水合不匹配。
有时候,你明明在服务端注入了 Context,在客户端也注入了,但 React 还是报错,说服务端 HTML 和客户端状态不一致。
这通常是因为时序问题。
比如,你在服务端渲染时,Context 里的数据是 null(因为用户还没登录,或者数据还没查到),但在客户端水合时,window.__SSR_DATA__ 里的数据也是 null。这看起来没问题对吧?
但如果你的代码里写了一行:
// Client side
const user = window.__SSR_DATA__?.user || getCurrentUserFromCookie();
服务端渲染的是 null,但客户端 JS 执行到这里时,getCurrentUserFromCookie() 可能返回了 UserA。于是,React 发现服务端画的是“未登录”的样子,客户端变成“已登录”的样子,立马炸锅。
解决方案:
客户端读取 Context 时,必须严格遵守服务端的返回值。如果服务端是 null,客户端第一次渲染也必须是 null(或者加载状态),直到你通过 useEffect 再次异步获取数据。
结语:保持清洁的艺术
回到我们的主题。Express 中间件和 React SSR 的 Context 同步,本质上是一场数据卫生运动。
- Express 是负责清理垃圾的环卫工。
- res.locals 是那个干净、透明的垃圾桶。
- Context 是那个收集垃圾(数据)的仓库。
如果你在 Express 里乱扔垃圾(全局变量、闭包陷阱),Context 仓库就会充满臭味,React 组件就会因为呼吸到错误的空气而生病。
记住这个口诀:
- 数据别乱放,全塞 locals 里。
- 渲染同步做,别让异步等。
- 服务端定乾坤,客户端接盘侠。
- 水合要一致,莫让用户看笑话。
这就是技术,枯燥、繁琐,但每当你解决一个变量污染的 bug,看着那个瞬间渲染出来的、不需要闪烁的页面,你就会觉得这一切都是值得的。
好了,今天的讲座就到这里。如果你们在实战中遇到了奇怪的数据错乱,记得回来检查一下是不是把 res.locals 当成了全局变量。祝大家编码愉快,再也没有变量污染!