DOM 里有 Tailwind class,为什么样式还是不生效?v4 闭环修复实战

在 monorepo 组件库开发中,我们遇到了 class 明明挂在了 DOM 上,样式却完全不生效的诡异问题。排查过程中深入了 Tailwind CSS v4 的核心机制,形成此文。

一、问题现场

项目 vtable-guild 是一个基于 Vue 3 + Tailwind CSS v4 的 monorepo 表格组件库,使用 pnpm workspace 管理包结构:

bash 复制代码
vtable-guild/
├── packages/
│   ├── core/      # useTheme composable、插件
│   ├── theme/     # 默认主题定义 + CSS token
│   └── table/     # 表格组件
├── playground/    # 开发调试用的 Vite 应用
└── package.json

主题包 @vtable-guild/theme 中的 table.ts 定义了表格组件的默认样式:

css 复制代码
// 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 消费这些样式,然后绑定到模板:

xml 复制代码
<!-- 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> 元素:

css 复制代码
<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 中实际生成的工具类:

javascript 复制代码
// 提取所有 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:

arduino 复制代码
✅ .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 的工具类全部缺失

arduino 复制代码
❌ .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.vueui: { th: 'text-primary' }
uppercase main.tsslots: { 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:

scss 复制代码
/* playground/src/main.css */
@import 'tailwindcss';
@import '@vtable-guild/theme/css';

这行 @import 'tailwindcss' 实际上展开为 四层 CSS @layer

less 复制代码
@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 只会生成这两条规则:

less 复制代码
@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 字符串。扫描策略如下:

  1. 扫描项目根目录下的所有源文件.html.js.ts.vue.jsx.tsx.svelte.astro 等)

  2. 自动排除以下目录:

    • node_modules/(包括 pnpm 的符号链接)
    • .git/
    • 二进制文件、图片、字体等
  3. 纯文本匹配 :扫描器不理解语法树,它只是在文件内容中查找像 CSS class 的字符串。字符串 'border-b border-default transition-colors' 中的每个空格分隔的 token 都会被识别为一个潜在的 class

4.2 关键:node_modules 被排除

这是我们问题的根因。在 pnpm monorepo 中:

bash 复制代码
node_modules/
  @vtable-guild/
    theme/ → ../../packages/theme   # 符号链接

虽然 @vtable-guild/theme 通过 pnpm workspace 链接到了 packages/theme/,但 Tailwind 的扫描器仍然通过符号链接的路径 识别它在 node_modules 中,因此直接跳过。

这意味着 packages/theme/src/table.ts 中定义的所有 class 字符串(border-bborder-defaulttransition-colorshover:bg-surface-hover 等)从未被扫描器发现,对应的 CSS 规则也就从未被生成。

4.3 与 v3 的对比

在 v3 中,我们通过 tailwind.config.jscontent 数组手动指定扫描路径:

java 复制代码
// 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:

css 复制代码
/* 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-surfacetext-on-surfaceborder-defaulttext-primary 等工具类。暗色模式只需切换 :root 上的 CSS 变量值(通过 .dark class),不需要写 dark: 前缀。

5.2 @source --- 手动添加扫描路径

这是解决我们问题的关键指令。

@source 告诉 Tailwind "除了自动扫描的文件之外,还要去扫描这个路径下的文件":

less 复制代码
@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 插件集成的方式:

csharp 复制代码
// playground/vite.config.ts
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
  plugins: [vue(), vueJsx(), vueDevTools(), tailwindcss()],
})

这个插件做了三件事:

  1. 拦截 CSS @import :识别 @import 'tailwindcss' 和包含 @theme@source 等指令的 CSS 文件
  2. 执行内容扫描:遍历项目文件,收集所有使用到的 class 名
  3. 按需注入 CSS :根据扫描结果,在 @layer utilities 中生成对应的 CSS 规则

6.2 一个隐蔽的坑:@import 的写法

这里有一个额外的坑,也是在我们项目中踩到的。

stylelint-config-standard 有一条默认规则 import-notation: url,它会在保存时自动将:

scss 复制代码
@import 'tailwindcss';

修正为:

arduino 复制代码
@import url('tailwindcss');

看起来只是写法不同,语义相同?@tailwindcss/vite 插件只识别裸字符串 形式的 @importurl() 写法会导致插件完全无法识别这条导入,Tailwind 的整个处理链路直接断裂------不扫描、不生成、不注入。

修复方式是在 stylelint 配置中覆盖这条规则:

dart 复制代码
// 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 的入口文件)中添加一行:

less 复制代码
@source "../dist";

/* 原有的 @theme 和 CSS 变量定义... */

