Material UI v4 到 v5 迁移实战:从 JSS 平滑过渡到 Emotion 的 styled、sx 与 tss-react 方案
【免费下载链接】material-uiMaterial UI: Comprehensive React component library that implements Google's Material Design. Free forever.项目地址: https://gitcode.com/GitHub_Trending/ma/material-ui
Material UI(现 MUI)v5 将默认样式引擎从 JSS 替换为 Emotion,这是 v4 → v5 升级中最核心的风格化架构变化。本文基于仓库中的迁移指南 migrating-from-jss.md 完整展开:两条官方迁移路线(styled/sx与tss-react)的 codemod 命令、手动改写示例与限制条件全部保留,并深入 mui-codemod 包 的两个转换器源码,解释 PREFIX 推导、选择器重写、$嵌套规则替换等底层机制,帮助你把迁移风险降到最低。
背景:v5 为什么替换 JSS,以及迁移的灵活性
v5 中最大的变化之一,是用 Emotion(或 styled-components 作为替代引擎)取代 JSS 作为默认样式方案。两点关键事实需要明确:
- JSS 在 v5 中仍然可用。即使升级到 v5,你依然可以用
makeStyles、withStyles添加组件覆盖样式。这意味着迁移可以渐进式进行:先把组件库升到 v5,再逐文件把 JSS 风格化代码重构为新引擎。 - SSR 场景有现成参考。如果项目使用 Next.js,且不确定如何让 SSR 同时兼容 Emotion 与 JSS,仓库中提供了专门的双引擎迁移示例工程 examples/material-ui-nextjs-ts-v4-v5-migration,其
package.json中同时安装了@mui/styles与 Emotion 相关依赖,可直接对照其服务端渲染配置。
下面两条路线均可使用,官方认为第一条(styled / sx)是更优选择。
路线一:使用 styled 或 sx API
1.1 使用官方 codemod 自动转换
官方提供 codemod 将 JSS 样式迁移到styledAPI。注意其固有代价:转换结果会增加 CSS 特异性(specificity)——这是 codemod 能做到的最佳机械转换,后续可参考后文的示例自行精炼。
npx @mui/codemod@latest v5.0.0/jss-to-styled <path>典型转换前后对比(makeStyles→styled+ 类名映射表):
import Typography from '@mui/material/Typography'; -import makeStyles from '@mui/styles/makeStyles'; +import { styled } from '@mui/material/styles'; -const useStyles = makeStyles((theme) => ({ - root: { - display: 'flex', - alignItems: 'center', - backgroundColor: theme.palette.primary.main - }, - cta: { - borderRadius: theme.shape.radius - }, - content: { - color: theme.palette.common.white, - fontSize: 16, - lineHeight: 1.7 - }, -})) +const PREFIX = 'MyCard'; +const classes = { + root: `${PREFIX}-root`, + cta: `${PREFIX}-cta`, + content: `${PREFIX}-content`, +} +const Root = styled('div')(({ theme }) => ({ + [`&.${classes.root}`]: { + display: 'flex', + alignItems: 'center', + backgroundColor: theme.palette.primary.main + }, + [`& .${classes.cta}`]: { + borderRadius: theme.shape.radius + }, + [`& .${classes.content}`]: { + color: theme.palette.common.white, + fontSize: 16, + lineHeight: 1.7 + }, +})) export const MyCard = () => { - const classes = useStyles(); return ( - <div className={classes.root}> + <Root className={classes.root}> {/* 这一方案的收益在于 Root 内部的 JSX 代码保持不变。 */} <Typography className={classes.content}>...</Typography> <Button className={classes.cta}>Go</Button> - </div> + </Root> ) }官方建议:先对一小批文件运行该 codemod,检查变更再继续——转换并非覆盖所有情况,部分文件需要在转换后手工调整。
源码级解读:codemod 如何推导出 PREFIX 与选择器
阅读 jss-to-styled.js 可以弄清上面转换结果中每个细节的由来,这对你复核 codemod 输出非常有用:
- PREFIX 推导是四级回退。
getPrefix()按顺序尝试:①withStyles(styles, { name })第二参数中的name选项;② 文件的export default声明名;③ 首个大写开头的具名导出变量名;④ 最终回退到文件 basename(去掉扩展名,见computePrefixFromPath)。所以上例中PREFIX = 'MyCard'来自组件导出名/文件名。 &.与& .的选择器区分。getRootClassKeys()会扫描 JSX 根元素上出现的className={classes.xxx}(支持直接成员表达式与clsx(classes.xxx)调用参数),凡是根元素自身使用的类键生成[`&.${classes.root}`](自身匹配),其余类键生成[`& .${classes.cta}`](后代选择器)。这就是为何转换结果的特异性高于 JSS 原版。- 重复类键自动去重。
createClasses与convertToStyledArg中的计数逻辑会把重名键追加数字后缀(root、root2),避免类名冲突。 - Fragment 根与 Suspense 的特殊处理。若组件根是
React.Fragment,codemod 会替换为div并插入TODO jss-to-styled codemod注释(见createStyledComponent中的注释分支),提醒你检查标签是否合适;若根是Suspense则直接放弃转换(isTagNameSuspense检查后原样返回)。 withStyles也会被一并转换:withStyles(styles)(Button)形式会被解包,样式并入Root,并在 JSX 使用处自动补回classesprop(withStylesComponents收集逻辑)。- 导入清理:转换器把
@material-ui/core/styles/@mui/styles中makeStyles、withStyles、createStyles的具名导入剥离,并在缺失时插入import { styled } from '@mui/material/styles';结尾还有三条正则替换做useStyles()残留与classes解构的收尾清理。
对应的行为测试集中在 jss-to-styled.test.js,jss-to-styled.test/ 目录下有多组 before/expected 快照(multipleWithStyles、ninth、tenth等),可对照验证各类边界写法。
1.2 手动改写:优先推荐 sx
对于响应式样式或小的 CSS 覆盖,官方推荐sx而非styled(sx的系统式属性详见 sx 属性文档):
import Chip from '@mui/material/Chip'; -import makeStyles from '@mui/styles/makeStyles'; +import Box from '@mui/material/Box'; -const useStyles = makeStyles((theme) => ({ - wrapper: { - display: 'flex', - }, - chip: { - padding: theme.spacing(1, 1.5), - boxShadow: theme.shadows[1], - } -})); function App() { - const classes = useStyles(); return ( - <div className={classes.wrapper}> - <Chip className={classes.chip} label="Chip" /> - </div> + <Box sx={{ display: 'flex' }}> + <Chip label="Chip" sx={{ py: 1, px: 1.5, boxShadow: 1 }} /> + </Box> ); }注意sx中py: 1, px: 1.5直接对应theme.spacing(1, 1.5),boxShadow: 1对应theme.shadows[1]——sx 会按主题系统自动解析这些简写值。
1.3 手动改写:拆分为多个 styled 组件
若不想增加 CSS 特异性,可以把一组类拆成多个 styled 组件,每个样式只挂在自己对应的元素上:
-import makeStyles from '@mui/styles/makeStyles'; +import { styled } from '@mui/material/styles'; -const useStyles = makeStyles((theme) => ({ - root: { - display: 'flex', - alignItems: 'center', - borderRadius: 20, - background: theme.palette.grey[50], - }, - label: { - color: theme.palette.primary.main, - } -})) +const Root = styled('div')(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + borderRadius: 20, + background: theme.palette.grey[50], +})) +const Label = styled('span')(({ theme }) => ({ + color: theme.palette.primary.main, +})) function Status({ label }) { - const classes = useStyles(); return ( - <div className={classes.root}> - {icon} - <span className={classes.label}>{label}</span> - </div> + <Root> + {icon} + <Label>{label}</Label> + </Root> ) }社区还有一个非 MUI 维护的转换工具,能把 JSS 直接转成多个 styled 组件而不增加特异性,适合上述拆分场景。
路线二:使用 tss-react
tss-react的 API 与 v4 的makeStyles非常接近,但底层运行在@emotion/react之上,且 TypeScript 支持远好于 v4 的makeStyles。限制条件:如果你以 styled-components 替代 Emotion 作为底层引擎(参见 互操作性文档 中 styled-components 一节),tss-react 不可用。
安装依赖:
# npm npm install tss-react # yarn yarn add tss-reacttss-react 并非由 MUI 官方维护。SSR(Next.js)配置、
theme对象定制等问题应查阅 tss-react 官方文档;Bug 与需求也应在其仓库中提交。
2.1 使用官方 codemod
npx @mui/codemod@latest v5.0.0/jss-to-tss-react <path>基础转换(makeStyles()变为柯里化调用,返回值解构出classes):
import * as React from 'react'; -import makeStyles from '@material-ui/styles/makeStyles'; +import { makeStyles } from 'tss-react/mui'; import Button from '@mui/material/Button'; import Link from '@mui/material/Link'; -const useStyles = makeStyles((theme) => { +const useStyles = makeStyles()((theme) => { return { root: { color: theme.palette.primary.main, }, apply: { marginRight: theme.spacing(2), }, }; }); function Apply() { - const classes = useStyles(); + const { classes } = useStyles(); return ( <div className={classes.root}> <Button component={Link} to="https://support.mui.com" className={classes.apply}> Apply now </Button> </div> ); } export default Apply;使用$嵌套选择器语法与clsx组合类名时,转换结果会引入 TypeScript 泛型与cx:
import * as React from 'react'; -import { makeStyles } from '@material-ui/core/styles'; -import clsx from 'clsx'; +import { makeStyles } from 'tss-react/mui'; -const useStyles = makeStyles((theme) => ({ +const useStyles = makeStyles<void, 'child' | 'small'>()((theme, _params, classes) => ({ parent: { padding: 30, - '&:hover $child': { + [`&:hover .${classes.child}`]: { backgroundColor: 'red', }, }, small: {}, child: { backgroundColor: 'blue', height: 50, - '&$small': { + [`&.${classes.small}`]: { backgroundColor: 'lightblue', height: 30 } }, })); function App() { - const classes = useStyles(); + const { classes, cx } = useStyles(); return ( <div className={classes.parent}> <div className={classes.child}> Background turns red when the mouse hovers over the parent. </div> - <div className={clsx(classes.child, classes.small)}> + <div className={cx(classes.child, classes.small)}> Background turns red when the mouse hovers over the parent. I am smaller than the other child. </div> </div> ); } export default App;使用 JavaScript(而非 TypeScript)的项目,请删掉
<void, 'child' | 'small'>泛型参数。
2.2 综合示例:$语法 + 参数 + classes prop + 显式命名
以下完整示例同时覆盖四种高级特性:$语法、useStyles()参数、合并classesprop、为样式表提供显式name:
-import clsx from 'clsx'; -import { makeStyles, createStyles } from '@material-ui/core/styles'; +import { makeStyles } from 'tss-react/mui'; -const useStyles = makeStyles((theme) => createStyles< - 'root' | 'small' | 'child', {color: 'primary' | 'secondary', padding: number} -> -({ - root: ({color, padding}) => ({ +const useStyles = makeStyles<{color: 'primary' | 'secondary', padding: number}, 'child' | 'small'>({name: 'App'})((theme, { color, padding }, classes) => ({ + root: { padding: padding, - '&:hover $child': { + [`&:hover .${classes.child}`]: { backgroundColor: theme.palette[color].main, } - }), + }, small: {}, child: { border: '1px solid black', height: 50, - '&$small': { + [`&.${classes.small}`]: { height: 30 } } -}), {name: 'App'}); +})); function App({classes: classesProp}: {classes?: any}) { - const classes = useStyles({color: 'primary', padding: 30, classes: classesProp}); + const { classes, cx } = useStyles({ + color: 'primary', + padding: 30 + }, { + props: { + classes: classesProp + } + }); return ( <div className={classes.root}> <div className={classes.child}> The Background take the primary theme color when the mouse hovers the parent. </div> - <div className={clsx(classes.child, classes.small)}> + <div className={cx(classes.child, classes.small)}> The Background take the primary theme color when the mouse hovers the parent. I am smaller than the other child. </div> </div> ); } export default App;源码级解读:tss-react codemod 的转换规则与 TODO 标记
jss-to-tss-react.js 实现了上述全部转换,理解它能帮你预判哪些写法会失败:
- 导入重定向。转换器把来自
@material-ui/core/styles、@material-ui/core、@mui/styles、@material-ui/styles/makeStyles等路径的makeStyles/withStyles具名导入统一改写为tss-react/mui;createStyles的包裹会被剥掉,但会先提取其中的第二个类型参数作为 params 类型(即上例<{color: ...}, 'child' | 'small'>中第一个泛型的来源)。 $嵌套规则的正则替换。transformNestedKeys收集样式对象顶层所有规则名,构造形如(\$child|\$small)的正则,在嵌套规则键中定位$xxx片段并替换为模板字符串`.${classes.child}`(父级嵌套用后代选择器.,自身组合规则如'&$small'替换为`&.${classes.small}`),同时把这些规则名收集进nestedKeys生成第二个泛型。clsx/classnames自动替换为cx。转换器检测文件中的clsx或classnames默认导入,把调用点改写为cx并从解构中补上cx,同时移除原导入。classesprop 的二元参数迁移。useStyles({ ..., classes })中的classes会被抽出来,重组为第二参数{ props: { classes } }(源码中对hookArg.properties的拆分逻辑),与 tss-react 的 API 契约对齐。withStyles参数重排。tss-react 的withStyles(Component, styles)把组件放在第一个参数,转换器执行withStylesCall.arguments.unshift(component)完成参数换位,这也解释了 2.4 节 diff 中Button从外层移到内层的原因。- 无法可靠处理的场景会留下
TODO jss-to-tss-react codemod注释,包括:CSS prop 值中嵌套了 ArrowFunctionExpression、@global规则(tss-react 不支持,注释中附替代方案指引)、createStyles具名 hook 被跨文件导出使用(外部文件中的调用不会被转换)、箭头函数参数形态不符合预期等。
运行 codemod 后的必做检查:全局搜索
TODO jss-to-tss-react codemod定位未转换的写法。此外,即使没有 TODO 注释,仍存在 codemod 未完全处理的情况——特别是当样式定义在函数体内部且使用了$语法或useStyles参数时,这些样式不会被正确迁移。相关边界场景见 jss-to-tss-react.test.js 与 expected-todo-comments.js 快照。
2.3cx取代clsx的取舍
官方明确要求丢弃clsx、改用 Emotion 的cx。其核心优势是:cx能识别 Emotion 生成的类名,从而保证样式按正确顺序被覆盖。注意一个细节:JSS 与 tss-react 对多个 CSS 类的默认优先级不同,cx参数的顺序可能需要手工重排——迁移后若出现样式覆盖不符合预期,这是首要排查点。
2.4withStyles()的类型安全替代
tss-react 提供 v4withStyles()的类型安全实现,$语法在其withStyles()中同样受支持:
-import Button from '@material-ui/core/Button'; +import Button from '@mui/material/Button'; -import withStyles from '@material-ui/styles/withStyles'; +import { withStyles } from 'tss-react/mui'; const MyCustomButton = withStyles( + Button, (theme) => ({ root: { minHeight: '30px', }, textPrimary: { color: theme.palette.text.primary, }, '@media (min-width: 960px)': { textPrimary: { fontWeight: 'bold', }, }, }), -)(Button); +); export default MyCustomButton;2.5 主题样式覆盖(Theme style overrides)
全局主题覆盖(components主题键)在 TSS 中开箱即用。操作要点:
- 按 v5 风格破坏性变更文档 中 "Restructure component definitions" 一节重构组件定义;
- 为
makeStyles提供name(如上文综合示例的{name: 'App'}),使类名包含组件真实名称,主题覆盖才能正确匹配。
v5 中样式覆盖还支持回调形式。默认情况下 TSS 只向覆盖回调提供theme;若还需要 props 与ownerState,需按 tss-react 的 "MUI global style overrides" 文档额外配置。
2.6 类名中包含组件真实名称
为了让类名始终包含组件实际名称(便于调试与主题覆盖匹配),可以把name作为隐式命名键传入:name: { App }。这与 jss-to-styled codemod 的 PREFIX 推导目的相同——区别在于前者由 codemod 静态推导,后者由你在makeStyles选项中显式声明。
2.7 ESLint 提示
当你从 hook 返回值解构多个变量(const { classes, cx } = useStyles())时,可能触发eslint(prefer-const-destructuring)之类的告警;在常规项目中可以直接对该规则关闭,这是官方给出的处理方式。
完成迁移:移除 @mui/styles
全部样式迁移完成后,卸载不再需要的 JSS 包:
# npm npm uninstall @mui/styles # yarn yarn remove @mui/styles重要:
@emotion/styled是@mui/material的 peer dependency。即使你的代码从不显式引用它,也必须保留在依赖中,否则物料组件内部样式无法工作。
迁移路径小结
| 维度 | styled / sx | tss-react |
|---|---|---|
| 底层引擎 | Emotion | @emotion/react |
| 与 v4 API 的相似度 | 低(需改为 styled 组件或 sx 系统属性) | 高(makeStyles形态几乎不变) |
| TypeScript 支持 | 良好 | 显著优于 v4makeStyles |
| 自动化工具 | npx @mui/codemod@latest v5.0.0/jss-to-styled <path> | npx @mui/codemod@latest v5.0.0/jss-to-tss-react <path> |
| 主要代价 | 机械转换会增加 CSS 特异性 | 多类优先级可能与 JSS 不同,cx参数顺序需复核 |
| 维护方 | MUI 官方 codemod | 第三方(非 MUI 维护) |
| 兼容性限制 | 无 | 不能与 styled-components 引擎混用 |
无论选择哪条路线,都建议:小批量先行 → 检查 codemod 输出与 TODO 标记 → 全量执行 → 搜索验证 → 卸载@mui/styles并确认@emotion/styled仍保留在依赖中。
【免费下载链接】material-uiMaterial UI: Comprehensive React component library that implements Google's Material Design. Free forever.项目地址: https://gitcode.com/GitHub_Trending/ma/material-ui
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考