Material UI + Express 服务端渲染(SSR)实战指南:基于 material-ui-express-ssr 示例的完整解析
导读
本文以本仓库中的 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)启动build与serve。build即webpack -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 负责渲染;ThemeProvider 与 CssBaseline 来自 @mui/material,负责主题注入与全局样式基线;CacheProvider、createEmotionServer、createEmotionCache 来自 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 收割」:
extractCriticalToChunks(html):解析已经渲染出的 HTML,按 Emotion 内部规则把「关键样式」切分为多个 chunk;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 水合的关键纪律——服务端与客户端渲染结果必须同构:
- 组件包装结构完全一致:
CacheProvider→ThemeProvider→CssBaseline→App; - 传入的
theme、创建的cache(同为key: 'mui-style')配置一致; - 差异仅在 API 层面:服务端用
ReactDOMServer.renderToString,客户端用ReactDOM.hydrateRoot(document.querySelector('#root'), ...)。
只有保证两端的首屏 HTML 结构完全一致,React 18 的水合(hydrate)才能把事件监听绑定到服务端已有的 DOM 节点上,而不是整体重渲染替换,从而实现「首屏由服务端直出、交互由客户端接管」的最终效果。
App.js 本身只是一个用 Container、Typography、Box、Link 拼装的演示页面(App.js),并复用了 ProTip.js(内含自定义 LightBulbIcon 的 SvgIcon 用法),你可以把它替换为任意真实业务组件树。
五、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 });
}
逐行解读其设计意图:
const isBrowser = typeof document !== 'undefined':同一份代码会在 Node 环境(服务端渲染时)和浏览器环境(水合时)分别执行。Node 环境下不存在document,因此跳过插入点查询。- 浏览器端查找插入点:客户端渲染时会先在 DOM 中查找
<meta name="emotion-insertion-point">这个标签——它正是服务端 renderFullPage 在<head>里预留的空标签。Emotion 会把运行时生成的<style>标签插到该 meta 之后,从而确保 Material UI 的样式位于页面最前、被最先解析加载。 insertionPoint ?? undefined:使用空值合并运算符,查不到 meta 标签时优雅降级为undefined,即让 Emotion 采用默认插入策略,不会因此崩溃。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.js 与 client.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.js 中express.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 等工具的浏览器兼容范围)。依赖可分为四组,便于理解各自存在的理由:
| 分组 | 包 | 作用 |
|---|---|---|
| 运行框架 | express、react、react-dom |
HTTP 服务与 UI 渲染 |
| Material UI | @mui/material |
组件库,含 ThemeProvider、CssBaseline |
| 样式引擎 | @emotion/cache、@emotion/react、@emotion/styled、@emotion/server |
默认样式引擎及服务端 CSS 抽取 |
| 工程工具 | @babel/core、@babel/node、@babel/preset-env、@babel/preset-react、babel-loader、webpack、webpack-cli、nodemon、npm-run-all、cross-env |
转译、打包、进程编排、环境变量 |
其中 @emotion/server 是 SSR 专用的「样式收割」工具包(提供 createEmotionServer),浏览器端产物中并不需要它。
七、同一套模式在仓库其他框架示例中的印证
「extractCriticalToChunks / createEmotionCache / emotion-insertion-point」这一组合并非 Express 独有。在本仓库其他框架的 SSR/静态渲染示例中能看到同一设计模式,可互为参照:
- Remix:entry.server.tsx 与 root.tsx 使用相同的缓存抽取管线;
- Next.js(Pages Router):pages/_document.js 与 TS 版 pages/_document.tsx 同样在
_document阶段处理 Emotion 关键样式; - React Router:createCache.ts、entry.server.tsx 与 root.tsx 也是同一思路。
这说明:无论采用哪种服务端框架,Material UI + Emotion 的 SSR 解决方案都收敛为固定三件事——为每次渲染新建缓存 → renderToString 后抽取关键样式注入 HTML → 客户端以相同配置水合。理解 Express 这一版,就等于掌握了其他框架版本的公共内核。
八、实践要点清单
结合以上源码分析,落地这套 SSR 方案时需要守住以下纪律:
- 每次请求新建 Emotion cache:
handleRender内调用createEmotionCache(),不要把缓存提升到模块顶层,避免样式跨请求累积(依据:server.js)。 - 两端同构:服务端
renderToString与客户端hydrateRoot使用相同的组件树、相同的theme、相同的 cachekey: 'mui-style',否则水合期会出现内容不一致或样式错位。 - 预留 emotion-insertion-point:在 HTML 模板
<head>中放置<meta name="emotion-insertion-point" content="" />,它让 MUI 样式以「最先插入」的方式获得被其他样式方案覆盖的可能。 - 先样式后 JS:把
extractCriticalToChunks+constructStyleTagsFromChunks产出的<style>标签直接嵌入renderFullPage返回的 HTML,从根本上避免 FOUC。 - 善用 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> 中已经内联了完整样式——这正是本节全部原理的直观验证。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00