这告诉 Tailwind 扫描器:去扫描 packages/theme/dist/ 目录下的文件。而 dist/index.mjs(构建产物)中包含了所有主题定义的 class 字符串:

arduino 复制代码
// 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-bborder-defaulttransition-colors 等所有 class 字符串,然后在 @layer utilities 中生成对应的 CSS 规则。

7.2 为什么是 ../dist 而不是 ../src

因为 package.jsonfiles 字段是 ["dist", "css"]

perl 复制代码
{
  "name": "@vtable-guild/theme",
  "exports": {
    ".": "./dist/index.mjs",
    "./css": "./css/tokens.css"
  },
  "files": ["dist", "css"]
}

当这个包被发布到 npm 后,src/ 目录不会包含在内。如果写 @source "../src",在 monorepo 开发时能用,但外部消费者安装后会报错(路径不存在)。../dist 在两种场景下都能正确解析。

7.3 消费者体验:零配置

修复后,消费者只需要两行 CSS:

scss 复制代码
@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 中:

less 复制代码
@source "./components";

它指向自己的组件目录,让 Tailwind 扫描所有 Vue 组件模板中的 class。消费者通过 @import "@nuxt/ui" 引入这个 CSS 文件时,@source 指令自动生效。

核心原则:由库的 CSS 入口声明 @source,而不是要求消费者手动配置扫描路径。

八、完整排查流程回顾

遇到"class 在 DOM 上但样式不生效"时,可以按以下流程排查:

kotlin 复制代码
                    class 在 DOM 上?
                    ┌─── 否 ──→ JS 运行时问题(组件逻辑 / props 传递)
                    │
                    ├─── 是
                    │
              对应 CSS 规则存在?
              ┌─── 否 ──→ Tailwind 内容扫描问题
              │           │
              │           ├ 检查 @import 写法(url() vs 裸字符串)
              │           ├ 检查文件是否在扫描范围内
              │           └ 需要 @source 显式注册?
              │
              ├─── 是
              │
        规则被其他样式覆盖?
        ┌─── 是 ──→ 检查 CSS 优先级 / @layer 顺序
        │
        └─── 否 ──→ 检查 CSS 变量是否有值

验证方法:在 DevTools Console 中执行

javascript 复制代码
// 检查某个 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()
相关推荐
默默学前端22 分钟前
ES6模板语法与字符串处理详解
前端·ecmascript·es6
lxh011330 分钟前
记忆函数 II 题解
前端·javascript
我不吃饼干37 分钟前
TypeScript 类型体操练习笔记(三)
前端·typescript
华仔啊41 分钟前
除了防抖和节流,还有哪些 JS 性能优化手段?
前端·javascript·vue.js
chenhdowue1 小时前
vue 表格 vxe-table 高亮行支持取消操作
vue.js·vxe-table
CHU7290351 小时前
随时随地学新知——线上网课教学小程序前端功能详解
前端·小程序
清粥油条可乐炸鸡1 小时前
motion入门教程
前端·css·react.js
这是个栗子1 小时前
【Vue3项目】电商前台项目(四)
前端·vue.js·pinia·表单校验·面包屑导航
前端Hardy1 小时前
Electrobun 正式登场:仅 12MB,JS 桌面开发迎来轻量化新方案!
前端·javascript·electron
树上有只程序猿1 小时前
新世界的入场券,不再只发给程序员
前端·人工智能