首页
/ Material UI + Express 服务端渲染(SSR)实战指南:基于 material-ui-express-ssr 示例的完整解析

Material UI + Express 服务端渲染(SSR)实战指南:基于 material-ui-express-ssr 示例的完整解析

2026-09-07 11:59:57作者:滕妙奇

导读

本文以本仓库中的 material-ui-express-ssr 示例 为主体,系统讲解如何在 Node.js + Express 环境下为 Material UI 应用实现服务端渲染(SSR):服务端用 ReactDOMServer.renderToString 输出完整 HTML 并同步抽取 Emotion 生成的 CSS,浏览器端用 ReactDOM.hydrateRoot 完成水合。读完本文,你将掌握一套可直接复制的「Express + webpack + Babel + Emotion + Material UI」SSR 工程骨架,并理解 Emotion 缓存(Cache)、插入点(insertion point)与主题注入在 SSR 全流程中的关键作用。

该示例是 MUI 官方 Server Rendering 指南的参考实现,其对应说明文档位于 docs/pages/material-ui/guides/server-rendering.js


一、示例工程速览:8 个文件构成的最小 SSR 骨架

示例目录 examples/material-ui-express-ssr 结构非常精简,职责划分清晰:

文件 职责
server.js Express HTTP 服务、服务端渲染管线、HTML 模板拼接
client.js 浏览器端入口,执行 React 水合(hydration)
createEmotionCache.js 创建 Emotion 缓存,并处理浏览器端插入点
App.js 示例业务组件
ProTip.js 复用的提示组件
theme.js Material UI 主题定义
webpack.config.js 客户端 bundle 打包配置
package.json 依赖与 npm scripts 编排

其中 App 渲染的核心流程可以概括为一句话:同一棵 React 组件树,服务端「渲染成字符串 + 抽出 CSS」,浏览器端「加载 bundle + 水合」,双方共享同一套 ThemeProvider / CacheProvider 包装结构。下面逐一拆解。


二、快速开始:安装与启动

2.1 从本仓库直接运行

由于该示例已作为子目录包含在当前仓库中,最直接的方式是进入示例目录安装并启动:

cd examples/material-ui-express-ssr
npm install
npm run start

2.2 三种启动模式的差异

示例 package.json 中的 scripts 是理解这套开发/生产流程的关键,需要结合原文说明展开解读:

"scripts": {
  "start": "npm-run-all -p build serve",
  "build": "webpack -w",
  "serve": "nodemon --ignore ./build --exec babel-node -- server.js",
  "production": "cross-env NODE_ENV=production npm start",
  "post-update": "echo \"codesandbox preview only, need an update\" && pnpm update --latest"
}
  • npm run start:通过 npm-run-all -p 并行-p = parallel)启动 buildservebuildwebpack -w-w 为 watch 模式,持续监听源码变更重新打包客户端 bundle);serve 使用 nodemon 监听服务端代码并在变更时自动重启,通过 --exec babel-node -- server.js 直接以 Babel 转译运行 server.js,因此 server.js 里可以放心书写 ES Module 的 import 语法,无需单独编译步骤。
  • --ignore ./build:让 nodemon 忽略 webpack 输出的 build/ 目录,避免客户端产物变化触发服务端重复重启。
  • npm run production:通过 cross-env 在跨平台环境(macOS/Linux/Windows)下统一注入 NODE_ENV=production 后再执行 npm start。该环境变量会被 webpack.config.js 读取,用于切换构建模式:
mode: process.env.NODE_ENV || 'development',

即默认以 development 模式打包(利于调试),生产环境下自动切到 production 模式(产物压缩、更优性能)。

说明:原文 README 中同时提供了「一条 curl 命令从上游归档中抽取本示例目录」以及 StackBlitz / CodeSandbox 在线一键编辑的入口。如果希望在自己的项目里独立复刻,最直接的做法是把本仓库中的 examples/material-ui-express-ssr 目录整体拷贝到你自己的工程根目录,然后按上文命令安装启动即可。


三、服务端渲染管线:server.js 源码级拆解

server.js 是整个示例的心脏。去掉注释后,核心逻辑只分四步:创建 Express 应用 → 处理请求 → 渲染组件为字符串 → 抽取 CSS 后拼装完整页面返回

3.1 依赖导入与角色

