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

@apply 很容易被理解成“把模板里的 Tailwind class 搬到 CSS 里”。这个理解足够直观,也足够危险。

真实机制更窄一点:@apply 会把 Tailwind 能识别的 utility 展开成 CSS 声明。它要处理的是 utility,不是任意 class 字符串,不是普通 CSS 值,也不是模板里用于建立状态关系的标记 class。

这件事之所以容易误判,是因为 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;
}

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

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

两类问题看起来不同,本质一样:进入 @apply 后,判断标准从“这个词在 CSS / HTML 里有没有意义”,变成“Tailwind 编译器能不能把这个 token 展开成 CSS 声明”。

Tailwind @apply token 判断流程

先把判断口径定下来

读一行 @apply 时,不要先问“这个 class 我有没有在模板里见过”。先问四个更具体的问题:

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

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

Tailwind 官方文档对 @apply 的描述一直围绕一个词:existing utility classes。v3 文档说它会把现有 utility class 内联到自定义 CSS。v4 文档也是同一个口径,只是 v4 增加了 @theme@utility@variant@reference 等 CSS-first 的组织方式。

“现有 utility”这几个字比看起来更重要。普通 class、CSS 值、状态标记和外部文件里的自定义样式,都不能自动获得 utility 身份。

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-linearease-inease-outease-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];
}

这里有一个实用边界:不要为了“全都写进 @apply”把普通 CSS 也拧成奇怪的 arbitrary utility。@apply 是桥,不是格式洁癖。属性值本身更清楚时,直接写属性值反而更容易读。

grouppeer 不是声明型 utility

grouppeergroup/cardpeer/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>

这不是“Tailwind 写法”和“BEM 写法”谁更高级的问题。关键是状态锚点必须真实存在。项目如果要求模板 class 保持 BEM,那就不要把 group 强行塞进 @apply;直接用 BEM 的 hover / focus / aria 状态选择器更直观。

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

完整的变体 utility 要单独看。hover:bg-black/5focus:ring-2 这类 token 不等于 hoverfocus 本身,它们包含了“状态 + 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;
}

有些构建上下文里它可能看起来能跑,有些组件隔离样式里会直接报 unknown utility。根本原因是 Tailwind 负责展开 utility,不负责把任意 CSS class 当 mixin 系统使用。

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

  • 这段样式就是普通 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 和 v3 最值得注意的差异之一。v4 并没有把 @apply 放宽成“什么 class 都能搬”;它只是把自定义 utility、主题变量和状态变体的定义方式搬到了 CSS 里。

Tailwind v3 和 v4 的 @apply 边界

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

下面这张表更适合日常 review 时用:

token 类型 v3 口径 v4 口径 建议
内置 utility,如 flexrounded-lgtext-white/70 可以 @apply 可以 @apply 正常使用
arbitrary utility,如 ease-[ease]bg-[var(--x)] 可以 @apply 可以 @apply v4 可用更短的 CSS 变量语法
CSS 值,如 easelinear#fff1px 不能当 utility 不能当 utility 写成属性值或 arbitrary utility
状态 marker,如 grouppeergroup/card 不能内联声明 不能内联声明 留在 DOM,或改成 BEM 状态选择器
variant 名本身,如 hoverfocusdark 不是 utility 不是 utility 写完整 variant utility,或 v4 用 @variant
普通项目 class,如 .button-primary 不稳定,不当作 utility 不稳定,不当作 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 自己不能直接 @applybg-[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 提示容易把人带偏。它不是在鼓励把所有普通 class 塞进 @layer 后再 @apply。它的真实提醒是:Tailwind 在当前上下文找不到这个 utility。下一步应该先判断 token 类型,再决定是改成 utility、改成普通 CSS、注册自定义 utility,还是把 marker 留在 DOM。

工具护栏应该分两层

这类问题不适合只靠人工 review。人工能发现 group,下一次也可能漏掉 peer/item。更稳的是两层护栏。

第一层是轻量 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
}

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

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

这两层职责不同:

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

只做黑名单会漏。只做完整编译又太重。两层放在一起,能把“Nuxt build 才炸”的问题往前推很多。

写 CSS 时的几个稳定动作

我现在看到 @apply 会先做这几个动作。

  • 先把每个 token 分成“声明型 utility / variant utility / CSS 值 / 状态 marker / 普通 class / 自定义 utility”。
  • CSS 值直接写属性,或者改成 arbitrary utility;不要把 ease1px#fff 这种值当 class。
  • grouppeer 这类状态 marker 留在 DOM;如果模板只允许 BEM,就用 BEM 状态选择器表达 hover / focus / selected。
  • 普通业务 class 不拿来 @apply。需要复用时优先抽组件;确实是通用能力时,注册成 Tailwind custom 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 拼装和跨文件样式复用的所有职责。

总结

  • @apply 处理的是 Tailwind utility,不是任意 class 字符串。
  • 能不能写在模板 class 里,不等于能不能写进 @apply
  • CSS 值要写成属性值或 arbitrary utility;ease 对应 ease-[ease] 或 Tailwind 的 ease-* utility。
  • group / peer 是状态 marker,必须留在真实 DOM 上,或改用 BEM 状态选择器。
  • 普通自定义 class 不自动等于 utility。v3 用 plugin,v4 用 @utility;隔离样式块还要看 @reference
  • v3 和 v4 的核心边界一致,差异主要在自定义 utility、CSS 变量 shorthand、状态变体和隔离样式块引用机制。
  • 工具上用轻量 stylelint 黑名单挡已知坑,再用文件级 Tailwind 编译挡真实 unknown utility。

这套判断不需要背完整黑名单。每次看到 @apply,把 token 当成要交给 Tailwind 编译器的输入,而不是普通 CSS 或 HTML class 的复制粘贴,很多奇怪的构建错误就会提前消失。