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()
相关推荐
ashuicoder1 小时前
vue文件自动生成路由会成为主流
前端·vue.js
白中白121381 小时前
Vue系列-4
前端·javascript·vue.js
Ai runner1 小时前
Show call stack in perfetto from json input
java·前端·json
晴殇i1 小时前
前端防调试攻防战:如何保护你的JavaScript代码不被“偷窥”?
前端·javascript·面试
清粥油条可乐炸鸡2 小时前
tailwind-variants基本使用
前端·css
2301_816997882 小时前
虚拟DOM与Diff算法
前端·vue.js·算法
清粥油条可乐炸鸡2 小时前
Vite创建react项目
前端·vue.js
德育处主任2 小时前
JS 大数值处理和金额格式化处理方案
前端·javascript·前端框架