
博客从 Mantine 到 StyleX
在《博客重构》里,我专门解释过为什么选择 Mantine:我熟悉它,组件足够全,拿来就能用。那次重构的第一目标是尽快把博客从 Hexo 搬到 Next.js,而不是先花几个月造一套按钮、卡片、抽屉和响应式布局。Mantine 很好地完成了这个任务。
问题是,将近一年之后,目标变了。
页面早已能用,内容构建、搜索、代码高亮和移动端目录也逐渐稳定。我不再需要一个组件库帮我快速搭出所有东西,反而开始在意每个页面究竟带了多少 CSS、浏览器为什么要下载这些规则,以及一个看起来很简单的静态博客为什么还需要完整的主题状态管理。
最后推动我动手的数字很朴素:迁移前的构建产物里,CSS 一共 271.0 KB,gzip 后是 42.1 KB。其中最大的一份 CSS 文件原始体积 233.6 KB,gzip 后 34.4 KB。首页实际引用的 CSS gzip 体积是 39.9 KB。
几十 KB 放在今天当然谈不上灾难。图片随便大一点就超过它,CDN 也能缓存。可这是一份以文字为主的静态博客:首页只有文章卡片、导航、侧栏和几个按钮,却要先拿到一整套通用组件样式。数字不算夸张,比例却很刺眼。
于是问题从「Mantine 好不好用」变成了另一个:这个博客还需要为 Mantine 的通用能力继续负重前行吗?
我最后还是决定换。Mantine 依然好用,只是它照顾的是通用应用,而这个博客需要的东西已经少得多了。我开了 feat/stylex-migration 分支,先完成主体迁移,紧接着又花了不少时间收拾迁移带出来的小问题。那些返工,反倒比「把组件换掉」更值得写。
StyleX 和 Mantine 本来也不是同一类东西。前者是样式编译器,后者是一整套组件。换到 StyleX 以后,那些现成组件并不会凭空出现,我得自己维护一小套 UI。CSS 是小了,活也回到了自己手里。
一、Mantine 没做错,问题出在需求已经变了
1.1 当初选 Mantine,效率就是正确答案
2025 年把博客切到 Next.js 时,我需要的不是一项单独能力,而是一组能马上协同工作的零件:AppShell 负责页面骨架,Grid、Stack 和 Group 负责布局,Card、Badge、Button 负责基础外观,Tooltip、Progress、TableOfContents 处理交互,再由 MantineProvider 提供主题和配色。
如果全部自己写,工作不会停在几十行 CSS。组件还要处理属性透传、可访问性、键盘焦点、禁用状态、响应式参数、暗色模式和类型。Mantine 把这些问题收进了熟悉的组件属性里:
<AppShell
header={{ height: { base: 104, sm: 60 }, offset: false }}
footer={{ height: 120, offset: false }}
padding={{ base: 'sm', sm: 'md', xl: 'xl' }}
>
<AppShellMain>
<Grid gap="md" maw="88rem" mx="auto">
<GridCol span={{ base: 12, md: 8, xl: 9 }}>
{children}
</GridCol>
</Grid>
</AppShellMain>
</AppShell>这类代码的价值不在于少写几个 div,而在于不必当场设计每个抽象。重构初期页面结构仍在变化,先用一套成熟语言把页面搭起来,比先争论本站的 Card 应该暴露几个变体更重要。
Mantine 也不等于运行时注入一大团 CSS。它的组件样式本身由 CSS Modules 构建并随包发布,官方同时提供整包样式、单组件样式和 CSS Layer 版本。本站迁移前使用的是:
import '@mantine/core/styles.layer.css'
import '@mantine/code-highlight/styles.layer.css'这么写简单、稳定,并且解决了 Next.js 不保证样式导入顺序时的覆盖问题。Mantine 官方文档也明确说明,@mantine/core/styles.css 会包含 Core 包的全部组件样式;如果需要缩小体积,可以改为逐组件导入,但要同时处理组件之间的样式依赖和导入顺序。比如 Button 建立在 UnstyledButton 之上,不能只导入前者。
所以,大 CSS 不是一个神秘 Bug。它是我主动选择「整包导入,少维护依赖关系」之后得到的结果。把责任全推给 Mantine 并不公平。
1.2 组件库带来的便利,后来逐渐变成间接层
博客稳定之后,Mantine 组件越来越少承担真正复杂的行为,越来越多只是在替普通元素换一个名字。
Box 往往只是 div,Text 最后渲染成 p 或 span,Group 是带 display: flex 的容器,Stack 是另一个方向的 Flex,Card 在本站主要提供背景、边框、圆角和间距。真正仍然需要客户端状态的,只剩搜索、移动端目录、目录高亮、Tooltip 和页面过渡之类的小块。
与此同时,我已经写了 25 份 CSS Module 来覆盖或补充组件外观。迁移前,博客中有 44 个文件直接导入 @mantine/*。也就是说,界面既没有完全采用 Mantine 的设计语言,也不完全由本站控制,而是由 Mantine 组件、组件属性、主题变量和 CSS Modules 共同决定。
这会产生一种很隐蔽的维护成本。看到一处间距时,我得判断它来自 gap="md"、组件的默认属性、主题里的 spacing,还是本地 CSS Module;看到颜色时,还要判断是 c="dimmed"、Mantine CSS 变量,还是选择器覆盖。每一种写法单独看都合理,放在一起以后,追查一条样式却要来回翻好几个地方。
在《最近给博客做的优化》里,我已经处理过一次 Mantine 样式边界:改用 styles.layer.css,让未分层的本站 CSS 可以稳定覆盖组件样式。那个调整解决了层叠顺序,却没有解决「没用到的通用规则仍然进入产物」这件事。
1.3 为什么不直接逐组件导入?
这是最应该先问的问题。既然 Mantine 官方已经提供单组件 CSS,为什么要重写 UI?
逐组件导入确实最省事,也可能把那份 233.6 KB 的 Core 样式砍掉大半。若这是一个业务系统,页面仍然大量使用 Select、Modal、Menu、DatePicker 等复杂组件,我会优先走这条路,而不是为了几十 KB 推倒组件库。
本站的情况不同。
第一,44 个 Mantine 导入文件散布在布局、页面、正文组件和导航中。整理每个组件需要的 CSS 依赖并不只是修改一个入口。官方提供了依赖清单,但升级组件之后仍要继续维护。
第二,CSS 只是最醒目的问题,不是唯一问题。MantineProvider、主题对象和部分组件实现也让原本能在服务端渲染的代码进入了客户端。迁移前有 16 个文件带 use client,其中主题管理、页头和代码展示后来都不再需要客户端渲染。只做 CSS 按需导入,并不会顺便解决这些问题。
第三,本站已经在重写 Mantine 的外观。继续保留组件 API,再用 CSS Module 和组件属性把它改成另一套样子,等于长期维护两套设计之间的转换关系。
因此我没有把目标定成「让 Mantine 小一点」,而是定成了三件事:
- 样式在构建期生成,浏览器不负责注入和解析一套主题运行时;
- 只为本站实际出现的组件和状态输出规则,且相同声明尽量复用;
- 组件仍能跨文件、跨包组合样式,不能退回到随处拼字符串。
1.4 这次迁移,其实是《最近给博客做的优化》的续篇
写到这里,需要把时间线接回《最近给博客做的优化》。如果没有那篇文章,这次迁移看起来像是突然嫌弃 Mantine 太大,然后临时换了一个更时髦的方案。实际情况正相反:上一篇已经把 Mantine 的边界、StyleX 的计划,以及暂时不迁的理由都写了出来。
那一轮已经把 Mantine 样式切到了 styles.layer.css,用 CSS Layer 固定组件样式与本站 CSS Modules 的覆盖顺序。它解决的是「谁覆盖谁」,没有减少浏览器最终拿到的规则。层叠关系稳定以后,问题反而更清楚了:不是哪次导入顺序碰巧错了,而是整包 Core CSS 确实进了每个页面。
Shiki 当时也已经从浏览器搬到了构建端。上一篇记录了动态加载高亮器、按语言加载、等页面过渡结束,最后一路退到 server-only 预渲染的过程。代码窗口还在使用 Mantine,语法高亮却已经不再是客户端的工作。后来把代码组件移进共享 UI 包时,我差点又把 Shiki 带回客户端,只好把高亮函数单独放进 @vacuum/ui/server。这条边界不是迁移时才想出来的,上一篇已经踩过一遍。
移动目录也处在半自有状态:Drawer 已经换成本站自己的底部面板,目录追踪仍然使用 Mantine 的 TableOfContents。焦点怎么进入面板、关闭后回到哪里、按 Escape 要不要退出,上一篇都还欠着账。所以去掉 Mantine 时,我不能只看截图差不多就算完成,组件库原来帮忙处理的行为总得有人接手。
文章卡片上的标签则依赖 OverflowList 在客户端测量宽度。服务端不知道读者的字体、缩放和容器最终有多宽,也就算不出一行恰好能放几个标签。上一篇给自己的建议是先改展示规则,再谈去掉 use client。这次重做卡片时,我只保留了更稳定的分类展示。不是 StyleX 突然会算宽度了,只是页面不再要求它算。
更直接的是,那篇文章的 8.1 小节标题就是「去掉 Mantine,用 StyleX 完成所有样式」。当时给出的顺序也很谨慎:先梳理实际使用的组件,正文容器、卡片和普通布局先动,依赖测量、焦点或弹层的控件留到后面;如果标签展示规则本来就要变,先定规则,不要用 StyleX 原样复刻 OverflowList 后再重做。
实际做的时候顺序稍微变了:先接入构建链和外层布局,再迁正文与文章组件,然后处理页面、导航和剩余交互。主体换完以后,我又回头收拾设计变量、主题、共享包、移动 Safari 和服务端代码。上一篇记的是「准备怎么做」,这一篇接着写「做下去以后又出了什么事」。
两篇文章还有一条共同判断:博客内容在构建前已经确定,更新频率远低于阅读次数,适合把工作提前。Shiki 与 Mermaid 先走到了构建端;StyleX 继续把组件样式的生成和去重移到编译阶段。它们不是互不相关的优化,而是在同一个约束下逐步移动边界。
所以,从 Mantine 到 StyleX 并不是推翻上一篇的工作。CSS Layer、服务端高亮、移动目录和卡片标签的探索,反而让我知道哪些东西可以直接迁、哪些行为不能丢、哪些规则应该先改。上一篇在「想试试,但暂时排不到最前面」停下;这次则是在测出 271.0 KB CSS、确认组件使用范围后,把那项未来计划真正做完。
二、想换掉 Mantine,也不能只盯着 CSS 大小
候选方案并不少。继续用 Mantine、改成逐组件导入,当然算一个;CSS Modules 本来就在项目里;Tailwind、运行时 CSS-in-JS 和 StyleX 也都能把页面做出来。可这个博客已经有一套正在工作的构建链,方案能不能接进去,比 GitHub Star 或语法长短更实际。
迁移时的约束如下:
| 约束 | 为什么重要 |
|---|---|
| Next.js 16 App Router 与 Turbopack | 开发和生产构建已经依赖这套工具,不准备为了样式改用其他打包工具 |
| 静态导出 | 页面在构建时已知,适合把更多工作前移,不需要服务端运行时注入样式 |
| React Server Components | 没有交互的组件应继续留在服务端,样式方案不能强迫整棵树变成客户端组件 |
| Monorepo 与共享 UI 包 | 样式不仅在 packages/blog,还要能跟着 packages/ui 的源码组件一起编译 |
| 类型与组合 | 共享组件要接收受控的样式覆盖,而不是把任意 className 当作唯一扩展口 |
| 响应式与主题 | 需要媒体查询、prefers-reduced-motion、暗色模式和少量动态值 |
| 渐进迁移 | 两套样式必须能暂时共存,否则一次改完整站,回归范围太大 |
这些约束也说明了为什么「原生 CSS 才是最简单」只说对了一半。最终产物当然应该尽量接近原生 CSS,但写代码时还要考虑规则放在哪里、设计变量怎样共享、冲突怎样发现,以及组件是否允许调用者覆盖样式。
2.1 继续用 Mantine:风险最低,但没有改变抽象边界
保留 Mantine、改成单组件样式导入,是最省事的方案。成熟组件、文档和可访问性实现都能继续使用,也不用再引入新的编译器。
它没有入选,不是因为做不到减重,而是因为我真正想删掉的是通用组件层。博客几乎没有表单系统和复杂浮层,最常用的反而是 Card、Stack、Badge 这类很容易限定边界的小组件。继续围绕 Mantine 优化,会把时间花在精确裁剪一个我正在逐渐离开的 API 上。
这仍然是大多数 Mantine 项目更应该先尝试的方案。若你只嫌 CSS 大,先按官方的 Mantine styles 文档做按需导入;只有当组件抽象本身也开始妨碍你,迁移整个系统才有意义。
2.2 CSS Modules:已经在用,也足够可靠
CSS Modules 没有运行时,Next.js 原生支持,局部类名能避免大部分命名冲突。本站迁移前已有 25 份 .module.css,它显然可以继续承担全部样式。
我没有全量选择它,主要是组合方式不够直接。共享组件如果既有默认类,又允许页面覆盖,通常要在 className、多个命名类和 CSS 变量之间另做约定。响应式规则与设计变量仍要靠人保持一致;两个文件写了相同的 display: flex 和间距,最终能否合并取决于压缩器,写样式时并不能直接看出来。
但迁移后我也没有清零 CSS Modules。搜索标签和标签云目前仍保留两份样式文件,正文排版与全局基础样式继续使用普通 CSS。选择 StyleX 不意味着所有 CSS 都必须改写成 JavaScript 对象。 对后代选择器密集的 Markdown 正文,.typography h2 比给 MDX 生成的每个节点传 StyleX 样式更自然;对 data-level、aria-pressed 这类有限状态,普通 CSS 也很清楚。
2.3 Tailwind CSS:体积合适,写法却不合我的习惯
Tailwind 同样把样式收敛成原子规则,并在构建时只生成用到的工具类。响应式、暗色模式和状态变体都很成熟。若目标只有「减少 CSS」,它完全有资格成为答案。
我最后没有选它,主要是因为写法不合习惯。本站经常同时调整样式和组件结构,我希望 JSX 只表达「它是文章标题」「它是侧栏」,具体声明留在命名样式里。StyleX 的 styles.title、styles.sidebar 更接近这个习惯;Tailwind 则会把 display、间距、颜色、断点和状态直接铺在 className 中。
这不是说 Tailwind 普遍难读。熟悉它的团队看到工具类就能直接读出结果,通过组件和变体也能控制重复。只是这个仓库已经以 TypeScript 组件来组织代码,我更想传递一个有类型的 style 对象引用,而不是继续设计字符串合并规则。
2.4 运行时 CSS-in-JS:没有必要把静态问题留到浏览器
styled-components、Emotion 一类方案擅长动态主题和根据组件属性改变样式,写法也很灵活。但本站是静态导出,绝大多数规则在构建时就能确定。为它们保留运行时注入、序列化或水合阶段的协调,不符合这次优化方向。
这里不是说运行时 CSS-in-JS 一定慢,而是它解决的动态性本站用不上。和代码高亮从浏览器移到构建端一样,已知输入就提前计算,通常更适合这个博客。
2.5 为什么最后是 StyleX?
StyleX 把 JavaScript 对象写法编译成静态原子 CSS。官方文档把核心模型概括为 stylex.create 定义、stylex.props 应用;本地定义和使用的简单场景可以在编译后直接成为类名,跨文件传递样式时则保留一个很小的合并运行时。它不是「零 JavaScript」的魔法,但常见路径没有运行时插入样式。
我最后选它,主要因为这些约束刚好对得上:
- 每个 CSS 属性生成可复用的原子规则,重复声明不会随着组件数量线性增长;
- 同一属性由最后应用的样式获胜,跨文件组合不依赖选择器权重碰运气;
defineVars和defineConsts把颜色、间距和媒体条件变成可导入的类型引用;StyleXStyles可以作为组件属性,调用者能组合样式,组件也能限制哪些属性可以覆盖;- 生成静态 CSS,不要求组件为了样式变成
use client。
StyleX 官方也写清了随之而来的限制。它依赖 Ahead-of-Time(AOT,预先)编译,所以 stylex.create 中的值必须可静态分析:不能随便调用函数、展开对象或导入普通常量;真正动态的样式要写成受限箭头函数,底层通过 CSS 变量承接运行时值。它偏爱元素自己携带类名,不鼓励 .parent > * 这种远距离选择器。语法看似是对象,思维方式却不是把任意 CSS 原样塞进对象。
我接受这些限制,是因为它们正好迫使样式边界变得可解释。最终选择并不是「StyleX 写得最少」,恰恰相反,它经常比 Tailwind 和 CSS Modules 多写几行。选择它是因为我愿意用更多构建期约束,换取更小、可组合且不依赖层叠偶然性的产物。
三、样式很好写,先得让 Turbopack 认得它
StyleX 的 Hello World 很简单:
import * as stylex from '@stylexjs/stylex'
const styles = stylex.create({
title: {
color: 'rebeccapurple',
fontWeight: 650,
},
})
export function Title({ children }: { children: React.ReactNode }) {
return <h2 {...stylex.props(styles.title)}>{children}</h2>
}在这个仓库里,难的是让 Blog 和 UI 两个包、Turbopack 的 TypeScript 转换、PostCSS 的 CSS 提取看到同一批源码。
StyleX 官方的 Next.js 文档使用 Babel 编译 JS/TS,再由 PostCSS 把 @stylex 替换成生成的 CSS。本站没有切换到 Babel,而是选择了社区维护的 @stylexswc/nextjs-plugin 和 @stylexswc/postcss-plugin。前者用 Rust/SWC 完成 StyleX 转换;在 Turbopack 下,加载器只负责转换代码,CSS 提取仍由 PostCSS 完成。
这里也多了一层我得自己承担的风险:@stylexswc/* 不是 Meta 官方 StyleX 项目。 它追踪 StyleX 版本,也恰好提供我需要的 Turbopack 接入。当前锁定的是 StyleX 0.19.0 与 @stylexswc/* 0.18.6,以后升级不能只看其中一边。
最终的 Next.js 包装大致如下:
import withStylexTurbopack from '@stylexswc/nextjs-plugin/turbopack'
export default withStylexTurbopack({
rsOptions: {
aliases: {
'@/*': [`${process.cwd()}/*`],
'@vacuum/ui/*': [`${process.cwd()}/../ui/src/*`],
},
dev: process.env.NODE_ENV !== 'production',
unstable_moduleResolution: { type: 'commonJS' },
},
stylexImports: ['@stylexjs/stylex'],
})(nextConfig)PostCSS 还要扫描 Blog 与 UI 的源码:
export default {
plugins: {
'@stylexswc/postcss-plugin': {
include: [
'app/**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}',
'components/**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}',
'../ui/src/**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}',
],
rsOptions: stylexOptions,
},
},
}这两处配置必须一致。如果路径别名只写进 Turbopack,PostCSS 就可能找不到共享的设计变量,结果是代码可以编译,CSS 却不完整;如果 PostCSS 没有扫描 packages/ui,共享组件会生成类名,产物里却找不到对应规则。接入工作并不会在配好一个编译插件以后自动结束。
StyleX 的 defineVars 还有更严格的模块规则:变量必须放在 .stylex.ts 一类文件中,必须直接命名导出,文件也不能顺手导出其他内容。最初我把设计变量、主题和普通常量混在一起,很快就撞上了这些限制。后来才拆成 color.stylex.ts、space.stylex.ts、radius.stylex.ts 等文件,媒体查询则放进 constants.stylex.ts 的 defineConsts。
这些限制起初有点烦,但它们让编译器不必猜一段任意 JavaScript 最终会产生什么。StyleX 的性能不是凭空来的,静态可分析就是价格。
四、第一个动作并没有删除 Mantine
我没有一上来就删除 Mantine。第一个动作是接入 StyleX 的构建配置、设计变量和主题,再挑页头、页脚、整体布局和页面摘要这些最外层组件试着改。
先改外层,主要是为了验证编译链。如果一开始就动几十个组件,最后页面没有样式,很难判断是某个 StyleX 声明不合法、PostCSS 没扫到文件,还是主题变量根本没生效。这几个组件足以覆盖生成 CSS、暗色主题和响应式规则,出错时也容易缩小范围。
Mantine 和 StyleX 并不冲突。旧组件继续使用 Mantine 的 Layer CSS,新组件使用 StyleX 原子类,原来的 CSS Module 也照常覆盖。页面不会因为两套系统暂时共存就坏掉,我也不必把全站一次换完。
这一阶段也保留了管理主题的 Provider 组件。最初的 StyleX 主题用 defineVars 定义一整组默认值,再用 createTheme 生成明暗两套类名。这个组件读取 localStorage,挂载后同步状态,切换时把主题类加到 document.documentElement。
它能工作,却只是把 Mantine 的主题管理换成了自己写的版本:
export function Provider({ children }: PropsWithChildren) {
const [colorScheme, setColorScheme] = useState<ColorScheme>('light')
useEffect(() => {
const current = document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light'
// eslint-disable-next-line react/set-state-in-effect -- historical implementation
setColorScheme(current)
}, [])
return (
<ColorSchemeContext value={{ colorScheme, toggleColorScheme }}>
{children}
</ColorSchemeContext>
)
}做到这里,页面虽然已经开始像 StyleX 项目,结构却没有变简单。客户端主题状态、水合前执行的脚本和两套主题类都还在,只是原来由 Mantine 管理的部分换成了自己写的代码。
五、外层走通以后,开始一块一块地换
外层布局确认能工作以后,我先处理正文与文章区域:分页、MDX 元素、文章卡片、时效进度、许可信息、标签、目录、代码块、图片和链接。紧接着才是文章详情、友链、搜索、分类、热门标签、移动操作区和用户卡片。
我选择按可见区域推进,而不是先把全站所有 Text 换掉,再换所有 Group。纵向切的好处是每完成一块,就能打开对应页面检查完整结果。文章卡片的图片、标题、元数据和标签一起迁移,比四次经过同一个组件更容易发现布局回归。
以文章卡片为例,旧实现同时依赖 Mantine 和 CSS Module:
<Card className={classes.card} p={0}>
<div className={classes.grid}>
<Box component={CardLink} className={classes.imageLink} href={post.path}>
<Image className={classes.image} src={post.cover} />
</Box>
<Text className={classes.title} component={CardLink} href={post.path}>
{title}
</Text>
<Group className={classes.meta} wrap="nowrap" gap="xs">
...
</Group>
</div>
</Card>现在的实现把结构语义留给本站组件,把局部规则放到同一文件:
<Card as="article" padding="none" style={styles.card}>
<div {...stylex.props(styles.grid)}>
<CardSection padding="none" style={styles.mediaSection}>
<CardLink style={styles.imageLink} href={post.path}>
<Image style={styles.image} src={post.cover} />
</CardLink>
</CardSection>
</div>
</Card>
const styles = stylex.create({
grid: {
display: 'grid',
gridTemplateColumns: {
'default': 'minmax(16rem, 40%) minmax(22rem, 1fr)',
'@media (max-width: 47.999em)': 'minmax(0, 1fr)',
},
},
})新代码并没有明显更短,有些地方甚至更长。好处是查样式时不用再来回翻:Card 只负责本站定义的四档内边距和三种外观,styles.grid 直接包含响应式规则,不必再同时检查 Mantine 组件属性、主题和 CSS Module。
这一轮一口气删掉了大多数 CSS Modules,也很快暴露出机械迁移的问题。把每个 CSS 选择器原样搬成 stylex.create,顶多只能保证它会编译。紧接着回头检查这些代码时,很多地方都得重来。
六、紧接着,先收拾自己写出来的 StyleX
6.1 先让 ESLint 阻止错误写法
我先把 @stylexjs/eslint-plugin 加进来,开启扩展名、冲突属性、未使用样式、合法简写和样式有效性检查。
很快我就发现,单靠 TypeScript 不够。它知道 color 应该是什么类型,却不一定能发现 StyleX 编译规则里的所有限制。比如在同一对象中混用会冲突的简写与长写属性、从普通模块导入媒体查询字符串、样式定义后从未使用,都更适合由 StyleX 自己的规则检查。
等编译器报错再修通常已经太晚,而且错误还可能到 PostCSS 阶段才出现。让编辑器和代码检查尽早发现问题以后,才不用每次都等构建失败再逐个排查。
6.2 设计变量、主题值和普通常量不能混在一起
初版 theme.stylex.ts 超过一百行,颜色、字体、间距、圆角、阴影、断点和明暗主题都在一个文件。看起来集中,实际上混淆了三类东西:
- 需要生成 CSS 自定义属性的设计变量;
- 只供编译期展开的媒体查询常量;
- 主题切换时才变化的值。
接着拆开常量和主题,再把响应式条件集中。最后留下七组设计变量:color、font、radius、shadow、size、space 与 breakpoints。组件只导入自己用到的部分。
import * as stylex from '@stylexjs/stylex'
export const color = stylex.defineVars({
background: 'var(--color-background)',
surface: 'var(--color-surface)',
text: 'var(--color-text)',
textMuted: 'var(--color-text-muted)',
border: 'var(--color-border)',
primary: 'var(--color-primary)',
})export const breakpoints = stylex.defineConsts({
sm: '@media (min-width: 48em)',
md: '@media (min-width: 62em)',
xl: '@media (min-width: 88em)',
reducedMotion: '@media (prefers-reduced-motion: reduce)',
})集中断点不是为了省几个字符串,而是避免 48em、47.999em 和组件库旧断点慢慢分叉。StyleX 不允许从任意模块导入值进入 create,defineConsts 正好给这类编译期常量一个合法出口。
6.3 StyleX 偏爱局部样式,但复用组件需要跨文件组合
StyleX 官方建议把样式与组件结构放在同一文件,本地调用 create 和 props 时也最容易在编译后消除。我把大多数文件改成了这种写法:样式声明放在组件附近,条件通过布尔值或三元表达式传给 stylex.props,不自己拼类名。
分页按钮和元数据 Badge 是两个例外,相关组件需要共用同一套状态样式。如果把规则复制到每个文件,原子 CSS 虽然会去重,源码还是会逐渐出现差异,于是这少量声明继续留在独立样式模块里。
这里没有绝对规则。我最后采用的是:默认共置;只有两个以上组件确实共享同一语义规则时再抽离。不要因为 StyleX 能跨文件组合,就重新建一个包含全站所有样式的 styles.ts;那会把刚得到的局部性又丢掉。
七、组件散得到处都是,干脆抽出一个 UI 包
换完大部分 Mantine 组件后,替代品全散落在 Blog 包里。我新建了 packages/ui,把 AppShell、Badge、Button、Card、Code、Image 和 Stack 收进去,再让 Blog 从这个包里使用它们。
这个包直接分发源码,没有预先编译成一份不透明的产物。package.json 中的 exports 直接指向 src:
{
"exports": {
"./button": "./src/components/button/index.ts",
"./card": "./src/components/card/index.ts",
"./token/color.stylex": "./src/token/color.stylex.ts"
}
}这样做是为了让应用中的 StyleX 编译器读到组件里的 stylex.create,再与 Blog 的样式一起生成原子 CSS。相应地,使用这个包的项目必须同时配置 StyleX、TypeScript 源码处理和路径别名;它不是放进任意 React 项目就能直接使用的通用组件库。
共享组件也刻意保持很小。比如 Stack 只接受三档间距,Card 只有 elevated、outlined、subtle 三种外观。它们没有照搬 Mantine 的全部组件属性,否则只会在本地再造一个缩小版 Mantine。
export function Stack({ gap = 'default', style, ...props }: StackProps) {
return (
<div
{...props}
{...stylex.props(
styles.stack,
gap === 'compact' && styles.gapCompact,
gap === 'default' && styles.gapDefault,
gap === 'section' && styles.gapSection,
style,
)}
/>
)
}这里的 style?: StyleXStyles 是迁移后很重要的扩展口。组件提供基础规则,页面传入额外 StyleX 样式,stylex.props 负责按属性合并。调用者不需要知道内部生成了哪些类名,也不必靠提高选择器权重覆盖默认值。
不过,改用自有组件以后,这些细节就得由我自己照看。Mantine 的 Button 已经处理了许多浏览器问题,我自己的 Button 则要逐一补上 type、焦点样式、禁用语义和减少动画;Tooltip、目录和移动面板也不能只看外观正常,可访问性仍要重新检查。依赖变小了,要操心的事情却变多了。
八、主题做完一版,又被我推翻了
迁移中最明显的返工是主题。
第一版照着 Mantine 的经验,用 React Context 保存配色,用 createTheme 生成明暗主题,再在 HTML 上切换类名。这套写法能工作,但对这个博客来说太重了。
本站既不需要为不同用户提供不同皮肤,也不需要让同一页面的局部区域使用另一套主题,绝大多数颜色跟随系统偏好就够了。现代 CSS 已经有 color-scheme 和 light-dark()。我先补上稳定的 CSS 变量,然后把明暗配色交回 CSS,最后删掉那套刚写不久的 StyleX 主题。
现在的基础主题直接写成:
:root {
color-scheme: light dark;
--color-background: light-dark(#f1f1f1, #1b1b1b);
--color-surface: light-dark(#ffffff, #262626);
--color-text: light-dark(#111111, #ffffff);
--color-text-muted: light-dark(#525252, #9e9e9e);
--color-border: light-dark(#00000014, #ffffff1a);
}
:root[data-theme='light'] {
color-scheme: light;
}
:root[data-theme='dark'] {
color-scheme: dark;
}StyleX 中的设计变量再引用这些 CSS 变量。看起来绕了一层,实际分工很清楚:CSS 决定主题值和系统配色,StyleX 负责带类型的引用与原子规则组合。
最终根布局不再包裹主题组件,保存主题的 Context、切换状态和挂载后的同步逻辑都被删除。迁移前 16 个客户端文件降到 12 个,页头、主题相关组件和旧代码块组件都回到了服务端。页面仍可用 data-theme 强制明暗配色,但当前界面不再提供手动切换入口,默认跟随系统。
这里确实少了手动切换主题的功能。如果产品必须保存用户选择,React Context 或一段很小的启动脚本仍然有用,不能只为了减少客户端代码就强行删除。本站改成跟随系统,是因为这样已经够用。
九、不是所有样式都应该迁进 StyleX
迁移途中很容易产生一种洁癖:既然已经选了 StyleX,就把 .css 文件数量清零。最后我没有这么做。
9.1 Markdown 排版继续使用普通 CSS
正文来自 MDX,包含标题、段落、列表、表格、图片、引用和代码。这里最自然的规则是:
.typography p {
margin-block: 1em;
line-height: inherit;
text-wrap: pretty;
}
.typography :is(h1, h2, h3, h4, h5, h6) {
margin-top: var(--space-section);
scroll-margin-top: 4.75rem;
}
.typography :not(pre) > code {
padding: 0.125em 0.35em;
overflow-wrap: anywhere;
}StyleX 强调「元素上的样式应由元素自己的类名造成」,不鼓励依赖复杂后代选择器。可 Markdown 正文正是一个由内容生成器提供结构、外层统一控制排版的场景。为每个原生节点增加 StyleX 样式属性,只会让 MDX 映射变复杂,并不会让代码更清楚。
因此正文排版最终迁到 @vacuum/ui/style/typography.css,由文章路由加载。它不是迁移失败的残留,而是更合适的工具边界。
9.2 全局基础样式和稳定变量也保留在 CSS 中
html、body、box-sizing、字体栈、图片默认行为和滚动条规则天然是全局的。把这些规则包装成组件样式没有意义。theme-neutral.css 同时保存全局基础样式、设计变量,也是 StyleX CSS 的 @stylex 插入点。
9.3 少量属性选择器留在 CSS Modules
搜索标签依赖 aria-pressed='true',标签云依赖 data-level='1' 到 data-level='5'。它们当然也能根据条件传入 StyleX 样式,但现有 CSS 很短,状态直接映射到属性,可访问性状态与视觉状态也保持一致。
迁移完成后,CSS Modules 从 25 份降到 2 份,而不是 0 份。这个数字比「零 CSS 文件」更健康:大部分组件样式进入可组合的原子系统,真正适合选择器的部分继续使用选择器。
十、页面能打开以后,问题才一个个冒出来
等主要页面已经能打开、Mantine 组件也基本消失以后,事情还没有结束。紧接着,设计变量拆了又合,主题写完又删,共享包导错了入口,移动 Safari 的过渡也开始出问题。到收尾时,相关改动已经碰过 121 个文件,增加 3746 行、删除 3288 行。所谓「迁移完成」,显然不能只看依赖列表里还有没有 Mantine。
10.1 Shiki 差点重新进入客户端
代码组件迁入 @vacuum/ui 后,高亮函数也跟着移动。如果从共享包的默认入口导出高亮函数,客户端组件可能顺着集中导出文件找到 Shiki、Oniguruma 和语言包。
最后的修法是给高亮模块加上 server-only,从 UI 默认导出移除,改由 @vacuum/ui/server 暴露。展示代码块的组件可以共享,执行语法高亮的函数只能从服务端构建路径导入。
这不是 StyleX 专属问题,却是抽共享包时很容易出现的退步。文件从 Blog 移到 UI,看起来只是换了位置,公开入口却会改变打包工具看到的依赖关系。迁移后 JS 下降,靠的不只是换掉样式库,也包括没有让仅服务端使用的依赖混进客户端。
10.2 移动 Safari 与 View Transition
迁移后页面结构和样式类发生变化,View Transition 的快照范围也跟着变了。先是移动 Safari 截到了不该截的过渡层,临时对移动端绕过以后,文章间距、标题缩放和代码块换行又陆续露出问题。
这类问题不会出现在静态截图里。view-transition-name、粘性定位容器、溢出裁切和移动浏览器的快照行为会互相影响;桌面 Chrome 看起来正常,Safari 切页时仍可能截到一张过大的层。
最终,部分 View Transition 名称仍通过 StyleX 动态样式表达:
const styles = stylex.create({
viewTransition: (name: string) => ({ viewTransitionName: name }),
})StyleX 会把动态值放进 CSS 变量,而不是为每个文章路径生成一条静态规则。这类动态样式应该少用,但文章路径在构建前虽已知,对组件源码来说仍是参数,正适合受限动态函数。
10.3 视觉设计顺便变化,比较不能假装是纯框架实验
迁移过程中,我还顺手重做了中文排版、macOS 风格代码块、文章卡片 Pill、悬浮操作区、友链卡片和文章详情布局。所以这不是一次严格控制变量的 A/B 实验。
因此后面的结果只能回答:「从最后一个 Mantine 版本到当前完整迁移结果,构建产物发生了什么?」不能把每一 KB 都算在 StyleX 编译器头上。CSS 最大文件的消失与移除 Mantine 整包样式直接相关;JS 下降则同时来自删除主题管理组件、简化自有组件、减少客户端代码和其他性能修改。
诚实限定这个边界,比给 StyleX 记下所有功劳更重要。
十一、用构建产物回答:到底省了多少?
11.1 对比方法
迁移前的数据取自最后一个仍完整使用 Mantine 的版本,当时使用 Mantine 9.6.0;迁移后的数据取自完成全部收尾工作的当前版本。
两份代码分别检出到独立 Git Worktree,使用同一台机器:
- Apple M1 Max,32 GB 内存;
- macOS arm64;
- Node.js 24.21.0;
- pnpm 12.3.4;
- Next.js 16.3.4,Turbopack;
- 两边都执行
pnpm install --frozen-lockfile; - 两边都先构建
@vacuum/vacuum,再从没有.next和out的状态构建 Blog。
命令为:
pnpm --filter @vacuum/vacuum build
/usr/bin/time -lp pnpm --filter @vacuum/blog build第一次直接构建迁移前的 Blog 时,@vacuum/vacuum/client 和 server 尚未生成,构建因此失败,所以没有把那次时间计入结果。正式测算前,两边都先构建 @vacuum/vacuum。
体积统计读取静态导出的 out:遍历 HTML、CSS 和 JS,使用 Node.js gzipSync 计算 gzip 体积,再从每个 HTML 的 link 与 script 标签得到该路由直接引用的初始资源。表里的 KB 按 1000 字节计算,不是浏览器缓存后的重复传输量,也不包含图片、搜索 JSON 与字体等其他资源。
迁移前有 78 个 HTML 路由,当前有 79 个;Next.js 构建阶段分别生成 84 和 85 个静态页面。当前版本多了一篇内容,因此 HTML 总量与构建时间并非完全在相同条件下测得。CSS、JS 总量和相同路由的初始引用仍然可以直接比较。
11.2 CSS:这是最明确的变化
| 指标 | 迁移前 Mantine | 当前 StyleX | 变化 |
|---|---|---|---|
| CSS 文件数 | 7 | 4 | -42.9% |
| CSS 原始体积 | 271.0 KB | 42.0 KB | -84.5% |
| CSS gzip | 42.1 KB | 10.1 KB | -76.0% |
| 首页 CSS gzip | 39.9 KB | 8.2 KB | -79.4% |
| 最大 CSS 文件原始体积 | 233.6 KB | 36.1 KB | -84.5% |
| 最大 CSS 文件 gzip | 34.4 KB | 8.2 KB | -76.2% |
这就是迁移动机最直接的回答。总 CSS 原始体积只剩原来的约 15.5%,gzip 后只剩约 24%。首页少下载约 31.7 KB gzip CSS。
当前最大的 36.1 KB CSS 仍不全是 StyleX 原子规则,它还包含全局主题、基础样式与正文排版。继续追求个位数原始 KB,需要牺牲文章排版或把路由 CSS 拆得更碎,收益已经不像最初删除 233.6 KB 整包样式那么明显。
11.3 JavaScript:下降明显,但不能只归功于 StyleX
| 指标 | 迁移前 Mantine | 当前 StyleX | 变化 |
|---|---|---|---|
| JS 文件数 | 17 | 15 | -11.8% |
| JS 原始体积 | 956.0 KB | 677.1 KB | -29.2% |
| JS gzip | 301.9 KB | 220.8 KB | -26.9% |
| 首页 JS gzip | 257.4 KB | 187.8 KB | -27.0% |
| 搜索页 JS gzip | 260.9 KB | 194.1 KB | -25.6% |
StyleX 自己并非完全没有运行时代码。跨文件传递 StyleXStyles、动态样式和运行时条件仍需要合并类名。但它没有像通用组件库那样把组件实现与主题能力一起带进来。
更关键的是,根级 MantineProvider 被删除,客户端文件由 16 个降到 12 个;页头回到服务端组件,代码高亮被固定在 server-only 入口。JS 减少是更换样式方案、精简组件功能、重新整理服务端与客户端代码的共同结果。
11.4 相同路由的初始 gzip 负载
| 路由 | 迁移前 Mantine | 当前 StyleX | 减少 | 降幅 |
|---|---|---|---|---|
首页 / | 318.7 KB | 216.6 KB | 102.1 KB | 32.0% |
搜索 /search/ | 315.7 KB | 215.1 KB | 100.6 KB | 31.9% |
标签 /tag/ | 310.8 KB | 218.7 KB | 92.1 KB | 29.6% |
友链 /friend/ | 308.3 KB | 213.3 KB | 95.0 KB | 30.8% |
| 长文示例 | 486.1 KB | 372.3 KB | 113.8 KB | 23.4% |
长文示例是 /2026/micro-macro-economics/。它本身的 HTML gzip 就有 170.8 KB,正文内容与高亮标记占了很大比例,所以固定 CSS、JS 减少后,总比例不会像首页那么高。
请求数也一起下降:首页从 14 个初始 HTML/CSS/JS 请求变成 10 个,搜索页从 15 个变成 12 个,长文从 16 个变成 12 个。这不是网络总请求数,也不含后续图片加载,但能说明首屏静态依赖图更短。
全部路由的分布如下:
| 指标 | 迁移前 Mantine | 当前 StyleX | 变化 |
|---|---|---|---|
| 初始 gzip 中位数 | 342.3 KB | 237.0 KB | -30.8% |
| 初始 gzip P95 | 457.0 KB | 345.5 KB | -24.4% |
| 初始 gzip 最大值 | 486.1 KB | 372.3 KB | -23.4% |
最让我满意的不是首页这一项,而是各类路由普遍都变小了。只优化首页很容易,文章页、搜索页和友链继续背着大包并没有意义。
11.5 构建没有变快
| 指标 | 迁移前 Mantine | 当前 StyleX | 变化 |
|---|---|---|---|
| Next.js 编译 | 11.7 秒 | 12.5 秒 | +6.8% |
| TypeScript | 2.1 秒 | 2.4 秒 | +14.3% |
| 静态页面生成 | 1.848 秒 / 84 页 | 1.545 秒 / 85 页 | 不宜直接比较 |
| Blog 完整构建 | 18.83 秒 | 20.96 秒 | +11.3% |
| 最大常驻内存 | 1.63 GB | 1.59 GB | -2.6% |
StyleX 需要额外执行源码转换与 CSS 提取,当前还通过社区 SWC 插件和 PostCSS 扫描 Blog、UI 两个目录。干净构建总时长增加约 2.13 秒。一次测量会受系统调度、文件缓存与编译缓存影响,不能把小数点后的差异当成稳定基准,但至少可以确认:这次迁移没有让生产构建更快。
这个结果我可以接受。博客的发布次数远少于阅读次数,构建时多花两秒,读者每次初次打开页面则少加载约 100 KB gzip 资源,对静态站点来说很划算。若这是一个需要频繁部署的大型应用,就应该做多轮 CI 测试和增量编译测试,不能只凭一次本机构建下结论。
静态 out 目录从 61,772 KiB 降到 58,240 KiB,约减少 5.7%;.next 从 229,056 KiB 降到 227,508 KiB,约减少 0.7%。这两个目录包含大量文章 HTML、构建缓存与服务端中间产物,不适合作为样式方案的核心指标,只作为结果记录。
11.6 体积数字不等于真实用户指标,但仍然值得守住
初始 gzip 少了约 100 KB,不代表 Largest Contentful Paint(最大内容绘制,LCP)一定按同样比例缩短。真实访问还受网络往返时间、Cloudflare 节点命中、浏览器缓存、HTML 流式解析、图片尺寸、设备 CPU 与页面恢复方式影响。本站是静态导出,首屏封面图片往往比 CSS 大得多;回访用户又可能已经缓存公共文件,此时迁移带来的传输差异会进一步缩小。
反过来,也不能因为有缓存就认为公共资源体积无所谓。新访客没有缓存,资源更新后的回访也会重新请求;移动网络的带宽和丢包并不稳定,更多文件还会增加请求调度与校验。即使 gzip 后只有几十 KB,解压后的 271.0 KB CSS 仍要被浏览器解析、建立 CSSOM,并参与样式计算。原子 CSS 不保证样式计算必然更快,但删掉从未使用的选择器至少减少了输入规模。
这次报告选择「HTML 直接引用的 CSS 与 JS」作为统一口径,因为它可以离线、稳定地从构建产物中重复计算,不依赖某次网络测速。这里没有扣除浏览器共享缓存:两个路由引用同一个文件时,各自的初始加载量都会把它算进去。这种算法适合回答「第一次打开某个页面,需要加载哪些静态资源」,不能把全站所有路由的数字相加后当成用户下载量。
HTML 体积也要单独看。本站在构建时完成 Shiki 高亮,代码标记与明暗主题颜色会进入文章 HTML。长文的 HTML gzip 可能超过全部 CSS,这不是 StyleX 能解决的问题。如果以后文章页面继续变大,应该检查高亮标记、内嵌 SVG、React 服务端组件数据和重复元数据,而不是继续压缩已经只有 10.1 KB 的 CSS。
同样,JS 总 gzip 是构建目录中所有文件块去重后的总和,路由 JS gzip 则只统计该 HTML 直接引用的文件。一个仅由搜索页使用的文件会进入前者,却不会进入首页数字。总量用来观察依赖是否整体膨胀,路由量用来观察单个页面是否变重,两项都不能少。
若要把这次结果扩展成严谨的用户性能结论,还需要在部署后补充真实用户监控,至少按新访客与回访、桌面与移动、网络类型观察 LCP、Interaction to Next Paint(下一次绘制交互,INP)和页面导航耗时,再用 Lighthouse 或 WebPageTest 做受控实验。本文没有这些数据,所以只报告可复现的构建产物,不宣称 Core Web Vitals 提升了某个百分比。
不过,构建指标仍然适合提前把关。线上指标出现波动时,原因可能来自网络、内容和第三方服务,反馈很慢;体积测算可以在代码合并前指出「这次改动让首页多了 40 KB JS」。它不能替代真实用户数据,却能拦住很多根本不必上线验证的明显退步。
十二、把结果写进 CI,而不是写完文章就忘
迁移收尾时,我没有再换组件,而是新增 check-bundle-size.ts 作为体积测算脚本。它会在 CI 构建完成后统计各项体积,并检查是否超过上限:
| 测算项目 | 上限 | 当前结果 |
|---|---|---|
| CSS 总 gzip | 11.0 KB | 10.1 KB |
| JS 总 gzip | 225.0 KB | 220.8 KB |
| 首页初始 gzip | 230.0 KB | 216.6 KB |
| 所有路由初始 gzip P95 | 350.0 KB | 345.5 KB |
每次构建都会把 Markdown 和 JSON 格式的测算结果写进 GitHub Actions 摘要,同时上传为构建产物。任何一项超过上限,CI 都会失败。
上限不是越紧越好。当前 CSS 距上限只剩 0.9 KB,JS 只剩 4.2 KB,P95 只剩 4.5 KB。这些数字只是警戒线,确实增加了有价值的交互时,可以说明理由再调整;如果只是无意间重新引入了大包,就应该在合并前被发现。
这一步比本次数字更重要。没有自动测算,下一次引入一个方便的组件库、图表工具或客户端 Markdown 插件,体积还会慢慢长回来,直到某天再写一篇「最近给博客做的优化」。加上检查以后,每次改动都会顺便看看页面有没有变重。
十三、迁移之后,我怎样看 Mantine 和 StyleX
13.1 Mantine 仍然是很好的启动选择
如果今天要做一个带后台表单、弹窗、日期选择、复杂菜单和数据展示的应用,我不会因为这次迁移就排除 Mantine。成熟组件库能减少大量不是业务核心、却必须正确处理的细节。
Mantine 确实帮过这个博客:它让我快速完成 Next.js 重构,也让我在需求还不稳定时不用先设计一整套 UI。后来迁出,并不能反过来证明当初选错了。同一个工具,在项目的不同阶段可以先是正确答案,后来又不再合适。
若一开始就为了最终 10.1 KB 的 CSS 自己造所有组件,博客可能更晚才能用,甚至根本没有后续优化的对象。
13.2 StyleX 不是组件库,省下的东西要自己补
StyleX 给我的是样式语言、编译器、设计变量和组合规则。它没有提供按钮行为、Tooltip 的可访问性、抽屉面板的焦点管理,也不会替我决定 Card 应该有哪些外观。
从 Mantine 到 StyleX,与其说是「替换依赖」,不如说是「把通用组件库拆成样式编译器与自有 UI 包」。依赖体积变小,仓库源码反而变多,相关代码净增加 458 行,只看行数甚至不像一次删除。
所以,只有项目已经清楚自己需要什么时,这种迁移才比较合适。现在的博客只需要七八个基础组件、少量交互和稳定的文章结构。若需求每周都在变化,自有组件 API 就会反复重写,继续使用通用组件库反而省事。
13.3 原子 CSS 解决重复,不解决设计
StyleX 能让相同属性声明复用,不能替我决定字体大小、留白层级和暗色对比度。迁移后仍然经历了文章标题缩放、代码块间距、侧栏宽度、卡片结构等多轮调整。
体积更小也不自动等于体验更好。若焦点样式丢失、手机目录打不开、暗色正文对比不足,10 KB CSS 只是一个漂亮但无关紧要的数字。后续几十笔修复不是迁移的杂音,而是把编译成功变成可用产品的必要工作。
13.4 编译期能力会把复杂度移到工具链
浏览器少做一些工作,构建工具就得多处理一些事情。现在 Next.js 配置、Turbopack 加载器、PostCSS 扫描范围、路径别名、.stylex.ts 模块规则和 ESLint 必须互相对应。StyleX SWC 插件又是社区实现,升级时要同时核对的版本比只导入 CSS 更多。
这笔复杂度值得,是因为它集中在少数配置文件,并换来了每个页面都能复用的静态结果。但它并没有消失。以后如果 StyleX、Next.js 或 Turbopack 的接口变化,维护者就是我。
十四、组件不能只换个名字
沿着迁移顺序写下来,很容易把过程看成删除一个 Import、增加一个 stylex.create。实际动手时,每一类 Mantine 组件都逼着我重新回答一个问题:我过去需要的是组件的行为,还是只需要它最后生成的那几条 CSS?
这个问题决定了替代方式。若只看最终 DOM,把所有组件无脑换成 div,页面或许还能显示,却会丢失语义、键盘行为和可访问性;若照着原组件的全部属性复刻一遍,又会在仓库里制造一个没有文档、没有测试、也没有足够使用场景的小型 Mantine。难点不在「怎么写」,而在「哪些能力应该留下」。
14.1 布局组件:先还原 CSS,再缩小公开 API
迁移前的页面骨架大量使用 AppShell、Grid、GridCol、Flex、Group、Stack、Box 和 Center。这些组件很方便,但它们表达的是一套通用布局语言。比如 Grid 默认按照十二列思考,本站最终只有正文与侧栏两列;Group 能接受很多 Mantine 间距值,本站实际上只反复使用紧凑、默认和章节三档。
替换布局时,我先把每个响应式属性展开成实际的 CSS 规则,而不是立刻设计新组件。旧版主内容列是 base: 12, md: 8, xl: 9,侧栏是 base: 12, md: 4, xl: 3。换成普通 Grid 后,可以直接表达意图:手机只有一列,中等屏幕是 1fr 2fr,宽屏是 1fr 3fr;正文排在第二列,侧栏排在第一列并使用粘性定位。
const styles = stylex.create({
layout: {
display: 'grid',
gridTemplateColumns: {
default: 'minmax(0, 1fr)',
[breakpoints.md]: 'minmax(0, 1fr) minmax(0, 2fr)',
[breakpoints.xl]: 'minmax(0, 1fr) minmax(0, 3fr)',
},
gap: space.default,
},
})两种写法视觉上接近,后一种却把「为什么是这些列宽」留在代码里。十二列系统适合布局不断变化的后台页面;只有一种结构的博客没必要先把比例翻译成 Span,再由组件翻译回 CSS Grid。
Stack 和 Group 则值得保留,因为它们在几十个地方重复出现,并且有稳定语义。但新组件不接受任意数字和所有对齐组合,只提供项目确实使用的三档间距、四种对齐方式和四种分布方式。以后如果出现第四种间距,应该先判断设计变量是否真的缺一档,而不是随手传入 13px。
Box 没有进入共享包。它最常见的用途只是修改根元素或加一条样式,原生 div、section、article 与 span 已经足够。少一个万能容器,反而会促使调用处选择更准确的 HTML 元素。
布局迁移还有一个容易遗漏的细节:min-width: 0。Grid 与 Flex 子项的默认最小宽度可能由内容决定,长代码、长 URL 或表格会把正文列撑开。Mantine 时代有些规则藏在组件样式或 miw="0" 属性中,换成自有组件后必须明确写回正确层级。页面在普通文章上正常,不代表包含长代码的文章不会横向溢出。
14.2 文本组件:视觉属性不应吞掉 HTML 语义
Mantine 的 Text、Title、Anchor 很适合用统一属性控制颜色、字号和截断。迁移时最省事的做法,是写一个新的 Text,继续提供 size、c、lineClamp 和 component。我没有这么做,因为博客中的文本种类其实已经固定。
文章摘要就是 p,日期和字数是 span,文章标题链接是 a,页面标题是对应层级的 h1 或 h2。它们的外观可以共用设计变量,HTML 语义不必再由 component 属性间接决定。直接写原生元素还减少了一层多态类型:不必计算当 as="a" 时允许哪些属性,也不用处理组件自有 style 与原生 style 的名字冲突。
这并不意味着所有文本组件都没有价值。若一个产品需要十几种排版样式、不同语言的字体策略和统一的截断行为,抽出基础文本组件会更合适。本站正文大部分由 .typography 控制,卡片文本只有少数几种,把它们留在使用处更容易阅读。
链接则保留了 TextLink、NavLink 与 CardLink,因为它们除了颜色还有导航行为差异。站内链接需要接入 Next.js Link 和 View Transition,卡片链接要处理整块可点击区域,正文外链需要 target、rel 和视觉提示。这里复用的不是一条 text-decoration,而是一组稳定行为,所以值得成为组件。
迁移过程中我也重新检查了嵌套交互元素。卡片本身不能简单包成一个巨大的链接,因为内部还有分类入口;a 里再放 a 是无效结构,屏幕阅读器与键盘导航也会混乱。最终图片与标题分别链接文章,分类 Badge 保留自己的链接。少了 Mantine 并不会自动得到正确语义,这些决策仍要在 JSX 结构里完成。
标题层级也是同样的问题。视觉上更大的字不一定是 h1,组件不能为了复用样式随意改变文档的标题结构。MDX 标题继续由 H(level) 映射,StyleX 或普通 CSS 只负责外观。样式应该服从内容结构,而不是反过来用外观决定语义。
14.3 Card、Badge 与 Button:只留下本站能承诺维护的变体
基础组件最容易越写越大。第一次写 Card 时会有 padding,第二次需要 shadow,接着加入 radius、border、withBorder、component、section、interactive,不久就又长成一套通用 API。
当前 @vacuum/ui/card 只做了三个选择。第一,根元素可以通过 as 指定,保证文章卡片能直接使用 article,但没有照搬 Mantine 完整的多态类型系统。第二,内边距只有 none、sm、md、lg 四档,并映射到设计变量。第三,外观只有 elevated、outlined、subtle 三种,调用者不能临时发明第四种。
CardSection 独立存在,是因为图片区域经常需要突破 Card 的内边距,并在上下边缘增加分隔线。把这个能力做成明确子组件,比支持任意负 Margin 更容易维护。
Badge 与 Pill 看起来相近,我也没有强行合并。Badge 表达分类、状态或元数据,有不同尺寸和外观;Pill 更像一段紧凑标签,样式和交互范围不同。如果为了减少文件数量,做出一个拥有十几个布尔属性的万能圆角组件,最终每个调用处仍要猜这些属性组合起来是什么效果。
Button 的风险更高,因为它是交互控件。迁移时至少要保留以下行为:默认 type="button",避免放进表单后意外提交;禁用时同时处理原生 disabled 与视觉状态;键盘 :focus-visible 不能被悬停样式覆盖;图标按钮必须有可读的 aria-label;仅在支持悬停的设备上启用相应反馈;用户要求减少动画时关闭过渡效果。
对于移动目录,按钮还要设置 aria-controls 和 aria-expanded,面板打开后,背景中的按钮不能继续出现在键盘切换顺序里。遮罩层虽然看起来只是一层半透明背景,也应该使用能响应键盘的元素,或明确调整 tabIndex。这些都是 Mantine 过去部分代劳、迁移后必须自己负责的事情。
Tooltip 是我刻意没有抽进通用 UI 包的例子。本站只有文章时效提示使用它,组件直接围绕这一场景实现 role="tooltip"、aria-describedby、悬停与焦点状态。等出现第二个不同的 Tooltip 场景,再抽出定位、延迟和方向等通用能力,比提前复制完整的浮层系统更稳妥。
14.4 动态样式:先判断有限状态,最后才使用函数
从 Mantine 组件属性迁移时,很多值看起来都是动态的:卡片能否交互、按钮使用哪种外观、目录项是否激活、进度条宽度是多少、页面过渡名称来自哪篇文章。它们不应该全部写成 StyleX 动态函数。
外观、尺寸和布尔状态通常只有有限几种,最适合预先定义好静态样式,再按条件组合:
<button
{...stylex.props(
styles.root,
size === 'sm' && styles.sizeSm,
variant === 'secondary' && styles.secondary,
disabled && styles.disabled,
style,
)}
/>这样每种属性值在构建期已知,生成的原子规则能在所有组件实例之间复用。不要因为对象函数写起来方便,就把 padding、fontSize 和颜色都变成 CSS 变量。动态样式越多,编译器越难消除运行时代码,浏览器也会看到更多元素级内联变量。
进度百分比与 View Transition 名称不同,它们的取值由文章日期或路径决定,静态枚举没有意义。这时才使用:
width: (value: number) => ({ width: `${value}%` })StyleX 对函数形状有意施加限制:参数必须是简单标识符,函数体直接返回对象,不能在里面先执行几步计算再返回。复杂计算应在调用前完成,样式函数只负责把最终值绑定到 CSS 属性。这能保持提取结果可预测,也让业务计算与视觉映射分开。
响应式值不属于运行时动态。屏幕宽度由媒体查询解决,不需要在 React 中监听 window.innerWidth 再切换样式。迁移过程中凡是能写成 { default, [breakpoints.md] } 的规则,都留在 CSS。这样服务端输出不必猜客户端宽度,也不会在页面水合后突然换布局。
悬停样式同样要区分设备能力。触摸屏浏览器可能模拟悬停,造成点一下后样式一直保留。当前的悬停规则通常放进 @media (hover: hover),按下和 focus-visible 状态则单独处理。StyleX 支持在属性值里嵌套伪类和媒体条件,但 default 分支必须写清楚;不需要默认值时写 null,不能省略后让覆盖关系变得难以判断。
14.5 从 CSS 选择器迁移时,不能机械复制优先级
传统 CSS Module 中常见这样的规则:基础类设置背景,悬停时改一次,激活时再改一次,激活加悬停又补第四次。最后的结果由选择器优先级和声明顺序共同决定。
StyleX 的属性级原子规则有另一套组合模型。同一属性由最后应用的 StyleX 样式决定,简写与长写还会按照确定性规则解决。迁移时如果仍然想着「把选择器写得更具体」,往往会得到冲突属性或无法表达的后代关系。
我的处理方式是先列状态表。例如搜索标签只有普通、悬停、选中、选中加悬停、键盘焦点和减少动画六种状态。普通与选中各自有一组 StyleX 样式,悬停与焦点规则写在对应属性里;如果状态直接来自 aria-pressed,保留一小份 CSS Module 反而更直观。工具应该服务于实际状态,而不是为了提高 StyleX 的使用比例强行改写 DOM。
从 Module 迁移还要检查简写。CSS 里先写 padding 再覆盖 padding-inline 很常见,StyleX 的 valid-shorthands 和 no-conflicting-props 会要求更清楚的表达。与其关闭规则,不如把四个方向拆开,或者从一开始全部使用逻辑属性 paddingBlock、paddingInline。本站需要中文横排,但使用逻辑属性仍能减少对左右方向的硬编码。
选择器作用范围变化也可能影响页面过渡、粘性定位和溢出裁切。旧 CSS Module 的类可能挂在 Mantine 生成的中间节点上;删除组件后 DOM 少了一层,原来的 height: 100%、绝对定位包含块或裁切对象都会变化。不能只把类搬到最近的元素,就假设布局仍然等价。在浏览器开发工具里查看包含块、滚动容器和实际的网格子项,比对照源码猜测更快。
14.6 排查「有类名但没有样式」的固定顺序
StyleX 集成出错时,最常见的现象不是编译失败,而是元素已经出现一串哈希类名,页面却没有对应样式。我最后形成了一套固定排查顺序。
先看 StyleX 声明是否满足静态分析。普通模块导入的字符串、对象展开、函数调用和不合法动态函数都可能阻止提取。ESLint 能覆盖一部分,生产构建仍是最终判断。
再看 Turbopack 是否转换了这个源文件。Blog 自己的文件通常没有问题,Monorepo 中 packages/ui 的源码则依赖路径别名、包导出配置和加载规则。如果组件代码没有转换,浏览器可能拿到本不该出现的 StyleX 对象。
第三步看 PostCSS include 是否覆盖该路径。转换 JavaScript 与提取 CSS 是两条相关但不同的链路,前者成功不代表 @stylex 已经包含共享包规则。新增目录或移动组件后,应同时检查扫描范围。
第四步核对两边的 rsOptions,尤其是路径别名与 unstable_moduleResolution。如果设计变量在转换阶段解析到一个路径,在 PostCSS 阶段又解析到另一个路径,就可能生成不一致的变量引用。给每个设计变量子路径明确写出别名虽然有些啰嗦,却比让两个工具各自猜测 Monorepo 的包导出关系更可靠。
最后才看 CSS 层叠和加载顺序。StyleX 能确定自身原子规则之间的覆盖,不会阻止普通 CSS 覆盖它。全局 CSS、正文路由 CSS、遗留 Module 和 StyleX 仍然处在浏览器同一套 Cascade 中。如果某条未分层规则有更高具体度,换框架不会让它自动消失。
开发模式修好后还要跑生产构建。Turbopack 开发服务可能保留旧模块,或以不同顺序增量更新;只有静态导出才会完整经过 CSS 提取、文件拆分和压缩。反过来也一样:生产能构建不代表快速刷新一定可靠。共享源码包加入后,MDX 的热更新就坏过一次,修好构建并不等于这条路径也自动恢复了。
14.7 如何判断自己的项目值不值得迁?
如果读到这里也在嫌组件库太大,我不建议直接照抄这次结论。先用构建产物回答几个问题。
第一,体积主要来自哪里?如果最大的是图片、字体、编辑器或图表运行时代码,替换 CSS 系统只是在最小的一栏里精打细算。本站是因为构建产物中明确摆着一个 233.6 KB 的 CSS 文件,才值得继续追。
第二,组件库的复杂组件使用比例有多高?一个只用 Box、Text、Stack、Card 的站点,自有替代品相对可控;一个重度使用日期选择、Combobox、Modal、通知系统与复杂表单的产品,迁移后要承担的行为远不止样式。逐组件 CSS 导入或保留现状通常更划算。
第三,团队是否愿意自己维护这套界面?删掉依赖并不会让需求消失。按钮状态、颜色变量、响应式规则、可访问性与文档总要有人维护。个人博客可以由一个人全包,大团队则需要明确由谁负责,以及怎样测试和发布。
第四,构建过程是否容得下额外的编译步骤?StyleX 在本站需要 SWC 加载器、PostCSS、路径别名与源码扫描。若框架没有稳定的接入方案,或者团队正在频繁升级打包工具,后续维护可能比省下来的传输体积更让人头疼。
第五,页面访问次数与构建次数是什么关系?静态博客构建一次、阅读很多次,适合在构建时多做一点工作;本地工具每天构建数百次、只在内网访问,构建速度和开发反馈可能更重要。不能只看浏览器少做了什么,也要看这些工作被挪到了哪里。
最后才是语法偏好。Tailwind、CSS Modules、vanilla-extract、StyleX 或继续使用 Mantine,都能做出体积合理的页面。最危险的不是选了某一个,而是在没有测量、没有边界的情况下,把一次大规模迁移当成自动优化。
对本站而言,这五个问题的答案刚好都指向迁移:那块大 CSS 很容易找到,复杂组件也不多,我愿意维护一小套 UI,Turbopack 已经有可用的接入方案,而且静态构建慢一点不会影响读者。换一个项目,我完全可能继续使用 Mantine。
十五、如果重新做一次,我会怎样安排?
现在回头看,我仍会迁,但会把顺序收紧一些。
第一,先把体积测算脚本写好,再开始迁移。这次直到收尾才加入脚本,前面主要靠观察 .next 和 out。如果一开始就自动记录基准数据,就能更清楚地看出接入 StyleX、删除 Mantine 的主题管理、抽出 UI 包、移除主题状态分别带来了多少变化。
第二,主题先用 CSS 原生能力,不要急着照搬 Mantine。第一版很自然地用了 createTheme + Context + localStorage,结果连旧系统的结构也一起复制了过来。对于只需跟随系统的站点,一开始就用 color-scheme + light-dark() 会简单得多。
第三,提前划定普通 CSS 的保留区。正文排版、全局基础样式和属性选择器本就不必迁移。如果一开始说清哪些地方继续用普通 CSS,就可以少做一些「为了统一而统一」的转换。
第四,设计 UI 包时就把服务端入口一起想好。把代码块组件迁到共享包时,就应该同时决定展示组件和 Shiki 高亮函数分别从哪里导出,而不是等打包出问题后再补 server-only。
第五,视觉重设计与基础设施迁移尽量分开。这次把卡片、代码块、悬浮按钮和中文排版一起调整了,成品更好看,但回归定位和性能归因都更困难。若是多人协作,我会先做到像素与行为尽量等价,再单独处理设计变化。
不过,个人项目很难完全按实验室方式推进。正因为迁移过程中看见了新的布局可能性,我才会顺手继续改。只要最后诚实记录测量边界,不把混合结果包装成单变量结论,这种路径也可以接受。
十六、CSS 小了,边界也窄了
这次迁移最显眼的结果是 CSS gzip 从 42.1 KB 降到 10.1 KB,首页初始 gzip 从 318.7 KB 降到 216.6 KB。它们证明最初的动机不是错觉:对于这个博客,Mantine 的通用样式确实太大了。
但如果只记住「StyleX 能省 76% CSS」,结论仍然太浅。这个比例依赖本站原来整包导入 Mantine Core、当前组件数量有限、视觉系统高度定制,以及迁移同时缩小了客户端边界。换一个已经按需导入、重度使用复杂组件的项目,结果很可能完全不同。
数字之外,项目里的边界也清楚了:
- 全局基础样式、主题值和正文排版留在 CSS;
- 组件局部样式由 StyleX 编译成原子规则;
- 设计 Token 通过
.stylex.ts模块共享; - 常用结构收进小而明确的
@vacuum/ui; - 只有交互需要客户端,Shiki 等构建能力留在服务端入口;
- 构建后由体积测算脚本检查 CSS、JS 和各路由的初始加载量。
与此同时,我也多了几件要长期维护的事:自有组件、社区版 StyleX SWC 集成,以及升级 Next.js 时必须一并检查的 PostCSS 提取和路径别名。干净构建也慢了约 11%。
Mantine 给了我快速抵达的路,StyleX 则适合现在这个已经知道自己要去哪的博客。迁移不是对前一个选择的否定,而是把项目从「借一套完整工具尽快建起来」,推进到「只保留真正需要的那一小套能力」。
这也回应了博客副标题那句话:完美,并非无以复加,而是无可删减。不过你们应该找不到这个副标题在那里 ^_^
至少在 CSS 这件事上,现在确实删掉了不少。
