在 React Router 与 TypeScript 工程中集成 Material UI:examples/material-ui-react-router-ts 示例全解析
本文以 Material UI 官方仓库中的 examples/material-ui-react-router-ts 示例为骨架,系统讲解如何在 Vite + React Router(SSR 模式)+ TypeScript 的现代工程中集成 Material UI,并正确处理 Emotion 样式引擎在服务端渲染、客户端水合与暗色/亮色主题下的配置。读完本文,你将掌握一套可复制的脚手架工程结构、关键配置文件的底层原理,以及遇到 MUI 与 React Router 组合开发时的常见取舍。
示例定位与工程概览
原 README 明确说明了该示例的用途:演示如何在 TypeScript 工程中把 Material UI 与 React Router 一起使用,并打包了 @mui/material 及其 peer 依赖,包括 Emotion——Material UI 的默认样式引擎。
与仓库中其他示例(如 examples/material-ui-nextjs、examples/material-ui-vite-ts)相比,这个示例的独特之处在于它基于 React Router 框架模式(framework mode),即由 Vite 插件驱动、自带路由配置与 SSR/水合能力的新一代架构,而不是把 React Router 当普通库手写 <BrowserRouter>。其文件布局如下:
examples/material-ui-react-router-ts/
├── app/ # 应用源码
│ ├── components/ # 可复用页面组件
│ │ ├── Copyright.tsx
│ │ └── ProTip.tsx
│ ├── routes/ # 路由级页面组件
│ │ ├── about.tsx
│ │ └── home.tsx
│ ├── createCache.ts # Emotion 缓存工厂(带 @layer mui 包装)
│ ├── entry.client.tsx # 浏览器端入口(水合)
│ ├── entry.server.tsx # 服务端渲染入口(含 Emotion SSR 提取)
│ ├── root.tsx # 根布局、全局 Provider 与错误边界
│ ├── routes.ts # 路由表声明
│ └── theme.tsx # Material UI 主题(亮/暗色)
├── public/favicon.ico
├── package.json
├── react-router.config.ts # React Router 框架配置
├── tsconfig.json
└── vite.config.ts # Vite 构建配置(reactRouter 插件)
快速上手:安装与运行
原 README 提供的核心流程是下载/拷贝示例后安装依赖并启动开发服务器。对应命令如下:
npm install
npm run dev
在原 README 中还提供了两种免手工拷贝的方式:一种是直接从上游仓库拉取该示例目录的压缩包解压,另一种是借助在线沙箱(CodeSandbox / StackBlitz)直接体验,二者对本地 Node 环境无要求。若在本地克隆仓库,则进入 examples/material-ui-react-router-ts 目录执行上述命令即可。
值得补充的是,该示例的 package.json 共声明了四个脚本,分别覆盖开发、构建、产物运行与类型检查:
| 脚本 | 命令 | 作用 |
|---|---|---|
dev |
react-router dev |
启动开发服务器(含 HMR) |
build |
react-router build |
构建生产产物 |
start |
react-router-serve ./build/server/index.js |
以 Node 服务方式运行构建产物(SSR) |
typecheck |
react-router typegen && tsc |
先为路由生成类型,再执行 TypeScript 严格检查 |
其中 typecheck 里的 react-router typegen 会生成 .react-router/types/**/* 类型文件,这也是 tsconfig.json 的 include 中包含 .react-router/types/**/*、并在 rootDirs 里把 ./.react-router/types 与工程根目录并列的原因——让路由 +types 的导入能在编译期通过。
依赖组成
从 package.json 可以看出依赖分三组:
- UI 层:
@mui/material,以及 Emotion 三件套@emotion/react、@emotion/styled、@emotion/cache(客户端缓存)与@emotion/server(服务端样式提取); - 框架层:
react-router、@react-router/node(Node 适配,提供createReadableStreamFromReadable等)、@react-router/serve(生产环境静态服务)、isbot(爬虫 UA 识别,供 SSR 全量渲染判断用); - 工程层:
vite、@react-router/dev、vite-tsconfig-paths、typescript及 React 类型声明。
所有依赖均取 latest,未锁定具体版本,实际行为以安装时的版本为准;示例同时设置了 "type": "module",整个工程按 ESM 方式运行。
构建配置:Vite、SSR 与路径别名
vite.config.ts
vite.config.ts 是整个工程最值得注意的配置文件:
import { reactRouter } from '@react-router/dev/vite';
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [reactRouter(), tsconfigPaths()],
ssr: {
// Workaround for resolving dependencies in the server bundle
// Without this, the React context will be different between direct import and transitive imports in development environment
optimizeDeps: {
include: ['@emotion/*', '@mui/*'],
},
noExternal: ['@emotion/*', '@mui/*'],
},
});
关键点有三:
reactRouter()插件接管构建,应用本身不再手写 Vite 入口,入口/路由由框架模式自动装配;tsconfigPaths()让 TypeScript 的~/*别名在 Vite 侧生效(与 tsconfig.json 中"~/*": ["./app/*"]对应),源码里的import ProTip from '~/components/ProTip'因此可正常解析;ssr.optimizeDeps.include与ssr.noExternal会把@emotion/*、@mui/*统一打进服务端 bundle。配置注释说明了动机:如果不这样做,开发环境下直接导入与传递导入可能产生两份 React 上下文,导致@emotion/react的 Provider 判断失效(这也是 MUI + SSR 常见的一类坑)。
react-router.config.ts 与 tsconfig.json
react-router.config.ts 极为精简:
import type { Config } from '@react-router/dev/config';
export default {
// Server-side render by default, to enable SPA mode set this to `false`
ssr: true,
} satisfies Config;
ssr: true 表示默认启用服务端渲染。若想退化为纯 SPA 模式,把这里改为 false 即可;这一开关同时会影响 entry.server.tsx 中渲染就绪策略的选择。
tsconfig.json 采用 strict 全开、moduleResolution: "bundler"、jsx: "react-jsx"、verbatimModuleSyntax 等现代配置,并开启 resolveJsonModule 与 skipLibCheck,types 仅包含 node 与 vite/client,避免引入无关全局类型。
应用外壳:根布局、全局 Provider 与错误边界
app/root.tsx 承担了三个职责:
1. 全局 <html> 布局与字体预连接。 links 导出函数声明了 Google Fonts 的 preconnect 以及 Roboto 字体表(Material UI 的推荐默认字体),并在 Layout 组件中放入 <Meta />、<Links />、<ScrollRestoration />、<Scripts /> 等框架必需标签,同时在 <body> 上设置 suppressHydrationWarning 以兼容水合差异。
2. 注入 Emotion 缓存与主题。 根组件 App 中根据是否处于浏览器环境做差异化渲染(见 app/root.tsx):
const cache = createEmotionCache();
export default function App() {
if (typeof window !== 'undefined') {
return (
<CacheProvider value={cache}>
<AppTheme>
<Outlet />
</AppTheme>
</CacheProvider>
);
}
return (
<AppTheme>
<Outlet />
</AppTheme>
);
}
注意:只有客户端才包 CacheProvider。这是因为服务端渲染时样式提取走的是 entry.server.tsx 里各自新建的缓存实例,而客户端水合需要一个稳定的全局单例缓存,否则可能出现重复注入样式。
3. 根级错误边界。 ErrorBoundary 使用 isRouteErrorResponse(error) 区分框架路由错误(404/5xx)与普通异常:404 展示专属文案;开发模式下(import.meta.env.DEV)才会泄露错误堆栈。边界内直接使用 @mui/material/Box 布局,体现 MUI 组件在整个应用(含错误页)中的覆盖。
主题与暗色模式:theme.tsx + CssBaseline
app/theme.tsx 展示了 Material UI 当前推荐的 createTheme 用法:
import * as React from 'react';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
const theme = createTheme({
cssVariables: true,
colorSchemes: {
light: true,
dark: true,
},
});
export default function AppTheme({ children }: AppThemeProps) {
return (
<ThemeProvider theme={theme}>
<CssBaseline />
{children}
</ThemeProvider>
);
}
两个配置项的含义:
cssVariables: true:让主题令牌以 CSS 变量形式输出,便于运行时切换主题、按颜色方案缓存样式,是 MUI 新版主题能力的开关;colorSchemes: { light: true, dark: true }:同时启用亮/暗两种配色方案,CssBaseline会按系统偏好与方案声明自动处理背景与前景色归一化(@media (prefers-color-scheme)逻辑见@mui/material内CssBaseline实现),因此示例页面在亮色与暗色之间可无缝切换。
AppTheme 这个包裹组件对路由 Outlet 之外的所有 MUI 组件生效,是“一处定义、全局复用”主题的标准姿势。
关键拼图:带 @layer mui 的 Emotion 缓存
app/createCache.ts 是很多读者容易忽略却至关重要的文件:
import createCache from '@emotion/cache';
export default function createEmotionCache(options?: Parameters<typeof createCache>[0]) {
const emotionCache = createCache({ key: 'mui', ...options });
const prevInsert = emotionCache.insert;
emotionCache.insert = (...args) => {
// ignore styles that contain layer order (`@layer ...` without `{`)
if (!args[1].styles.match(/^@layer\s+[^{]*$/)) {
args[1].styles = `@layer mui {${args[1].styles}}`;
}
return prevInsert(...args);
};
return emotionCache;
}
它做了两件事:
- 用
key: 'mui'创建 Emotion 缓存,使生成的样式类带有mui前缀标识,避免与第三方 Emotion 实例冲突; - 重写
insert钩子:对每条即将插入的样式追加@layer mui { ... }级联层包装,唯一的例外是当样式本身已是裸的@layer xxx声明(即不含{的层顺序语句)时跳过包装。这样可以把 MUI 生成的样式整体约束在自定义级联层内,降低与 Tailwind CSS 等使用@layer体系的框架共存时的优先级冲突风险。
这个缓存工厂同时被客户端水合路径(root.tsx)与服务端入口复用,确保两端规则一致。
SSR 与水合:entry.server.tsx 与 entry.client.tsx
框架模式的 SSR 由自定义入口接管。app/entry.server.tsx 展示了 MUI + Emotion 服务端渲染的规范做法:
const cache = createEmotionCache();
const { extractCriticalToChunks, constructStyleTagsFromChunks } = createEmotionServer(cache);
它通过 ReactDOMServer.renderToPipeableStream 把 <CacheProvider value={cache}><ServerRouter .../></CacheProvider> 渲染为可中断流,随后用 createEmotionServer(cache) 从产出的 HTML 中提取 Emotion 关键样式,拼成 <style> 标签注入到 </head> 之前。这解决了 SSR 场景下“首屏 HTML 不含样式导致闪烁/错版”的问题。
关于渲染就绪策略,代码做了精细区分:
const readyOption =
(userAgent && isbot(userAgent)) || routerContext.isSpaMode ? 'onAllReady' : 'onShellReady';
- 普通浏览器:用
onShellReady先吐壳,配合streamTimeout(5000ms 后abort)实现流式响应; - 爬虫/机器人或 SPA 模式下:改用
onAllReady,等全部内容加载完再响应,保证 SEO 抓取到完整 DOM(注释还引用了 React 官方对渲染策略的说明)。
浏览器侧 app/entry.client.tsx 则用 React.startTransition 包裹 ReactDOM.hydrateRoot,并在 <StrictMode> 下挂载 HydratedRouter 完成水合。这解释了前文 App 为何要区分客户端与服务端分支——服务端不建持久缓存、客户端必须建,两者配套才能保证样式幂等。
路由定义与页面组件
路由表集中在 app/routes.ts:
import { type RouteConfig, index, route } from '@react-router/dev/routes';
export default [
index('routes/home.tsx'),
route('/about', 'routes/about.tsx'),
] satisfies RouteConfig;
/映射routes/home.tsx(首页);/about映射routes/about.tsx(关于页)。
以 app/routes/about.tsx 为例,可以看出 MUI 组件与 React Router 的衔接写法:
import { Link as ReactRouterLink } from 'react-router';
// ...
<Button variant="contained" component={ReactRouterLink} to="/">
Go to the home page
</Button>
这里复用了 MUI Button 的 component 多态机制:把按钮的底层元素替换为 React Router 的 Link,从而既保留按钮外观又获得框架路由跳转能力(客户端导航、无整页刷新)。路由页面通过导出的 meta() 返回标题与描述,供框架生成 <head>。页面主体使用 Container、Box、Typography、Button 等 MUI 组件按 sx 布局;首页 home.tsx 的结构与之类似,可通过仓库源码继续对照阅读。
页面底部的 ProTip 与 Copyright 来自 app/components 目录,作为页脚提示与版权占位的可复用组件,也示范了把公共 UI 片段抽到 app/components 的组织习惯。
小结:可复制到自身项目的关键经验
围绕原 README 的“The idea behind the example”,可以从源码提炼出六条可直接迁移的工程经验:
- 用框架模式集成:
vite.config.ts挂载reactRouter()插件并声明~/*别名(vite-tsconfig-paths),无需手写路由入口; - 默认 SSR:
react-router.config.ts中ssr: true,需要纯 SPA 时可改回false; - 两端共享主题:在
root.tsx用AppTheme(ThemeProvider+CssBaseline)包住Outlet,theme.tsx中开启cssVariables与colorSchemes即可获得原生亮/暗双模式; - Emotion 缓存策略分端:客户端
CacheProvider使用单例缓存;服务端入口每次请求新建缓存并通过@emotion/server的extractCriticalToChunks/constructStyleTagsFromChunks注入<head>; - 样式分层隔离:通过
createCache.ts把全部 MUI 样式包进@layer mui,为将来与其他样式体系共存预留空间; - MUI × Router 的组合套路:跨页导航用
component={ReactRouterLink}让 MUI 组件直接具备框架路由能力,并在routes.ts集中维护路由表。
按原 README 的建议,拿到这套可用工程后,下一步可以在仓库文档的模板/入门章节以及 examples 目录下的其他示例(如 Next.js、Vite 版本)中挑选更完整的模板继续扩展——本次示例所承载的 MUI + React Router + Emotion SSR 体系,足以作为多数生产级应用的起点骨架。
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 StartedRust0627
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