import express from 'express';
import * as React from 'react';
import * as ReactDOMServer from 'react-dom/server';
import CssBaseline from '@mui/material/CssBaseline';
import { ThemeProvider } from '@mui/material/styles';
import { CacheProvider } from '@emotion/react';
import createEmotionServer from '@emotion/server/create-instance';
import createEmotionCache from './createEmotionCache';
import App from './App';
import theme from './theme';

依赖角色一目了然:React/ReactDOMServer 负责渲染;ThemeProviderCssBaseline 来自 @mui/material,负责主题注入与全局样式基线;CacheProvidercreateEmotionServercreateEmotionCache 来自 Emotion 生态,负责样式收集与抽取。注意 Emotion 是 Material UI 的默认样式引擎(README 原文也明确说明:示例包含 @mui/material 及其 peer 依赖,其中就包括 Emotion;若偏好 styled-components,官方文档也提供了互操作指引)。

3.2 handleRender:每次请求都使用全新的 Emotion 缓存

function handleRender(req, res) {
  const cache = createEmotionCache();
  const { extractCriticalToChunks, constructStyleTagsFromChunks } = createEmotionServer(cache);

  // Render the component to a string.
  const html = ReactDOMServer.renderToString(
    <CacheProvider value={cache}>
      <ThemeProvider theme={theme}>
        <CssBaseline />
        <App />
      </ThemeProvider>
    </CacheProvider>,
  );
  ...
}

这段代码藏着 SSR 一个极其重要的工程细节:每个请求都要新建一个 Emotion cache,绝不能在模块顶层共享缓存实例。原因在于 Emotion 会向缓存中不断累积样式。如果缓存是全局单例,第一个请求写入的样式会残留在缓存里,随着并发请求增多,后续页面的 CSS 会无谓膨胀甚至串味。CacheProvider value={cache} 正是把本次请求专用的缓存下发给组件树中所有依赖 Emotion 的样式。

3.3 抽出关键 CSS:extractCriticalToChunks + constructStyleTagsFromChunks

// Grab the CSS from emotion
const emotionChunks = extractCriticalToChunks(html);
const emotionCss = constructStyleTagsFromChunks(emotionChunks);

// Send the rendered page back to the client.
res.send(renderFullPage(html, emotionCss));

renderToString 只产出 HTML 字符串,样式并不会自动出现在页面里。因此服务端需要用 createEmotionServer(cache) 提供的两件套完成「CSS 收割」:

  1. extractCriticalToChunks(html):解析已经渲染出的 HTML,按 Emotion 内部规则把「关键样式」切分为多个 chunk;
  2. constructStyleTagsFromChunks(chunks):把这些 chunk 组装成可以直接嵌入 <head><style> 标签字符串。

3.4 renderFullPage:页面模板与样式注入位置

function renderFullPage(html, css) {
  return `
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <title>My page</title>
        <meta name="viewport" content="initial-scale=1, width=device-width" />
        <link rel="preconnect" href="https://fonts.googleapis.com" />
        <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
        <link
          rel="stylesheet"
          href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap"
        />
        <meta name="emotion-insertion-point" content="" />
        ${css}
      </head>
      <body>
        <script async src="build/bundle.js"></script>
        <div id="root">${html}</div>
      </body>
    </html>
  `;
}

该模板有三个值得注意的要点:

  • <meta name="emotion-insertion-point" content="" /> 必须出现在 <head> 靠前的位置、并被夹在 Google Fonts 之后、${css} 之前。它被 createEmotionCache.js 中的客户端逻辑当作「插入点」使用,保证 MUI 样式最先插入、位于其他样式之前,从而让开发者能方便地用 CSS Modules 等方案覆盖 MUI 样式(详见第五节)。
  • ${css} 是服务端抽取出的完整 Emotion 样式标签,插在模板中;这就是「首屏无样式闪烁(FOUC)」的解法——用户收到 HTML 时 CSS 已经就位,无需等 JS 加载后再注入样式。
  • <div id="root">${html}</div> 承载服务端渲染出的静态 HTML,配合 async 加载的 build/bundle.js,交给客户端水合接管。

3.5 Express 装配与监听

const app = express();

app.use('/build', express.static('build'));

// This is fired every time the server-side receives a request.
app.use(handleRender);

const port = 3000;
app.listen(port, () => {
  console.log(`Listening on ${port}`);
});
  • express.static('build') 把 webpack 产物目录映射到 /build 路径,供浏览器下载 bundle.js
  • app.use(handleRender) 表示所有请求统一走渲染中间件,返回完整页面;
  • 服务默认监听 3000 端口,启动后控制台会打印 Listening on 3000

