普通视图

发现新文章,点击刷新页面。
昨天 — 2026年2月27日首页

Tailwind CSS v4 — 当框架猜不透你的心思

作者 parade岁月
2026年2月27日 17:14

你在项目里写下 text-(--brand-color),满心期待文字变成品牌色,刷新页面——字号变了。

颜色没变,字号倒是歪了。你盯着屏幕,开始怀疑人生。

别急,这不是 bug,是 Tailwind 在"猜"你的意图——而且猜错了。

这篇文章会带你走一遍真实的开发场景。从最基础的任意值用法开始,一步步遇到更复杂的情况,直到你理解 Tailwind 为什么会猜错,以及如何优雅地纠正它。


场景一:设计稿给了个非标准值

设计师甩过来一张稿子,标注写着:top: 117px、背景色 #bada55

你翻了一遍 Tailwind 的间距和颜色系统——没有。top-28112pxtop-32128px,不上不下。

这时候就需要任意值(Arbitrary Values)了。用方括号 [] 把具体的 CSS 值包起来:

<div class="top-[117px]">精确定位</div>

<button class="bg-[#bada55]">这个颜色名字挺快乐</button>

<div class="left-[calc(50%-4rem)]">居中偏移</div>

方括号里可以放任何合法的 CSS 值——像素、百分比、calc() 表达式,甚至 var()。Tailwind 会原封不动地把它编译成对应的 CSS。

CSS 变量怎么写?

如果你的值存在 CSS 变量里,v4 提供了一个更简洁的语法——用圆括号 () 代替方括号:

<!-- v4 新语法:圆括号 + 裸变量名 -->
<div class="bg-(--brand-color)">用 CSS 变量设背景色</div>

<!-- 当然,显式写 var() 依然有效 -->
<div class="bg-[var(--brand-color)]">效果一样</div>

这是 v4 相对 v3 的一个重要变化。v3 里 CSS 变量简写用的是方括号 bg-[--brand-color],v4 改成了圆括号 bg-(--brand-color)。这个改动不是为了好看——而是为了解决歧义问题,后面会详细说。


场景二:Tailwind 没有的 CSS 属性

项目里需要用 mask-type 控制 SVG 遮罩行为。你搜了一圈文档,Tailwind 没有提供这个工具类。

任意属性(Arbitrary Properties)登场。用方括号把完整的 属性:值 对写进去:

<div class="[mask-type:luminance]">
  SVG 遮罩使用亮度模式
</div>

它和修饰符(modifier)配合也没问题:

<div class="[mask-type:luminance] hover:[mask-type:alpha]">
  hover 时切换为 alpha 模式
</div>

用任意属性设置 CSS 变量

这个语法还有一个很实用的场景——在 HTML 里直接设置 CSS 变量的值:

<div class="[--scroll-offset:56px] lg:[--scroll-offset:44px]">
  不同断点下设置不同的滚动偏移量
</div>

配合响应式前缀,你可以把 CSS 变量当作"响应式参数"来用,而不用写额外的媒体查询。


场景三:选择器玩不转了

产品经理说:"列表前三项要加下划线,hover 的时候。"

:nth-child(-n+3):hover —— 这选择器 Tailwind 的内置修饰符肯定不够用。

任意变体(Arbitrary Variants)可以搞定:

<ul>
  <li class="[&:nth-child(-n+3)]:hover:underline">第 1 项</li>
  <li class="[&:nth-child(-n+3)]:hover:underline">第 2 项</li>
  <li class="[&:nth-child(-n+3)]:hover:underline">第 3 项</li>
  <li>第 4 项(不受影响)</li>
</ul>

方括号里的 & 代表当前元素。Tailwind 会把 & 替换成生成的类名,编译出你需要的选择器。

再来几个例子:

<!-- 所有子 p 元素加上 margin-top -->
<div class="[&_p]:mt-4">
  <p>我有 margin-top</p>
  <p>我也有</p>
</div>

<!-- 当元素有 .is-dragging 类时 -->
<li class="[&.is-dragging]:cursor-grabbing">
  拖拽中换光标
</li>

<!-- @supports 查询 -->
<div class="flex [@supports(display:grid)]:grid">
  支持 grid 就用 grid,否则用 flex
</div>

v4 变体堆叠顺序变了:从左往右读,和 CSS 选择器一致。v3 是从右往左。


场景四:值里面有空格怎么办?

你在写 Grid 布局,需要 grid-template-columns: 1fr 500px 2fr

直接写 grid-cols-[1fr 500px 2fr]?Tailwind 会把空格当作类名分隔符,直接报错。

解决方案:用下划线代替空格。

<div class="grid grid-cols-[1fr_500px_2fr]">
  <!-- 编译后:grid-template-columns: 1fr 500px 2fr -->
</div>

Tailwind 在编译时会自动把下划线转成空格。

但是 URL 里的下划线怎么办?

放心,Tailwind 足够聪明,会保留 URL 里的下划线:

<div class="bg-[url('/what_a_rush.png')]">
  <!-- 不会被转成空格,保持原样 -->
</div>

真的需要下划线呢?

用反斜杠转义:

<div class="before:content-['hello_world']">
  <!-- 编译后:content: 'hello_world' -->
</div>

JSX 里反斜杠被吃了?

JSX 的字符串会把 `` 当转义字符处理。用 String.raw 模板标签:

<div className={String.raw`before:content-['hello_world']`}>
  在 JSX 中安全地使用下划线
</div>

核心场景:Tailwind 猜错了

好,前面都是热身。现在进入本文的重头戏。

问题复现

回到开头的例子。你在 CSS 里定义了一个品牌色变量:

