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;
}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]。
两类问题看起来不同,本质一样:进入 @apply 后,判断标准从“这个词在 CSS / HTML 里有没有意义”,变成“Tailwind 编译器能不能把这个 token 展开成 CSS 声明”。
先把判断口径定下来
读一行 @apply 时,不要先问“这个 class 我有没有在模板里见过”。先问四个更具体的问题:
- 这个 token 是 Tailwind 已存在的 utility 吗?
- 它能在当前位置生成 CSS 声明吗?
- 它是不是只负责选择器关系、状态锚点或运行时语义?
- 如果它是项目自定义能力,当前样式块能不能看到这份自定义 utility 定义?
第一、第二个问题决定它能不能被内联。第三个问题能挡住 group、peer 这类看起来像 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-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];
}这里有一个实用边界:不要为了“全都写进 @apply”把普通 CSS 也拧成奇怪的 arbitrary utility。@apply 是桥,不是格式洁癖。属性值本身更清楚时,直接写属性值反而更容易读。
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>这不是“Tailwind 写法”和“BEM 写法”谁更高级的问题。关键是状态锚点必须真实存在。项目如果要求模板 class 保持 BEM,那就不要把 group 强行塞进 @apply;直接用 BEM 的 hover / focus / aria 状态选择器更直观。
完整的变体 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;
}有些构建上下文里它可能看起来能跑,有些组件隔离样式里会直接报 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 里。
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 | 不稳定,不当作 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 提示容易把人带偏。它不是在鼓励把所有普通 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
}这层要克制。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 才炸”的问题往前推很多。
写 CSS 时的几个稳定动作
我现在看到 @apply 会先做这几个动作。
- 先把每个 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。需要复用时优先抽组件;确实是通用能力时,注册成 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 的复制粘贴,很多奇怪的构建错误就会提前消失。