四、客户端水合:client.js 与首屏接管

服务端返回的 HTML 只是静态快照,要让页面可交互,浏览器端必须把同一棵组件树「挂」到既有 DOM 上。client.js 完成这一任务:

import * as React from 'react';
import * as ReactDOM from 'react-dom';
import CssBaseline from '@mui/material/CssBaseline';
import { ThemeProvider } from '@mui/material/styles';
import { CacheProvider } from '@emotion/react';
import App from './App';
import theme from './theme';
import createEmotionCache from './createEmotionCache';

const cache = createEmotionCache();

function Main() {
  return (
    <CacheProvider value={cache}>
      <ThemeProvider theme={theme}>
        <CssBaseline />
        <App />
      </ThemeProvider>
    </CacheProvider>
  );
}

ReactDOM.hydrateRoot(document.querySelector('#root'), <Main />);

server.js 对照可以看到 React 18 水合的关键纪律——服务端与客户端渲染结果必须同构

  • 组件包装结构完全一致:CacheProviderThemeProviderCssBaselineApp
  • 传入的 theme、创建的 cache(同为 key: 'mui-style')配置一致;
  • 差异仅在 API 层面:服务端用 ReactDOMServer.renderToString,客户端用 ReactDOM.hydrateRoot(document.querySelector('#root'), ...)

只有保证两端的首屏 HTML 结构完全一致,React 18 的水合(hydrate)才能把事件监听绑定到服务端已有的 DOM 节点上,而不是整体重渲染替换,从而实现「首屏由服务端直出、交互由客户端接管」的最终效果。

App.js 本身只是一个用 ContainerTypographyBoxLink 拼装的演示页面(App.js),并复用了 ProTip.js(内含自定义 LightBulbIconSvgIcon 用法),你可以把它替换为任意真实业务组件树。


五、createEmotionCache:缓存 key 与浏览器插入点

createEmotionCache.js 虽短,却是连接「服务端抽样式」与「客户端水合」的关键枢纽:

import createCache from '@emotion/cache';

const isBrowser = typeof document !== 'undefined';

// On the client side, Create a meta tag at the top of the <head> and set it as insertionPoint.
// This assures that Material UI styles are loaded first.
// It allows developers to easily override Material UI styles with other styling solutions, like CSS modules.
export default function createEmotionCache() {
  let insertionPoint;

  if (isBrowser) {
    const emotionInsertionPoint = document.querySelector('meta[name="emotion-insertion-point"]');
    insertionPoint = emotionInsertionPoint ?? undefined;
  }

  return createCache({ key: 'mui-style', insertionPoint });
}

逐行解读其设计意图:

  1. const isBrowser = typeof document !== 'undefined':同一份代码会在 Node 环境(服务端渲染时)和浏览器环境(水合时)分别执行。Node 环境下不存在 document,因此跳过插入点查询。
  2. 浏览器端查找插入点:客户端渲染时会先在 DOM 中查找 <meta name="emotion-insertion-point"> 这个标签——它正是服务端 renderFullPage<head> 里预留的空标签。Emotion 会把运行时生成的 <style> 标签插到该 meta 之后,从而确保 Material UI 的样式位于页面最前、被最先解析加载
  3. insertionPoint ?? undefined:使用空值合并运算符,查不到 meta 标签时优雅降级为 undefined,即让 Emotion 采用默认插入策略,不会因此崩溃。
  4. createCache({ key: 'mui-style', insertionPoint }):缓存 key 取 'mui-style',生成的样式类名与 <style> 标签的 data-emotion 标识都以该前缀开头。服务端与客户端必须使用相同的 key,这是两端样式能够对齐、避免水合时样式错乱的隐含前提。

这个「插入点」机制带来一个非常实际的收益:由于 MUI 样式永远最先插入,后续插入的 CSS Modules、Tailwind 或其他样式方案天然具备覆盖 MUI 默认样式的优先级,而无需纠缠于 CSS 优先级计算。


六、主题定义与构建工具链

6.1 theme.js:启用 cssVariables 的主题

theme.js 展示了当前 Material UI(示例包版本为 7.0.0)的主题写法:

import { createTheme } from '@mui/material/styles';
import { red } from '@mui/material/colors';

// Create a theme instance.
const theme = createTheme({
  cssVariables: true,
  palette: {
    primary: {
      main: '#556cd6',
    },
    secondary: {
      main: '#19857b',
    },
    error: {
      main: red.A400,
    },
  },
});