:root {
  --brand-color: #e63946;
}

然后你写下:

<p class="text-(--brand-color)">品牌色文字</p>

你期望的是文字变成红色。但实际效果是——字号变了,颜色没变。

为什么?

因为 text-* 在 Tailwind 里是一个多义命名空间。它同时映射了两种不同的 CSS 属性:

  • text-lgtext-smfont-size(字号)
  • text-red-500text-blackcolor(颜色)

当你写字面值的时候,Tailwind 能从值本身推断出类型:

<!-- Tailwind 看到 22px,推断为 length → font-size -->
<div class="text-[22px]">这是字号</div>

<!-- Tailwind 看到 #bada55,推断为 color → color -->
<div class="text-[#bada55]">这是颜色</div>

22px 明显是长度,#bada55 明显是颜色——推断没问题。

但 CSS 变量是个黑盒

当你写 text-(--brand-color) 的时候,Tailwind 看不到变量里存的是什么。它不知道 --brand-color 是颜色还是尺寸还是别的什么。

这时候 Tailwind 只能猜。而默认的猜测策略可能不符合你的预期——它可能把变量当成了 font-size 而不是 color

于是你的文字不是变红了,而是字号变成了 var(--brand-color),浏览器无法解析为有效字号,表现就很诡异。

解决方案:CSS 数据类型提示

在圆括号里,变量名前面加上类型提示

<!-- 明确告诉 Tailwind:这是颜色 -->
<p class="text-(color:--brand-color)">品牌色文字 ✓</p>

<!-- 明确告诉 Tailwind:这是字号 -->
<p class="text-(length:--font-size)">自定义字号 ✓</p>

语法格式:工具类-(类型:--变量名)

Tailwind 看到 color: 前缀,就知道应该把这个变量编译成 color 属性而不是 font-size。歧义消除。

方括号里的写法

如果你用 var() 的显式写法,类型提示放在方括号开头:

<p class="text-[color:var(--brand-color)]">同样有效</p>

不止 text-*

text-* 是最经典的歧义案例,但不是唯一一个。以下工具类都存在类似的命名空间冲突:

bg-* — 背景相关

<!-- 背景色 -->
<div class="bg-(color:--my-var)">背景颜色</div>

<!-- 背景图 -->
<div class="bg-(image:--my-var)">背景图片</div>

<!-- 背景位置 -->
<div class="bg-(position:--my-var)">背景位置</div>

bg-* 的歧义更多——它可以是颜色、图片、尺寸、位置,不加类型提示几乎必出问题。

border-* — 边框相关

<!-- 边框颜色 -->
<div class="border-(color:--my-var)">边框颜色</div>

<!-- 边框宽度 -->
<div class="border-(length:--my-var)">边框宽度</div>

shadow-* — 阴影相关

<div class="shadow-(color:--my-var)">阴影颜色</div>

decoration-* — 文本装饰

<!-- 装饰线颜色 -->
<div class="decoration-(color:--my-var)">装饰色</div>

<!-- 装饰线粗细 -->
<div class="decoration-(length:--my-var)">装饰粗细</div>

规律总结:只要一个工具类前缀同时对应多种 CSS 属性(颜色 + 尺寸最常见),用 CSS 变量时就需要类型提示。用字面值(如 #fff2px)时不需要,因为 Tailwind 能自动推断。


可用的类型提示一览

Tailwind v4 支持的 CSS 数据类型提示:

类型关键词 匹配什么 示例值
color CSS 颜色 #fffrgb(...)oklch(...)
length 长度 16px1rem2em
percentage 百分比 50%
number 数值 1.50
integer 整数 14
angle 角度 45deg0.25turn
url URL url(...)
image CSS 图片类型 url(...)linear-gradient(...)
position 位置 centertop left
ratio 比例 16/9
line-width 线宽 边框宽度值
bg-size 背景尺寸 covercontain
family-name 字体族名 字体名称

速查表

把全文涉及的语法整理在一起,方便随时翻阅:

场景 语法 示例
字面任意值 工具类-[值] top-[117px]bg-[#bada55]
CSS 变量简写 工具类-(--变量) bg-(--brand-color)
CSS 变量 + var() 工具类-[var(--变量)] bg-[var(--brand-color)]
类型提示(圆括号) 工具类-(类型:--变量) text-(color:--brand-color)
类型提示(方括号) 工具类-[类型:var(--变量)] text-[color:var(--brand-color)]
任意属性 [属性:值] [mask-type:luminance]
设置 CSS 变量 [--变量:值] [--scroll-offset:56px]
任意变体 [选择器]:工具类 [&:nth-child(3)]:underline
空格用下划线 _ 代替空格 grid-cols-[1fr_500px_2fr]
真正的下划线 _ 转义 content-['hello_world']
JSX 中的转义 String.raw`...` String.raw`content-['a_b']`
昨天以前首页

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

作者 parade岁月
2026年2月25日 11:17

在 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.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:

/* 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 字符串。扫描策略如下:

  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 中:

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 数组手动指定扫描路径:

// 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-surfacetext-on-surfaceborder-defaulttext-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()],
})

这个插件做了三件事:

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

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

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

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

@import 'tailwindcss';

修正为:

@import url('tailwindcss');

看起来只是写法不同,语义相同?@tailwindcss/vite 插件只识别裸字符串形式的 @importurl() 写法会导致插件完全无法识别这条导入,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-bborder-defaulttransition-colors 等所有 class 字符串,然后在 @layer utilities 中生成对应的 CSS 规则。

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

因为 package.jsonfiles 字段是 ["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()
❌
❌