Tailwind @apply 不是把 class 搬进 CSS:哪些写法不能内联

把模板里的 Tailwind class 原样搬进 @apply,可能在构建时遇到 unknown utility。

@apply 要求 Tailwind 能在当前编译上下文中找到可展开的样式。内置 utility 是最常见的输入,v3 还支持同一处理上下文里的自定义 class;v4 的自定义 utility 则要用 @utility 注册。普通 CSS 值和模板里的状态标记,不能直接当成 utility 使用。

这件事之所以容易误判,是因为 Tailwind 在模板里确实长得像「预设 class 集合」:

<button class="group flex items-center rounded-full transition-colors hover:bg-black/5">
  <span class="group-hover:text-white">Hi</span>
</button>

搬到 CSS 里以后,很多人会自然写成:

.button {
  @apply group flex items-center rounded-full transition-colors hover:bg-black/5;
}

flex、items-center、rounded-full 这些没问题;hover:bg-black/5 在很多场景也能展开成带状态选择器的规则。问题出在 group:它本身不产生视觉声明,它只是给后代的 group-hover:* 提供一个 DOM 锚点。@apply 没有办法把这种「状态锚点」内联成 color、display、border-radius 这类声明。

我之前还遇到过一个更隐蔽的例子:@apply ease;。ease 在 CSS 里是合法的 transition-timing-function 值,但它不是 Tailwind v3 默认 utility。正确写法要么是 ease-in / ease-out / ease-in-out / ease-linear,要么用 arbitrary utility 写成 ease-[ease]。

group 和 ease 的问题都来自输入语义。进入 @apply 后,需要判断 Tailwind 编译器能否把 token 展开成 CSS 声明,仅在 CSS / HTML 里有意义还不够。

Tailwind @apply token 判断流程

内联取决于 token 类型和编译上下文

检查 @apply 时,需要确认 token 能力和定义的可见范围:

  1. 这个 token 是 Tailwind 已存在的 utility 吗?
  2. 它能在当前位置生成 CSS 声明吗?
  3. 它是不是只负责选择器关系、状态锚点或运行时语义?
  4. 如果它是项目自定义能力,当前样式块能不能看到这份自定义 utility 定义?

第一、第二个问题决定它能不能被内联。第三个问题能挡住 group、peer 这类看起来像 class、实际上不产生声明的 token。第四个问题主要发生在 Vue SFC、CSS Modules、Svelte、Astro 这类「每个组件样式单独处理」的场景里。

Tailwind 的 v3 文档和 v4 文档都将 @apply 描述为把 existing utility classes 内联到自定义 CSS。v4 增加了 @theme、@utility、@variant、@reference 等 CSS-first 的组织方式。

这里不能只凭「utility」这个词把所有普通 class 判成错误。v3 的官方示例就会 @apply 同一份样式中的自定义 class。需要检查的是版本、定义方式和可见范围;CSS 值与 group 这类状态标记,则是另外两种明确不能内联的输入。

CSS 值不能直接塞进 @apply

ease 是最容易踩的例子,因为它在 CSS 里太正常了:

.button {
  transition-timing-function: ease;
}

但下面这段在 Tailwind v3 项目里会报错:

.button {
  @apply transition-colors duration-[160ms] ease;
}

transition-colors 是 utility,duration-[160ms] 是 arbitrary utility,ease 只是 CSS 属性值。Tailwind v3 的 transition timing function 文档里,默认 easing utility 是 ease-linear、ease-in、ease-out、ease-in-out。要保留 CSS 原生 ease 曲线,需要写成:

.button {
  @apply transition-colors duration-[160ms] ease-[ease];
}

这类问题不只发生在 ease 上。下面这些 token 都常见,但它们自己不是 utility:

.card {
  /* ❌ 这些是 CSS 值或片段,不是 Tailwind utility */
  @apply center none 1px #fff ease linear;
}

对应的写法要么换成 Tailwind utility,要么退回普通 CSS 声明:

.card {
  /* ✅ 用 Tailwind utility 表达 */
  @apply border border-white ease-linear;
}

.card {
  /* ✅ 这个值确实没有必要 utility 化时,就直接写 CSS 属性 */
  place-items: center;
  transition-timing-function: ease;
}

