在 monorepo 组件库开发中,我们遇到了 class 明明挂在了 DOM 上,样式却完全不生效的诡异问题。排查过程中深入了 Tailwind CSS v4 的核心机制,形成此文。
一、问题现场
项目 vtable-guild 是一个基于 Vue 3 + Tailwind CSS v4 的 monorepo 表格组件库,使用 pnpm workspace 管理包结构:
vtable-guild/
├── packages/
│ ├── core/ # useTheme composable、插件
│ ├── theme/ # 默认主题定义 + CSS token
│ └── table/ # 表格组件
├── playground/ # 开发调试用的 Vite 应用
└── package.json
主题包 @vtable-guild/theme 中的 table.ts 定义了表格组件的默认样式:
// packages/theme/src/table.ts
export const tableTheme = {
slots: {
root: 'w-full',
table: 'w-full border-collapse text-sm text-on-surface',
tr: 'border-b border-default transition-colors',
th: 'px-4 py-3 text-left font-medium text-muted',
td: 'px-4 py-3',
// ...
},
variants: {
striped: { true: { tr: 'even:bg-elevated/50' } },
hoverable: { true: { tr: 'hover:bg-surface-hover' } },
bordered: { true: { table: 'border border-default', th: 'border border-default', td: 'border border-default' } },
},
// ...
} as const satisfies ThemeConfig
在 playground 中使用 useTheme composable 消费这些样式,然后绑定到模板:
<!-- playground/src/App.vue -->
<script setup lang="ts">
import { useTheme } from '@vtable-guild/core'
import { tableTheme } from '@vtable-guild/theme'
const props = {
size: 'md' as const,
bordered: false,
striped: true,
hoverable: true,
ui: { th: 'text-primary' },
class: 'my-8 rounded-lg overflow-hidden',
}
const { slots } = useTheme('table', tableTheme, props)
</script>
<template>
<div :class="slots.root()">
<table :class="slots.table()">
<thead>
<tr :class="slots.tr()">
<th v-for="col in columns" :key="col" :class="slots.th()">{{ col }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in data" :key="row.email" :class="slots.tr()">
<td :class="slots.td()">{{ row.name }}</td>
<td :class="slots.td()">{{ row.email }}</td>
<td :class="slots.td()">{{ row.role }}</td>
</tr>
</tbody>
</table>
</div>
</template>
运行 pnpm playground,打开浏览器——border 没有、hover 变色没有、隔行变色也没有。
表格倒是渲染出来了,文字内容都正常显示,只是看起来光秃秃的,完全没有任何 Tailwind 样式效果。
二、排查过程
第一步:确认 class 是否正确挂载
打开 DevTools 的 Elements 面板,检查 <tr> 元素:
<tr class="border-b border-default transition-colors even:bg-elevated/50 hover:bg-surface-hover">
class 确实在 DOM 上,说明 JavaScript 运行时的主题合并逻辑是正确的。
问题出在 CSS 侧——这些 class 对应的 CSS 规则根本没有被生成。
第二步:检查生成的 CSS
在 DevTools 的 Console 中执行脚本,提取 @layer utilities 中实际生成的工具类:
// 提取所有 Tailwind 生成的工具类名
const utilityRules = [...document.styleSheets]
.flatMap(s => { try { return [...s.cssRules] } catch { return [] } })
.filter(r => r instanceof CSSLayerBlockRule && r.name === 'utilities')
.flatMap(r => [...r.cssRules])
.map(r => r.selectorText)
结果只有 24 个工具类,全部是 playground 自身源码中直接出现的 class:
✅ .my-8, .mt-2, .mb-4, .min-h-screen, .rounded-lg, .overflow-hidden
✅ .bg-surface, .bg-elevated, .p-4, .p-8
✅ .text-2xl, .text-xs, .text-primary, .text-on-surface, .text-muted
✅ .font-bold, .uppercase, .tracking-wider, .cursor-pointer
而来自 @vtable-guild/theme 的工具类全部缺失:
❌ .border-b, .border-default, .border-collapse
❌ .transition-colors, .text-left, .font-medium
❌ .w-full, .px-4, .py-3, .text-sm
❌ hover:bg-surface-hover, even:bg-elevated/50
第三步:发现规律
| class | 定义位置 | 生成 CSS |
|---|---|---|
text-primary | App.vue 的 ui: { th: 'text-primary' } | ✅ |
uppercase | main.ts 的 slots: { th: 'uppercase tracking-wider' } | ✅ |
bg-surface | App.vue 模板中的 class="bg-surface" | ✅ |
border-b | 仅在 packages/theme/src/table.ts 中 | ❌ |
hover:bg-surface-hover | 仅在 packages/theme/src/table.ts 中 | ❌ |
规律非常明显:只有 playground 自身源码(src/ 目录)中出现的 class 才会生成 CSS 规则。定义在 workspace 子包中的 class 字符串全部被忽略。
这就引出了 Tailwind CSS v4 最核心的机制——内容扫描(Content Detection) 。
三、Tailwind CSS v4 架构总览
在深入内容扫描之前,先整体了解 v4 的架构。
3.1 一切从 @import "tailwindcss" 开始
在 v4 中,整个框架的入口就是一行 CSS:
/* playground/src/main.css */
@import 'tailwindcss';
@import '@vtable-guild/theme/css';
这行 @import 'tailwindcss' 实际上展开为 四层 CSS @layer:
@layer theme, base, components, utilities;
@layer theme {
/* Tailwind 的设计 token:颜色、间距、字体等 */
:root {
--color-red-500: oklch(0.637 0.237 25.331);
--spacing: 0.25rem;
--font-sans: ui-sans-serif, system-ui, sans-serif;
/* ... 数百个 CSS 变量 */
}
}
@layer base {
/* Preflight 重置 + 基础样式 */
*, ::before, ::after { box-sizing: border-box; }
body { margin: 0; font-family: var(--font-sans); }
/* ... */
}
@layer components {
/* 留空,供用户通过 @utility 或 @apply 扩展 */
}
@layer utilities {
/* 按需生成的工具类 —— 这里是关键 */
}
v3 vs v4 的本质区别在于:v3 中这四层分别由 @tailwind base、@tailwind components、@tailwind utilities 三个指令注入;v4 统一为一个 @import 入口,内部自动展开为四层 @layer。
3.2 @layer utilities 的按需生成
@layer utilities 是空的吗?不完全是。Tailwind 在构建时会把它填满——但只填入被实际使用的工具类。
例如,如果你的源码中出现了 class="px-4 text-red-500",那 Tailwind 只会生成这两条规则:
@layer utilities {
.px-4 { padding-inline: calc(var(--spacing) * 4); }
.text-red-500 { color: var(--color-red-500); }
}
这就是"按需生成"——不是把所有可能的工具类都打进 CSS(那会有几 MB),而是只生成你实际用到的。
问题来了:Tailwind 怎么知道你用了哪些 class?
四、核心机制:内容扫描
4.1 v4 如何发现 class
Tailwind CSS v4 使用一个基于 Rust 编写的高性能内容扫描器来检测源码中的 class 字符串。扫描策略如下:
-
扫描项目根目录下的所有源文件(
.html、.js、.ts、.vue、.jsx、.tsx、.svelte、.astro等) -
自动排除以下目录:
node_modules/(包括 pnpm 的符号链接).git/- 二进制文件、图片、字体等
-
纯文本匹配:扫描器不理解语法树,它只是在文件内容中查找像 CSS class 的字符串。字符串
'border-b border-default transition-colors'中的每个空格分隔的 token 都会被识别为一个潜在的 class
4.2 关键:node_modules 被排除
这是我们问题的根因。在 pnpm monorepo 中:
node_modules/
@vtable-guild/
theme/ → ../../packages/theme # 符号链接
虽然 @vtable-guild/theme 通过 pnpm workspace 链接到了 packages/theme/,但 Tailwind 的扫描器仍然通过符号链接的路径识别它在 node_modules 中,因此直接跳过。
这意味着 packages/theme/src/table.ts 中定义的所有 class 字符串(border-b、border-default、transition-colors、hover:bg-surface-hover 等)从未被扫描器发现,对应的 CSS 规则也就从未被生成。
4.3 与 v3 的对比
在 v3 中,我们通过 tailwind.config.js 的 content 数组手动指定扫描路径:
// tailwind.config.js (v3)
module.exports = {
content: [
'./src/**/*.{vue,js,ts}',
// 手动添加 workspace 包路径
'../packages/theme/src/**/*.ts',
],
}
这种方式虽然繁琐,但开发者对扫描范围有完全的控制权。
v4 去掉了 tailwind.config.js,改为自动扫描 + CSS 指令控制。自动扫描在大多数单包项目中都能正常工作,但在 monorepo 中引入了上述的坑。
五、CSS-first 配置
v4 的一个重大设计变化是:所有配置都在 CSS 文件中完成,不再需要 tailwind.config.js。
5.1 @theme — 注册自定义设计 token
@theme 指令用于向 Tailwind 的 theme layer 注入自定义 CSS 变量,使其成为可通过工具类使用的 token:
/* packages/theme/css/tokens.css */
:root {
--color-surface: oklch(100% 0 0deg);
--color-surface-hover: oklch(97% 0 0deg);
--color-on-surface: oklch(15% 0 0deg);
--color-muted: oklch(55% 0 0deg);
--color-default: oklch(87% 0 0deg);
--color-primary: oklch(55% 0.25 260deg);
--color-primary-hover: oklch(49% 0.25 260deg);
}
.dark {
--color-surface: oklch(17% 0 0deg);
--color-on-surface: oklch(95% 0 0deg);
/* ... */
}
@theme {
--color-surface: var(--color-surface);
--color-surface-hover: var(--color-surface-hover);
--color-on-surface: var(--color-on-surface);
--color-muted: var(--color-muted);
--color-default: var(--color-default);
--color-primary: var(--color-primary);
--color-primary-hover: var(--color-primary-hover);
}
注册后,你就可以直接使用 bg-surface、text-on-surface、border-default、text-primary 等工具类。暗色模式只需切换 :root 上的 CSS 变量值(通过 .dark class),不需要写 dark: 前缀。
5.2 @source — 手动添加扫描路径
这是解决我们问题的关键指令。
@source 告诉 Tailwind "除了自动扫描的文件之外,还要去扫描这个路径下的文件":
@source "../dist";
路径相对于当前 CSS 文件所在目录解析。
5.3 其他 CSS 指令
| 指令 | 作用 | 示例 |
|---|---|---|
@import "tailwindcss" | 引入 Tailwind 的四层 layer | @import 'tailwindcss' |
@theme | 注册自定义设计 token | @theme { --color-brand: #3b82f6; } |
@source | 添加额外的内容扫描路径 | @source "../components" |
@utility | 定义自定义工具类 | @utility tab-4 { tab-size: 4; } |
@variant | 定义自定义变体 | @variant hocus (&:hover, &:focus) |
@custom-variant | 注册自定义变体(与 @variant 类似) | — |
@reference | 引入但不输出内容(仅供引用) | @reference "tailwindcss" |
@plugin | 加载 JS 插件 | @plugin "tailwindcss-animate" |
六、Vite 插件集成
6.1 @tailwindcss/vite
v4 提供了专用的 Vite 插件,取代了 v3 中通过 PostCSS 插件集成的方式:
// playground/vite.config.ts
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [vue(), vueJsx(), vueDevTools(), tailwindcss()],
})
这个插件做了三件事:
- 拦截 CSS
@import:识别@import 'tailwindcss'和包含@theme、@source等指令的 CSS 文件 - 执行内容扫描:遍历项目文件,收集所有使用到的 class 名
- 按需注入 CSS:根据扫描结果,在
@layer utilities中生成对应的 CSS 规则
6.2 一个隐蔽的坑:@import 的写法
这里有一个额外的坑,也是在我们项目中踩到的。
stylelint-config-standard 有一条默认规则 import-notation: url,它会在保存时自动将:
@import 'tailwindcss';
修正为:
@import url('tailwindcss');
看起来只是写法不同,语义相同?不。@tailwindcss/vite 插件只识别裸字符串形式的 @import,url() 写法会导致插件完全无法识别这条导入,Tailwind 的整个处理链路直接断裂——不扫描、不生成、不注入。
修复方式是在 stylelint 配置中覆盖这条规则:
// stylelint.config.mjs
export default {
extends: ['stylelint-config-standard'],
rules: {
// Tailwind CSS v4 要求裸字符串 @import "tailwindcss",
// stylelint-config-standard 默认强制 url() 写法,需覆盖为 string
'import-notation': 'string',
// 允许 Tailwind CSS v4 的自定义 at-rule
'at-rule-no-unknown': [
true,
{
ignoreAtRules: [
'theme', 'apply', 'config', 'plugin',
'utility', 'variant', 'custom-variant',
'source', 'reference',
],
},
],
},
}
七、解决方案:@source 指令
7.1 最终修复
在 packages/theme/css/tokens.css(即 @vtable-guild/theme/css 的入口文件)中添加一行:
@source "../dist";
/* 原有的 @theme 和 CSS 变量定义... */
这告诉 Tailwind 扫描器:去扫描 packages/theme/dist/ 目录下的文件。而 dist/index.mjs(构建产物)中包含了所有主题定义的 class 字符串:
// packages/theme/dist/index.mjs (构建产物)
const tableTheme = {
slots: {
tr: "border-b border-default transition-colors",
th: "px-4 py-3 text-left font-medium text-muted",
// ...
},
// ...
}
扫描器会从中提取出 border-b、border-default、transition-colors 等所有 class 字符串,然后在 @layer utilities 中生成对应的 CSS 规则。
7.2 为什么是 ../dist 而不是 ../src?
因为 package.json 的 files 字段是 ["dist", "css"]:
{
"name": "@vtable-guild/theme",
"exports": {
".": "./dist/index.mjs",
"./css": "./css/tokens.css"
},
"files": ["dist", "css"]
}
当这个包被发布到 npm 后,src/ 目录不会包含在内。如果写 @source "../src",在 monorepo 开发时能用,但外部消费者安装后会报错(路径不存在)。../dist 在两种场景下都能正确解析。
7.3 消费者体验:零配置
修复后,消费者只需要两行 CSS:
@import 'tailwindcss';
@import '@vtable-guild/theme/css';
第二行导入的 tokens.css 文件中已经包含了 @source "../dist",Tailwind 会自动将 dist/ 纳入扫描范围。消费者不需要手动配置任何扫描路径。
7.4 参考:Nuxt UI 4 的做法
Nuxt UI 4 采用了完全相同的策略。在它的 CSS 入口文件 src/runtime/index.css 中:
@source "./components";
它指向自己的组件目录,让 Tailwind 扫描所有 Vue 组件模板中的 class。消费者通过 @import "@nuxt/ui" 引入这个 CSS 文件时,@source 指令自动生效。
核心原则:由库的 CSS 入口声明 @source,而不是要求消费者手动配置扫描路径。
八、完整排查流程回顾
遇到"class 在 DOM 上但样式不生效"时,可以按以下流程排查:
class 在 DOM 上?
┌─── 否 ──→ JS 运行时问题(组件逻辑 / props 传递)
│
├─── 是
│
对应 CSS 规则存在?
┌─── 否 ──→ Tailwind 内容扫描问题
│ │
│ ├ 检查 @import 写法(url() vs 裸字符串)
│ ├ 检查文件是否在扫描范围内
│ └ 需要 @source 显式注册?
│
├─── 是
│
规则被其他样式覆盖?
┌─── 是 ──→ 检查 CSS 优先级 / @layer 顺序
│
└─── 否 ──→ 检查 CSS 变量是否有值
验证方法:在 DevTools Console 中执行
// 检查某个 class 是否有对应的 CSS 规则
const hasRule = (cls) => [...document.styleSheets]
.flatMap(s => { try { return [...s.cssRules] } catch { return [] } })
.flatMap(r => r.cssRules ? [...r.cssRules] : [r])
.some(r => r.selectorText?.includes(cls))
console.log('border-b:', hasRule('border-b')) // false → 未扫描到
console.log('text-primary:', hasRule('text-primary')) // true → 正常
九、v4 vs v3 核心差异对照表
| 维度 | Tailwind CSS v3 | Tailwind CSS v4 |
|---|---|---|
| 配置文件 | tailwind.config.js(JS) | CSS 文件中的 @theme、@source 等指令 |
| CSS 入口 | @tailwind base/components/utilities | @import "tailwindcss" |
| 内容扫描配置 | content: ['./src/**/*.vue'] | 自动扫描 + @source 显式补充 |
| 扫描排除 | 需手动配置 | 自动排除 node_modules/、.git/ 等 |
| 自定义颜色 | theme.extend.colors 在 JS 中 | @theme { --color-xxx: ... } 在 CSS 中 |
| 暗色模式 | dark:bg-gray-900 | CSS 变量切换,无需 dark: 前缀 |
| 构建集成 | PostCSS 插件 | 专用 Vite/Webpack/PostCSS 插件 |
| 引擎 | JS | Rust(Lightning CSS) + JS |
| 性能 | — | 全量构建快 5 倍+,增量构建快 100 倍+ |
@import 写法 | 无限制 | 必须使用裸字符串,不支持 url() |