前几天 我将 Nuxt 从 3 升级到了 4,升级之后才发现有一个页面出现了异常,CSS 样式出现丢失。
出现问题的 CSS 文件是 styles.module.less,它是一个 CSS Modules 形式的 Less 文件。对应组件的引入方式有些特别,是根据文章内容来渲染的。
// 组件映射表
const componentMap: Record<string, Component> = {
"photo-collection": PhotoCollection,
};
// 渲染组件的函数
const renderComponent = (componentName: string, props: any) => {
const Component = componentMap[componentName];
if (Component) {
return h(Component, props);
}
// 如果组件不存在,返回错误提示
return h("blockquote", {}, `组件 ${componentName} 不存在`);
};我依次尝试了一些改动,发现都没解决...
- 重命名文件(
styles.module.less -> photo-collection.module.less) - 修改文件内容(
.collection下样式只写一条background规则) - 将引入方式调整成
if和else的形式
generateScopedName 解决方案
只好求助牛逼的 Claude Opus 4.6,初步排查原因是开发模式和生产模式下生产的 CSS Hash 不一致。
找到根本原因了!
- 客户端 JS bundle 用的哈希:
_collection_f6c82_1 - 服务端 SSR bundle 用的哈希:
_collection_x8r5c_1 - CSS 文件 里的规则:
._collection_f6c82_1(和客户端一致,和服务端不一致)
这就是问题所在:CSS Module 的类名哈希在 SSR 构建和客户端构建之间不一致。
具体表现:
- 服务端渲染 HTML 时,生成的
class是_collection_x8r5c_1 - 浏览器加载的 CSS 文件里只有
._collection_f6c82_1的规则 - 两者对不上,样式完全不生效
这与 componentMap 的写法无关,所有前面的方案当然也都没用——核心问题是 Vite 在 SSR 和 client 两次构建中,传给 CSS Modules 的 id(文件路径)不一致,导致哈希不同。
修复方案是在 nuxt.config.ts 里显式配置 generateScopedName,让两次构建使用同样的规则生成类名,消除哈希不一致问题:
export default defineNuxtConfig({
vite: {
css: {
modules: {
generateScopedName(name: string, filename: string) {
const normalizedPath = filename
.replace(/\?.*$/, "")
.replace(/\\/g, "/");
const relativePath = path.relative(process.cwd(), normalizedPath).replace(/\\/g, "/");
const hash = crypto
.createHash("md5")
.update(relativePath)
.digest("hex")
.slice(0, 5);
return `_${name}_${hash}`;
},
},
},
},
});总感觉这是一个很奇怪的修复方式,理论上 Vite 自己的配置都是经过验证的,自己魔改的配置越多,后续的升级只会更容易出现问题,想着能不能从其他方面再继续分析下其他的解决方法。
修改后缀名方案
我把这件事告诉给了 @Innei,先被他 PUA 了一遍
怎么还在用这么古老的技术栈
骂归骂,能有些解决问题的思路也好。他说他现在使用的“不古老”的方式是 vanilla-extract 这个库,看上去是类似 CSS in JS 的那种编写方式,但是实际背后处理返回的是一个 CSS 文件,看上去亮点主要是 TypeScript 友好。
用 Less 有啥优势
我说没有啥优势,只是好过全局 CSS 而已(因为是 CSS Modules 形式),当时没转过来指的是 Less 本身。
选择 Less 主要是主题色盘的色值可以直接用函数生成,而不是自己算好再写上去。并且它有 CSS 嵌套功能,从小窝后台一直用了好几年,也没啥问题就一直在用。
当然现在这些特性其实原生都有支持了,颜色计算有 color-mix,嵌套兼容性还不够好但也能用(Chrome 要求 120 版,iOS Safari 要求 17.2 的系统),继续沿用也只是为了保持兼容性而已。
我尝试直接将文件的后缀从 .less 改成 .css,居然也是解决的。Innei 看到我的惊喜若狂(卧槽,去掉还真好了,什么原理啊),什么话都没说。
为什么,什么原理
其实到这里就已经找到了最合适的方案了,因为简单直接还不需要改配置。我其实也好奇这两个修改方式为什么有效,也让 AI 替我分析了一下:
修改后缀名的原理
Vite 处理 CSS Modules 时,哈希是根据传给 postcss-modules 的 from(即文件路径)计算的。
.module.css(纯 CSS)的处理流程:
style.module.css → postcss-modules(from = 原始文件路径)→ 哈希路径简单直接,SSR 和 client 两次构建完全一致 → 哈希相同
.module.less(LESS 预处理器)的处理流程:
style.module.less → LESS 编译器 → 中间产物(id 可能附带 ?used / ?inline 等 query)
↓
postcss-modules(from = 中间产物的路径)→ 哈希Client build 和 SSR build 内部对中间产物的 id 处理方式不同,传给 postcss-modules 的 from 路径不一致 → 哈希不同
这是 Vite 在处理 CSS 预处理器(LESS/SCSS/Stylus)与 CSS Modules 组合时的一个长期存在的 SSR 一致性问题,在 LESS 这里表现得尤为明显。
你用 .css 替换 .less 已经是最干净彻底的修复方式了,不需要任何额外配置。我之前加的 generateScopedName 现在可以去掉,因为它只是用来绕过这个问题的。
这里我单独重命名更新了那个出问题的 .module.less 文件后,其实并没有彻底解决问题。
generateScopedName 的原理
奇怪的是,第一种方法,那怕采用最直接的 文件名 + 路径 生成 Hash,也是正常的,根本不像 AI 说的那样,是文件路径的问题。
export default defineNuxtConfig({
vite: {
css: {
modules: {
generateScopedName(name: string, filename: string, css: string) {
const hash = crypto
.createHash("md5")
.update(filename)
.digest("hex")
.slice(0, 5);
return `_${name}_${hash}`;
},
},
},
},
});我继续追问它,为什么简化了这个处理函数,也能解决 CSS 的异常:
真相:问题不在 filename,在 Vite 内部的 hash 计算
你说得对——generateScopedName 的 filename 参数在两次构建里看上去是一样的。这是因为 Vite 在把 filename 传给用户定义的 generateScopedName 之前,已经做了 clean url 处理(去掉 query 参数)。
但 Vite 自己内部不走这个用户函数。它有一套独立的 hash 计算逻辑,用的是原始的 Vite module ID(可能带 query 参数)。
用简图表示:
.module.less(SSR build) → id = "/path/style.module.less?used" → hash = x8r5c
.module.less(client build)→ id = "/path/style.module.less" → hash = f6c82
.module.css(SSR build) → id = "/path/style.module.css" → hash = abc12
.module.css(client build)→ id = "/path/style.module.css" → hash = abc12 ✅LESS/SCSS 等预处理器文件在 SSR 构建过程中会被打上 ?used 这类内部标记(Vite 用它区分 SSR 上下文里"已使用"的 CSS),导致原始 id 和 client build 不一样。纯 CSS 文件不走预处理器流程,没有这个标记,id 始终一致。
用户定义 generateScopedName 之所以能修复问题,不是因为 replace 做了什么归一化,而是因为只要你定义了这个函数,Vite 就把 hash 计算权交给你,并传入 cleaned filename。你写 filename + name 直接 hash 就够了,两次构建拿到的是同一个 cleaned filename,自然一致。
所以结论是
| 方案 | 原因 |
|---|---|
| 换成 .module.css | 绕开了预处理器流程,Vite 不会附加 ?used 标记 |
| 加 generateScopedName | 接管了 hash 计算,Vite 给你的是 cleaned filename |
你换 .css 是最干净的解法,不引入任何额外配置。如果项目里还有其他用 .module.less/.module.scss 的组件,遇到同样问题时,加一个简单的 generateScopedName 也能解决,不需要任何 replace,直接用 filename + name 哈希就够了。
这个答案我也不确定是否正确,我就将自己的“所见所得”告诉 AI,因为我发现其他的 .module.less 并没有遇到这个问题。其实这里误导了 AI,后面的内容就当看看笑话吧。
因为那些没有问题的.module.less其实都是走的 CSR 渲染,用了<ClientOnly>组件包裹,一个页面通过路由方式进入的都是 CSR,必须刷新才是 SSR,而我验证其他组件发现没问题,都是因为走了 CSR 模式
这时候 AI 给我的“猜测”就开始有偏差了,说是什么代码分割的问题,还说是因为我这个组件只有一个页面在用,实际我也验证过了和这个根本没关系,一个组件被多个页面使用也是一样的问题(地址栏直接输入对应 URL,走的是 SSR 渲染)
关键差异:代码分割(Code Splitting)
看引用次数:
| 组件 | 被引用的页面数 | Vite 打包结果 |
|---|---|---|
| PhotoCollection | 1 个页面([[slug]]) | → 打进该页面专属的 独立 chunk |
| Pager | 5 个页面 | → 被提取到 shared chunk |
| ThemePanel | layout(全局) | → 被提取到 shared chunk |
| Modal、Notice 等 | 多个页面 | → shared chunk |
Vite 发现 PhotoCollection 只有一个页面用,就把它和它的 CSS 打进了 [[slug]] 页面专属的 code-split chunk 里。
问题就出在这里:在 Vite 的 SSR 构建中,对于这种"只属于某一个页面"的 code-split chunk,模块 ID 的生成方式和 client build 不一致(SSR build 会附加额外标记来防止 CSS 被注入到所有页面),导致 postcss-modules 拿到不同的 hash 输入,输出不同的类名。
Shared chunk 里的组件因为 ID 在两次构建中是稳定一致的,所以没有这个问题。
PhotoCollection(独立 chunk):
SSR build: id = "/path/photo-collection.module.less?used" → x8r5c ❌
client build: id = "/path/photo-collection.module.less" → f6c82
Pager(shared chunk):
SSR build: id = "/path/pager.module.less" → a1b2c ✅
client build: id = "/path/pager.module.less" → a1b2c如果你把 PhotoCollection 改成也在两个以上的页面引用,Vite 就会把它提取进 shared chunk,.module.less 的问题自然也就消失了。 所以这个问题的触发条件非常精确:.module.less + 只被单个页面引用。
这个推理也是错误的,我继续追问它另外一个组件的 .module.less 没问题,其实也是有问题的(因为是 CSR),最后它也没办法了。
总结
如果想要彻底修复这个 Bug,需要将所有的 .module.less 都改成 .module.css 才行。它并不是其中一个文件才会引发的。
要么就是按照上面的方案修改 generateScopedName 配置,看来这个的确是 Vite 内部和 Less 衔接导致的异常,只是我一直没找到具体的证据。
当然也如 @Innei 所言,Less 现在确实被淘汰了,Antd 也早就迁移变成了自己的 CSS in JS 实现,我还是让 AI 整体优化重写,拥抱最新技术吧!
没有评论