.card {
  /* ✅ 需要保留原始值,又希望留在 Tailwind 语境里时,用 arbitrary utility */
  @apply ease-[ease] bg-[#fff] border-[1px];
}

属性值本身更清楚时,直接写普通 CSS 即可。为了统一使用 @apply 而构造难读的 arbitrary utility,反而会增加阅读成本。

group 和 peer 不是声明型 utility

group、peer、group/card、peer/input 这类 token,在模板里很有用。它们让后代或兄弟节点能响应父级 / 前序节点状态:

<button class="group card">
  <span class="card__text group-hover:text-white">Hi</span>
</button>

但 group 自己没有视觉效果。它不代表 display,不代表 color,也不代表 transition。它只是选择器里的锚点:

.group:hover .group-hover\:text-white {
  color: #fff;
}

所以这类写法应该被拦住:

.card {
  /* ❌ group 不是可内联声明 */
  @apply group rounded-2xl;
}

合适的写法有两种。第一种是保留 Tailwind 的状态机制,把 marker 留在真实 DOM 上:

<template>
  <button class="card group">
    <span class="card__text group-hover:text-white">Hi</span>
  </button>
</template>

第二种是模板只保留 BEM class,把状态关系写回 CSS:

<template>
  <button class="home-card-grid-item">
    <span class="home-card-grid-item__name">Hi</span>
  </button>
</template>

<style scoped lang="scss">
.home-card-grid-item {
  @apply rounded-2xl;

  &:hover {
    .home-card-grid-item__name {
      @apply text-white;
    }
  }
}
</style>

状态锚点必须存在于真实 DOM 上。项目如果要求模板 class 保持 BEM,就用 BEM 的 hover / focus / aria 状态选择器表达关系,不能把 group 放进 @apply。

group 和 peer 是状态标记,不是声明型 utility

完整的变体 utility 要单独看。hover:bg-black/5、focus:ring-2 这类 token 不等于 hover 或 focus 本身,它们包含了「状态 + utility」。Tailwind v3 文档里的 @apply 示例就包含 hover:*、focus:*。v4 也提供了 @variant,让自定义 CSS 里的状态逻辑写得更明确:

.button {
  @apply bg-white text-slate-900;

  @variant hover {
    @apply bg-slate-900 text-white;
  }
}

完整的状态 utility 需要按编译上下文判断,状态 marker 自己不能内联。

普通自定义 class 不自动等于 utility

项目里经常会有 .button-primary、.card-surface、.dialog-title 这类 class。它们可以是很好的 CSS class,但它们不天然是 Tailwind utility。

以这个例子为例:

.button-primary {
  @apply rounded-full bg-violet-500 px-4 py-2 text-white;
}

.dialog-action {
  /* ❌ 不要默认普通 class 可以被 @apply */
  @apply button-primary;
}

在 v3 中,如果 .button-primary 和引用它的 @apply 处于同一个处理上下文,这种自定义 class 复用是受支持的,并非偶然能跑;@layer components / utilities 里的定义还会被 Tailwind 识别为 utility。把定义放在全局 CSS、引用放在独立处理的组件样式块里,则未必可见。

v4 不再把 @layer 中的普通 class 自动注册为 utility,需要改用 @utility。这项变化在官方升级指南里有明确说明。迁移时,不能因为 v3 能编译,就认为原样搬到 v4 也成立。

需要复用时有三种更可靠的选择:

  • 这段样式就是普通 CSS 组件样式:用真实 class 组合,不要 @apply 另一个普通 class。
  • 这段样式确实要作为 Tailwind utility 被复用:在 Tailwind 的自定义 utility 机制里注册。
  • 这段样式属于业务组件内部:抽组件或抽局部 CSS 片段,不要把业务 class 扩散成全局工具。

v3 项目里,跨文件、跨组件都要能 @apply 的自定义 utility,更适合通过 plugin 注册。Tailwind v3 的文档在讲「per-component CSS 中使用 @apply」时也提醒过,组件样式是单独处理的,想让自定义样式在这些地方可见,应使用插件系统定义。

// tailwind.config.js
module.exports = {
  plugins: [
    function ({ addUtilities }) {
      addUtilities({
        '.content-auto': {
          contentVisibility: 'auto',
        },
      })
    },
  ],
}

v4 项目里,对应机制更偏 CSS-first,可以用 @utility:

@utility content-auto {
  content-visibility: auto;
}

如果这段 utility 定义在另一个 CSS 文件里,而当前文件是 CSS Module、Vue / Svelte / Astro 的组件样式块,v4 还需要 @reference 把外部主题变量、自定义 utility 和自定义 variant 引进来,但不重复输出 CSS:

<style scoped>
@reference "../../assets/css/app.css";

.panel {
  @apply content-auto;
}
</style>

v4 将自定义 utility、主题变量和状态变体的定义方式放到 CSS 里,@apply 的输入仍然需要符合 utility 语义和当前可见范围。

Tailwind v3 和 v4 的 @apply 边界

v3 和 v4 的差异主要在「怎么注册、怎么引用」

日常 review 更常用的判断口径如下表所示:

token 类型 v3 口径 v4 口径 建议
内置 utility,如 flex、rounded-lg、text-white/70 可以 @apply 可以 @apply 正常使用
arbitrary utility,如 ease-[ease]、bg-[var(--x)] 可以 @apply 可以 @apply v4 可用更短的 CSS 变量语法
CSS 值,如 ease、linear、#fff、1px 不能当 utility 不能当 utility 写成属性值或 arbitrary utility
状态 marker,如 group、peer、group/card 不能内联声明 不能内联声明 留在 DOM,或改成 BEM 状态选择器
variant 名本身,如 hover、focus、dark 不是 utility 不是 utility 写完整 variant utility,或 v4 用 @variant
普通项目 class,如 .button-primary 同一处理上下文可内联;跨隔离样式块不自动可见 不自动注册为 utility 确认版本与作用域,或组合 class / 注册 utility
自定义 utility 用 plugin / config 注册 用 @utility 注册 需要跨隔离样式块时注意引用范围
隔离样式块里引用外部主题 / utility 依赖 config / plugin 可见性 需要 @reference Vue SFC、CSS Modules 等场景重点检查

v4 还有一个容易让人混淆的小变化:CSS 变量 arbitrary value 的写法更短了。v3 常见写法是:

<div class="bg-[var(--brand-color)]"></div>

v4 可以写成:

<div class="bg-(--brand-color)"></div>

遇到 text-* 这类可能对应颜色也可能对应长度的 utility,v4 文档还提供了数据类型提示:

<div class="text-(color:--brand-color)"></div>
<div class="text-(length:--title-size)"></div>

这个差异影响的是「怎样把值表达成 utility」。它不改变核心判断:--brand-color 自己不能直接 @apply,bg-[var(--brand-color)] 或 bg-(--brand-color) 才是 utility。

为什么这类问题容易拖到构建才暴露

@apply group、@apply ease 这类错误,经常不是编辑器第一时间发现的。

编辑器通常靠语言服务、Tailwind 插件和静态索引提示 class。它能提示你很多 utility,但它不一定会对每个 Vue SFC 的 <style lang="scss"> 运行完整 Tailwind 编译。

Stylelint 默认也不会理解 Tailwind 的全部 utility 语义。项目为了支持 @apply,通常会允许 Tailwind 的 at-rule;之后 @apply 里面具体是什么 token,stylelint 没有 Tailwind 编译器上下文就很难准确判断。

真正会报错的地方,是 Tailwind 的 PostCSS 插件展开 @apply 的那一步。它看到 unknown utility,才会给出这类错误:

The `group` class does not exist. If `group` is a custom class,
make sure it is defined within a `@layer` directive.

或者:

The `ease` class does not exist. If `ease` is a custom class,
make sure it is defined within a `@layer` directive.

错误文案里的 @layer 提示表示 Tailwind 在当前上下文找不到这个 utility,不能据此把所有普通 class 放进 @layer。下一步要按 token 类型选择改成 utility、普通 CSS、注册自定义 utility,或将 marker 留在 DOM。

工具护栏应该分两层

review 发现了 group,下一次仍可能漏掉 peer/item。可以用轻量诊断和文件级编译分别检查已知错法与真实 utility 解析结果。

第一层是轻量 stylelint 规则,只挡已经明确的坑。它不尝试完整模拟 Tailwind,而是把项目踩过的、替代写法明确的 token 拦住:

const forbiddenTailwindApplyTokens = new Map([
  ['ease', '需要 CSS 原生 ease 曲线时写 ease-[ease]。'],
  ['linear', '需要线性曲线时写 ease-linear 或 ease-[linear]。'],
])

function getTailwindApplyTokenSuggestion(token) {
  if (forbiddenTailwindApplyTokens.has(token)) {
    return forbiddenTailwindApplyTokens.get(token)
  }

  if (/^(?:group|peer)(?:\/[\w-]+)?$/.test(token)) {
    return '`group` / `peer` 是状态 marker,应保留在 DOM 或改用 BEM 状态选择器。'
  }

  return undefined
}

这层要克制。absolute、relative、block、hidden 这些词在 CSS 里也有意义,但它们同时是 Tailwind utility,不能粗暴拉黑。stylelint 规则只收录确定会误导、确定不是 utility、确定有推荐写法的 token。

第二层是文件级 Tailwind 编译检查。它拿到本次改动的 .vue / .css / .scss,抽出 style 块,走一次 Sass + PostCSS + Tailwind。它比黑名单慢一点,但结论更接近真实构建。日常可以只对 staged 文件运行;专项清理时再传目录全量扫。

这两层职责不同:

  • stylelint 黑名单负责把已知高频坑提前到编辑和提交阶段。
  • Tailwind 文件级编译负责发现未知 utility、配置不可见、v3/v4 迁移边界这类更真实的问题。

黑名单会遗漏未知错误,完整编译的成本又较高。日常结合轻量诊断与按文件编译,可以让更多错误在 Nuxt build 前暴露。

下面这个示例只跑轻量诊断,效果接近 Stylelint 编辑器插件:ease / linear、group / peer、text-white/72 这类 token 会直接在编辑器里打波浪线,hover 能看到对应建议。

Stylelint 规则诊断示例正在加载代码工作区...

如果要让 Agent 接入这套护栏,可以把下面这段短 prompt 和源码工作区一起给它:

请把 Tailwind Apply Guardrail 接入当前项目。
工具包根路径:https://shengsheng.fun/files/tailwind-apply-utility-boundaries/kits/tailwind-apply-guardrail/
先读 README.md、MANIFEST.json、FILES.json、CHANGELOG.md、AGENT_PROMPT.md,再按 FILES.json 读取源码;迁移 copy/stylelint/、copy/scripts/ 和测试,按项目 Tailwind 版本调整验证命令。

完整源码放在这里,包含 Stylelint 规则、轻量扫描脚本、文件级 Tailwind 编译检查脚本、测试和接入示例:

Tailwind @apply 护栏源码正在加载代码工作区...

写 CSS 时的几个稳定动作

我在 review @apply 时,会按 token 类型和项目版本检查:

  • 先把每个 token 分成「声明型 utility / variant utility / CSS 值 / 状态 marker / 普通 class / 自定义 utility」。
  • CSS 值直接写属性,或者改成 arbitrary utility;不要把 ease、1px、#fff 这种值当 class。
  • group、peer 这类状态 marker 留在 DOM;如果模板只允许 BEM,就用 BEM 状态选择器表达 hover / focus / selected。
  • 项目里我更倾向于组合普通业务 class,而不是层层 @apply;这是维护取舍,不是 v3 的语法禁令。确实需要跨组件复用 utility 时,再按版本注册并检查可见范围。
  • v4 的 Vue SFC、CSS Modules、Svelte、Astro 等隔离样式块,使用外部主题、自定义 utility 或自定义 variant 时补 @reference。
  • 遇到 v3/v4 CSS 变量语法差异时,先确认项目版本。v3 用 bg-[var(--x)],v4 可以用 bg-(--x);不要把 v4 写法直接搬到 v3 项目。

@apply 最适合处理「我想用 Tailwind 的 utility 组织组件样式」这类场景。它不适合承担 CSS mixin、状态机、选择器锚点、动态 class 拼装和跨文件样式复用的所有职责。

token 交给 Tailwind 判断

  • @apply 处理的是 Tailwind utility,不是任意 class 字符串。
  • 能不能写在模板 class 里,不等于能不能写进 @apply。
  • CSS 值要写成属性值或 arbitrary utility;ease 对应 ease-[ease] 或 Tailwind 的 ease-* utility。
  • group / peer 是状态 marker,必须留在真实 DOM 上,或改用 BEM 状态选择器。
  • 自定义 class 要分版本看:v3 支持同一处理上下文中的内联,也支持 @layer / plugin 注册;v4 用 @utility,隔离样式块还要看 @reference。
  • 工具上用轻量 stylelint 黑名单挡已知坑,再用文件级 Tailwind 编译挡真实 unknown utility。

review 时先确认 token 类型、Tailwind 版本和定义可见范围,再由文件级编译验证 utility 是否存在。黑名单用于提示已知错法,无法替代编译器对当前上下文的判断。