前几天 我将 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} 不存在`);
};

我依次尝试了一些改动,发现都没解决...

  1. 重命名文件(styles.module.less -> photo-collection.module.less
  2. 修改文件内容(.collection 下样式只写一条 background 规则)
  3. 将引入方式调整成 ifelse 的形式

generateScopedName 解决方案

只好求助牛逼的 Claude Opus 4.6,初步排查原因是开发模式和生产模式下生产的 CSS Hash 不一致。


找到根本原因了!

  • 客户端 JS bundle 用的哈希:_collection_f6c82_1
  • 服务端 SSR bundle 用的哈希:_collection_x8r5c_1
  • CSS 文件 里的规则:._collection_f6c82_1(和客户端一致,和服务端不一致)

这就是问题所在:CSS Module 的类名哈希在 SSR 构建和客户端构建之间不一致。

具体表现:

  1. 服务端渲染 HTML 时,生成的 class_collection_x8r5c_1
  2. 浏览器加载的 CSS 文件里只有 ._collection_f6c82_1 的规则
  3. 两者对不上,样式完全不生效

这与 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-modulesfrom 路径不一致 → 哈希不同

这是 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 计算

你说得对——generateScopedNamefilename 参数在两次构建里看上去是一样的。这是因为 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 打包结果
PhotoCollection1 个页面([[slug]])→ 打进该页面专属的 独立 chunk
Pager5 个页面→ 被提取到 shared chunk
ThemePanellayout(全局)→ 被提取到 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 整体优化重写,拥抱最新技术吧!