export default theme;
  • createTheme 来自 @mui/material/styles,是 Material UI 标准的主题工厂函数;
  • cssVariables: true 让主题令牌以 CSS 变量形式输出,便于运行时换肤与调试;
  • palette 定义了 primary / secondary 主色,并从 @mui/material/colors 引入 red.A400 作为错误色;
  • theme 对象被 server.jsclient.js 共同引用,是「两端同构」的一部分。

6.2 webpack.config.js:客户端 bundle 的打包入口

/* eslint-disable mui/consistent-production-guard */
const path = require('path');

module.exports = {
  entry: './client.js',
  mode: process.env.NODE_ENV || 'development',
  output: {
    path: path.resolve(__dirname, 'build'),
    filename: 'bundle.js',
    publicPath: '/',
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        loader: 'babel-loader',
      },
    ],
  },
};

配置要点:

  • entry: './client.js':打包入口就是上面第四节的水合脚本,产出物是浏览器端唯一需要的 JS 文件;
  • 输出到 build/bundle.js,与 server.jsexpress.static('build') 以及模板里 <script async src="build/bundle.js"> 的路径严格对应;
  • babel-loader 负责把 JSX 与 ES 语法转译为浏览器可执行代码,排除 node_modules
  • mode 跟随 NODE_ENV,与 npm scripts 中的 production 命令联动。

6.3 依赖全景

package.json 中全部使用 "latest" 版本标签,并声明了 browserslist: [">0.25%", "not dead"](影响 Babel 转译与 autoprefixer 等工具的浏览器兼容范围)。依赖可分为四组,便于理解各自存在的理由:

分组 作用
运行框架 expressreactreact-dom HTTP 服务与 UI 渲染
Material UI @mui/material 组件库,含 ThemeProviderCssBaseline
样式引擎 @emotion/cache@emotion/react@emotion/styled@emotion/server 默认样式引擎及服务端 CSS 抽取
工程工具 @babel/core@babel/node@babel/preset-env@babel/preset-reactbabel-loaderwebpackwebpack-clinodemonnpm-run-allcross-env 转译、打包、进程编排、环境变量

其中 @emotion/server 是 SSR 专用的「样式收割」工具包(提供 createEmotionServer),浏览器端产物中并不需要它。


七、同一套模式在仓库其他框架示例中的印证

extractCriticalToChunks / createEmotionCache / emotion-insertion-point」这一组合并非 Express 独有。在本仓库其他框架的 SSR/静态渲染示例中能看到同一设计模式,可互为参照:

这说明:无论采用哪种服务端框架,Material UI + Emotion 的 SSR 解决方案都收敛为固定三件事——为每次渲染新建缓存 → renderToString 后抽取关键样式注入 HTML → 客户端以相同配置水合。理解 Express 这一版,就等于掌握了其他框架版本的公共内核。


八、实践要点清单

结合以上源码分析,落地这套 SSR 方案时需要守住以下纪律:

  1. 每次请求新建 Emotion cachehandleRender 内调用 createEmotionCache(),不要把缓存提升到模块顶层,避免样式跨请求累积(依据:server.js)。
  2. 两端同构:服务端 renderToString 与客户端 hydrateRoot 使用相同的组件树、相同的 theme、相同的 cache key: 'mui-style',否则水合期会出现内容不一致或样式错位。
  3. 预留 emotion-insertion-point:在 HTML 模板 <head> 中放置 <meta name="emotion-insertion-point" content="" />,它让 MUI 样式以「最先插入」的方式获得被其他样式方案覆盖的可能。
  4. 先样式后 JS:把 extractCriticalToChunks + constructStyleTagsFromChunks 产出的 <style> 标签直接嵌入 renderFullPage 返回的 HTML,从根本上避免 FOUC。
  5. 善用 npm scripts 编排npm-run-all -p build serve 并行跑「webpack 监听打包」与「nodemon + babel-node 热重载服务端」,开发体验最顺;生产环境使用 cross-env NODE_ENV=production npm start

在本地按第二节的命令启动后,访问 http://localhost:3000,右键查看网页源代码,你应当能看到 <div id="root"> 内已经是完整的 Material UI 组件 HTML、<head> 中已经内联了完整样式——这正是本节全部原理的直观验证。

登录后查看全文
热门项目推荐
相关项目推荐