Material UI 与 Next.js 的 v4 到 v5 迁移实战:TypeScript 下让 `@mui/styles`(JSS) 与 Emotion 双引擎共存
导读:本示例
material-ui-nextjs-ts-v4-v5-migration是一个面向"仍在使用 v4 遗留样式方案@mui/styles(基于 JSS)的存量项目"的 Next.js(Pages Router)+ TypeScript 起步脚手架。它同时接入 Material UI v5 默认的 Emotion 样式引擎与 v4 遗留的 JSS 引擎,并完成服务端渲染(SSR)双引擎配置,用于演示完成 v5 主题与组件破坏性变更处理后的中间迁移态。读完本文,你将掌握:如何在同一个 Next.js 应用中让 JSS 生成的样式在 SSR 阶段与 Emotion 并存、如何让 JSS 样式覆盖组件默认样式、如何适配 Pages Router 的 Link 组件,以及如何为渐进式迁离@mui/styles做好类型与结构铺垫。
示例定位:迁移过渡期的"中间态"脚手架
Material UI 从 v4 升级到 v5 时,样式引擎由 JSS 切换为 Emotion。对于大型存量项目,把全部 makeStyles/withStyles 一次性改写成本身就是高风险动作,官方推荐的落地方式是分阶段迁移:先让两个引擎并存,逐页清理,最后完全移除 @mui/styles。本示例正是这一过渡形态的最小可运行范本。
根据 README 的定位说明,该示例具有两个关键设计目标:
- 同时提供 Emotion 与 JSS 所需的全部 SSR 配置,避免客户端闪一下无样式内容(FOUC)或因双引擎取样式顺序错乱导致的水合问题;
- 让 JSS 样式覆盖(take precedence over)由 Emotion 写入组件的默认样式,这正是一个 v4 项目升级到 v5 后,尚未迁移的旧组件仍能保持原有观感的必要条件。
因此它本质上是"v4 遗留代码 + v5 新代码"在同一应用内共存的最小骨架,README 也明确说明它展示的是处理完 v5 在 theme 与 components 层面的破坏性变更之后的迁移结果。
目录结构与职责划分
示例完整目录如下:
examples/material-ui-nextjs-ts-v4-v5-migration/
├── pages/
│ ├── _app.tsx # 全局 Provider + 客户端移除 JSS 注入样式
│ ├── _document.tsx # SSR:Emotion 与 JSS 双引擎取样式
│ ├── about.tsx # makeStyles 用法演示
│ └── index.tsx # 首页,makeStyles 用法演示
├── public/favicon.ico
├── src/
│ ├── Copyright.tsx # 页脚公共组件(Emotion sx 写法)
│ ├── Link.tsx # Next.js Pages Router Link 的 Material UI 适配
│ ├── ProTip.tsx # 提示组件(JSS + sx 混用示例)
│ └── theme.ts # createTheme 主题实例(含 next/font 的 Roboto)
├── types/
│ └── mui-styles.d.ts # @mui/styles 默认主题类型增强
├── next.config.mjs # 仅开启 reactStrictMode
├── package.json
└── tsconfig.json
从 pages/index.tsx 与 pages/about.tsx 可以看到页面同时从 @mui/material 导入 Container、Typography、Button(新引擎组件)又从 @mui/styles 导入 makeStyles(旧引擎 API),二者互不干扰。
快速上手:安装、运行与在线体验
获取该示例最直接的方式是克隆本仓库后进入示例目录:
git clone https://gitcode.com/GitHub_Trending/ma/material-ui.git
cd material-ui/examples/material-ui-nextjs-ts-v4-v5-migration
安装依赖并启动开发服务器:
npm install
npm run dev
随后打开 Next.js 默认输出的本地地址即可访问首页与 /about 页面。项目还提供了对应的 package 级脚本,见 package.json:
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
}
即开发调试用 dev、产物构建用 build、生产环境启动用 start。此外,该示例在仓库的 GitHub 上游也配置了 StackBlitz、CodeSandbox 的一键在线编辑入口,适合在不拉取仓库的情况下直接体验双引擎 SSR 的效果。
依赖剖析:为什么会同时出现这些包
package.json 的依赖可以分成四组理解:
| 分组 | 依赖 | 作用 |
|---|---|---|
| 框架 | next、react、react-dom |
基于 Pages Router 的 SSR React 应用(示例用的是 Next.js 当前版本,README 亦说明其面向 Pages Router) |
| Material UI 新栈 | @mui/material、@mui/icons-material、@mui/material-nextjs |
v5 组件库、图标库,以及官方提供的 Next.js 集成(AppCacheProvider、DocumentHeadTags 等) |
| Emotion(v5 默认样式引擎) | @emotion/cache、@emotion/react、@emotion/styled、@emotion/server |
Material UI v5 的默认样式引擎及其 SSR 配套工具 |
| JSS 遗留引擎 | @mui/styles |
v4 时代的 makeStyles/withStyles/ServerStyleSheets 来源,本次迁移的主角 |
| 构建/静态辅助 | clsx、autoprefixer、clean-css、postcss、typescript 等 |
类名拼接、生产环境 CSS 前缀补齐与压缩、类型检查 |
注意
@mui/material-nextjs在仓库中同样有源码实现,见 packages/mui-material-nextjs 包,示例通过@mui/material-nextjs/v14-pagesRouter子路径引用其 Pages Router 集成 API。
若更偏好 styled-components 而非 Emotion,README 也指出可以按官方互操作指南将默认样式引擎替换为 styled-components——本示例选择 Emotion 作为演示默认配置。
SSR 关键实现之一:Emotion 的样式收集
v5 组件的 Emotion 样式在服务端通过 @mui/material-nextjs 提供的 Provider 完成收集。在 _app.tsx 中,根组件被 AppCacheProvider 包裹:
<AppCacheProvider {...props}>
<ThemeProvider theme={theme}>
{/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */}
<CssBaseline />
<Component {...pageProps} />
</ThemeProvider>
</AppCacheProvider>
它向 React 树注入 emotion cache;而在 _document.tsx 中,通过 DocumentHeadTags 组件与 documentGetInitialProps 把 Emotion 生成的 <style data-emotion> 标签写入 HTML <head>。_app.tsx 里还配置了 viewport meta,_document.tsx 里则通过 theme.palette.primary.main 输出 PWA 主题色,并引入 /favicon.ico。
SSR 关键实现之二:JSS 的样式收集与优先级控制
JSS 侧是本示例与普通 Material UI Next.js 模板最大的差异点。_document.tsx 在 MyDocument.getInitialProps 中手动实例化了 JSS 的服务器端样式表:
const jssSheets = new JSSServerStyleSheets();
const finalProps = await documentGetInitialProps(ctx, {
plugins: [
{
enhanceApp: (App) =>
function EnhanceApp(props) {
return jssSheets.collect(<App {...props} />);
},
resolveProps: async (initialProps) => {
let css = jssSheets.toString();
// ...
return {
...initialProps,
styles: [
...(Array.isArray(initialProps.styles)
? initialProps.styles
: [initialProps.styles]),
<style
id="jss-server-side"
key="jss-server-side"
dangerouslySetInnerHTML={{ __html: css }}
/>,
...React.Children.toArray(initialProps.styles),
],
};
},
},
],
});
这套流程可以从源码层面拆解为三步:
- 收集:
enhanceApp将整个 App 组件用jssSheets.collect(...)包裹,让渲染过程中所有makeStyles/withStyles生成的规则都被该 JSS sheet 收集; - 输出:
resolveProps中jssSheets.toString()把收集到的 CSS 序列化为字符串,注入到id="jss-server-side"的<style>标签; - 位置控制(优先级来源):关键在拼接顺序——
initialProps.styles(即 Emotion 的样式数组)被展开后放在 JSS<style>之后。也就是说 HTML 中先出现 JSS 规则、后出现 Emotion 规则。
为什么这个顺序决定了"JSS 样式优先"?CSS 层叠规则中,同优先级下后定义的规则胜出。服务端 HTML 里 Emotion 的样式标签排在后面,但客户端水合后 Emotion 会把样式重新插入 <head> 最前,而示例在客户端又主动移除了 #jss-server-side 标签(见下文),从而在真实浏览器层叠中让先注入的 JSS 规则位于 Emotion 规则之后。README 中"JSS style overrides take precedence over the default styles passed to the components by Emotion"(JSS 样式优先于 Emotion 写入组件的默认样式)正是通过这套双向配合实现的——JSS 规则在 DOM 中排后,即可覆盖 Emotion 的组件默认样式。
客户端清理服务端注入的 JSS 样式
_app.tsx 在挂载后的 useEffect 中执行了对称的清理:
React.useEffect(() => {
// Remove the server-side injected CSS.
const jssStyles = document.querySelector('#jss-server-side');
if (jssStyles) {
jssStyles?.parentElement?.removeChild(jssStyles);
}
}, []);
原因是 JSS 会在客户端基于同一份样式表重新生成一份新的 <style>,若不删除服务端注入的 #jss-server-side,页面上会出现两份内容重复的 JSS CSS,造成冗余与潜在冲突。这也说明双引擎并存必须在 SSR 注入与客户端清理两侧成对配置,缺一不可。
生产环境的 CSS 后处理
_document.tsx 中还有一个细节:仅在 NODE_ENV === 'production' 时动态加载 postcss + autoprefixer + clean-css,对 JSS 产出的 CSS 字符串先补齐浏览器前缀再压缩:
if (css && process.env.NODE_ENV === 'production') {
const result1 = await prefixer.process(css, { from: undefined });
css = result1.css;
css = cleanCSS.minify(css).styles;
}
文件注释里说明了选型理由:clean-css 比 cssnano 更快但输出体积略大;autoprefixer 用于按 .browserslistrc 补齐厂商前缀。这样 JSS 的产出在生产构建中能得到与 Emotion 体系同等的工程化处理,而不是一段"裸 CSS"。
主题实例与类型桥接:让 makeStyles 拿到完整主题
src/theme.ts 使用 v5 的 createTheme 构建主题:
const roboto = Roboto({
weight: ['300', '400', '500', '700'],
subsets: ['latin'],
display: 'swap',
});
const theme = createTheme({
cssVariables: true,
palette: {
primary: { main: '#556cd6' },
secondary: { main: '#19857b' },
error: { main: red.A400 },
},
typography: {
fontFamily: roboto.style.fontFamily,
},
});
值得注意的 v5 细节有两处:一是通过 next/font/google 的 Roboto 加载字体并注入 fontFamily,避免额外的网络字体请求;二是开启了 cssVariables: true(v5 的主题 CSS 变量实验能力,产生 --mui-* 变量)。该主题经 ThemeProvider 同时提供给 Emotion 组件与 JSS 的 makeStyles 回调。
而 types/mui-styles.d.ts 承担了类型桥接职责:
import { Theme } from '@mui/material/styles';
declare module '@mui/styles' {
interface DefaultTheme extends Theme {}
}
为什么需要这 4 行? v4 中 makeStyles 回调的 theme 参数类型来自 @mui/styles 内部的 DefaultTheme;迁移期应用从 v5 的 createTheme 生成主题后,二者默认类型互不相通。通过模块增强让 @mui/styles 的 DefaultTheme 继承 v5 的 Theme,即可让形如 theme.spacing(4)、theme.palette.primary.main 的既有代码在 makeStyles 回调中获得完整的类型提示与编译期校验。该文件被 tsconfig 默认纳入,无需手动 import。
页面中的 JSS 遗留用法:makeStyles 的实际样例
迁移态代码的典型形态可参见 pages/index.tsx:
const useStyles = makeStyles((theme) => ({
main: {
marginTop: theme.spacing(4),
marginBottom: theme.spacing(4),
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
},
}));
export default function Home() {
const classes = useStyles();
return (
<Container maxWidth="lg">
<div className={classes.main}>
<Typography variant="h4" component="h1" sx={{ mb: 2 }}>
Material UI - Next.js example in TypeScript with legacy @mui/styles
</Typography>
<Link href="/about" color="secondary">
Go to the about page
</Link>
<ProTip />
<Copyright />
</div>
</Container>
);
}
可以清楚看到 v4/v5 混写的细节:makeStyles(...) + classes.main 是典型的 v4 JSS 语法,而同文件里 Typography 的 sx={{ mb: 2 }} 却是 v5 新语法——两种写法并行不悖,正是渐进迁移希望达到的效果。about.tsx 还展示了把 MUI 组件与自定义 Link 通过 component 组合的用法,并使用 Button component={Link} noLinkStyle 让按钮充当路由链接。公共组件 ProTip.tsx 与 Copyright.tsx 也体现了同样的混合风格(makeStyles 与 sx 并存)。
Link 组件适配:让 Material UI 的 Link/Button 无缝使用 Pages Router
Next.js Pages Router 自带与 App Router 不同的 Link 组件语义(旧的 <Link><a> 时代 API 演变、href/as 的静态与动态路由能力等),直接把它塞进 @mui/material/Link 的 component 会导致类型与事件处理不一致。README 明确指出示例目录提供了针对 Material UI 的适配器,即 src/Link.tsx。它导出两个成员:
NextLinkComposed——对 Next.js next/link 的薄封装,将 MUI 世界习惯的 to/linkAs 属性转发为 Next.js 的 href/as,并通过 React.forwardRef 透传 ref:
export const NextLinkComposed = React.forwardRef<HTMLAnchorElement, NextLinkComposedProps>(
function NextLinkComposed(props, ref) {
const { to, linkAs, ...other } = props;
return <NextLink href={to} as={linkAs} ref={ref} {...other} />;
},
);
Link——面向使用者的高层组件。它把 Next.js Link 的能力与 MUI Link 的样式能力合并:默认渲染为 MuiLink(component={NextLinkComposed}),传入 noLinkStyle 时则只渲染 Next 的锚点、不套 MUI 样式;同时利用 useRouter() 与 clsx 实现了基于当前路径的 activeClassName 高亮逻辑:
const className = clsx(classNameProps, {
[activeClassName]: router.pathname === pathname && activeClassName,
});
if (noLinkStyle) {
return <NextLinkComposed className={className} ref={ref} {...nextjsProps} {...other} />;
}
return (
<MuiLink component={NextLinkComposed} className={className} ref={ref} {...nextjsProps} {...other} />
);
页面中 Link href="/about"(首页)、Button component={Link} noLinkStyle href="/"(about 页)即是对该适配器两种形态的消费示例。它在类型上同时兼容 next/link 的 href/as 与 MUI Link 的样式属性,因此可作为迁移期全局导航组件的统一替换点,更多细节可参考官方文档中关于 Pages Router 路由集成的说明。
迁移路线图:迁移完成后如何全身而退
本示例的目标是"让你能跑起来并继续逐页改造",而不是永久双引擎。仓库的迁移文档为后续动作提供了完整参考:
- v5 样式变更详解:Emotion 迁移、
makeStyles替代方案、主题结构变化(间距、圆角、阴影等 scale 化)、sx语法等; - v5 组件变更详解:组件 API 层面的破坏性变更(如
variant、InputProps、受控组件行为等)逐一处理清单; - 从 JSS 迁移指南:聚焦
makeStyles/withStyles到sx/styled 的具体改写步骤,并说明何时可以安全地从依赖中移除@mui/styles。
参考这些指南,典型的收尾路径是:在每一页完成 makeStyles → sx(或 styled)改写后删除该页对 @mui/styles 的 import → 移除 _document.tsx 中 JSSServerStyleSheets 相关逻辑与 #jss-server-side 注入 → 删除 _app.tsx 中的清理代码与 types/mui-styles.d.ts 类型增强 → 最后从 package.json 卸载 @mui/styles 与 autoprefixer/clean-css 等仅为 JSS 后处理服务的依赖。届时该脚手架就自然收敛为一个"纯 v5 + Emotion"的标准 Material UI Next.js 应用。
小结
本示例的价值在于把"v4 存量 JSS 代码 + v5 Emotion 新代码"这一最容易出错的过渡态封装成了可运行、可复制的脚手架。其核心工程要点可归纳为四条:
- SSR 双侧收集:Emotion 由
AppCacheProvider+DocumentHeadTags负责,JSS 由ServerStyleSheets在MyDocument.getInitialProps中手动collect/toString; - 样式优先级控制:服务端把 JSS CSS 注入为
#jss-server-side的<style>,配合客户端useEffect移除,使 JSS 覆盖 Emotion 默认样式且不产生重复 CSS; - 类型桥接:通过
declare module '@mui/styles'让DefaultTheme继承 v5Theme,保证makeStyles回调获得完整类型; - 组件适配层:
src/Link.tsx提供 MUI 化的 Pages Router 链接组件,作为导航统一入口。
对于正在将 Material UI v4 项目升级到 v5、且体量不允许一次性改写全部样式的团队,这个示例是最贴近真实生产迁移路径的参考起点。
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