Express 中间件与 React SSR 的 Context 同步:解决服务端渲染过程中的请求级变量污染

好,各位同学,把你们手里的保温杯先放一放,咱们今天不聊“如何优雅地写出三行代码搞定人生”,咱们聊聊那个让人又爱又恨的话题——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();
});

这看起来没问题,对吧?但如果你现在有两个请求同时进来:

  1. 请求 A:进来,设置 currentUser = { id: 1, name: "王五" }
  2. 请求 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 组件就会因为呼吸到错误的空气而生病。

记住这个口诀:

  1. 数据别乱放,全塞 locals 里。
  2. 渲染同步做,别让异步等。
  3. 服务端定乾坤,客户端接盘侠。
  4. 水合要一致,莫让用户看笑话。

这就是技术,枯燥、繁琐,但每当你解决一个变量污染的 bug,看着那个瞬间渲染出来的、不需要闪烁的页面,你就会觉得这一切都是值得的。

好了,今天的讲座就到这里。如果你们在实战中遇到了奇怪的数据错乱,记得回来检查一下是不是把 res.locals 当成了全局变量。祝大家编码愉快,再也没有变量污染!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注