惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Google DeepMind News
Google DeepMind News
Stack Overflow Blog
Stack Overflow Blog
Hugging Face - Blog
Hugging Face - Blog
博客园_首页
T
The Blog of Author Tim Ferriss
博客园 - 叶小钗
N
Netflix TechBlog - Medium
腾讯CDC
C
Check Point Blog
P
Proofpoint News Feed
Engineering at Meta
Engineering at Meta
GbyAI
GbyAI
S
SegmentFault 最新的问题
F
Fortinet All Blogs
美团技术团队
U
Unit 42
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
博客园 - 司徒正美
F
Full Disclosure
Recorded Future
Recorded Future
D
DataBreaches.Net
博客园 - 【当耐特】
Martin Fowler
Martin Fowler
J
Java Code Geeks
I
InfoQ
Y
Y Combinator Blog
A
About on SuperTechFans
AI
AI
爱范儿
爱范儿
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
Forbes - Security
Forbes - Security
W
WeLiveSecurity
M
MIT News - Artificial intelligence
雷峰网
雷峰网
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
Simon Willison's Weblog
Simon Willison's Weblog
Schneier on Security
Schneier on Security
The GitHub Blog
The GitHub Blog
Security Archives - TechRepublic
Security Archives - TechRepublic
aimingoo的专栏
aimingoo的专栏
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
G
GRAHAM CLULEY
Know Your Adversary
Know Your Adversary
Latest news
Latest news
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
D
Docker
Recent Commits to openclaw:main
Recent Commits to openclaw:main
量子位
V2EX - 技术
V2EX - 技术
Project Zero
Project Zero

保罗的小宇宙

奇趣影棚项目 Vibe Coding 第一天问题记录 失业后的一次前端面试经历 威联通 NAS 使用 CloudFlared 远程访问 记一次被街头诈骗的经历 记一次移动端 Safari 调试踩坑 记一次装机没有一次点亮的排查过程 2024 年终总结 将 MO3 音乐导出成 WAV/MP3/OGG 等格式 该写好代码吗?写好了也可能变得不好了 排查了一个导致页面白屏的问题 从零开始部署 Ubuntu 服务器环境 使用 Docker 自动化部署的 NextJS 镜像大小优化
记一次升级 Nuxt 4 的诡异问题
2026-03-07 · via 保罗的小宇宙

前几天 我将 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 整体优化重写,拥抱最新技术吧!