Tailwind @apply 里的 ease 为什么会拖到构建才报错
这次问题很小:一个 Vue SFC 的样式里写了 @apply ... ease;。编辑器没有提示,stylelint 没报错,git hook 也过了,最后在 Nuxt build 里炸出来:
The `ease` class does not exist. If `ease` is a custom class,
make sure it is defined within a `@layer` directive.第一反应很容易是“Tailwind 太挑了”或者“编辑器没配好”。真正的问题不是这两句。ease 在普通 CSS 里确实是合法值,但 @apply 不是普通 CSS 声明,它要的是 Tailwind utility class。前几层工具只看到了语法,或者只把 @apply 当成一个被允许的 at-rule;直到 Tailwind 的 PostCSS 插件真正解析 utility 时,才知道项目里没有一个叫 ease 的 class。
这篇文章记录的是一次工程护栏补齐:先把当前错法改成 ease-[ease],再补一条 Stylelint 黑名单规则和一个文件级 Tailwind 编译检查,把“构建才失败”的问题尽量提前到提交前。
ease 在两个语境里不是同一个东西
普通 CSS 里可以写:
.button {
transition-timing-function: ease;
}这里的 ease 是 transition-timing-function 的属性值。浏览器认识它,CSS 语法也认可它。
Tailwind 的 @apply 是另一件事。Tailwind v3 的 Functions & Directives 文档把 @apply 定义成把已经存在的 utility class 内联进自定义 CSS。下面这段代码会请求 Tailwind 展开每一个 class token:
.tab {
@apply transition-colors duration-[160ms] ease;
}transition-colors 是 utility,duration-[160ms] 是 arbitrary utility,ease 在 Tailwind v3 里不是默认 utility。Tailwind v3 的 Transition Timing Function 文档列出的默认 easing utility 是 ease-linear、ease-in、ease-out 和 ease-in-out;一次性自定义值要用方括号 arbitrary value,例如 ease-[cubic-bezier(...)]。
这次需要保留 CSS 原生 ease 曲线,所以推荐写法是:
.tab {
@apply transition-colors duration-[160ms] ease-[ease];
}如果业务上接受 Tailwind 的默认曲线,也可以直接改成 ease-in、ease-out、ease-in-out 或 ease-linear。进入 @apply 以后,脑子里要切到 Tailwind utility 语境,不要继续按普通 CSS 属性值写。
为什么编辑器和 stylelint 都没挡住
这次更值得记住的是检查链路的空洞,ease 只是把这个空洞暴露出来的一个词。
编辑器能做很多提示,但它不等于项目构建。尤其在 Vue SFC、SCSS、Tailwind、Nuxt、PostCSS 一起工作的项目里,编辑器可能只根据语言服务、插件和静态索引给出一部分提示。它能提示一批已知 utility,不代表会对 <style lang="scss"> 里的每个 @apply 运行完整 Tailwind 编译。
stylelint 也一样。项目里通常会把 Tailwind 的 @apply 设成合法 at-rule,否则所有 Tailwind 写法都会被误报。stylelint 负责 CSS 规则、选择器、属性、格式和一部分项目约束;它不天然承担“调用 Tailwind 解析 utility 是否存在”的职责。于是 @apply ... ease; 在普通 stylelint 配置里只是一个合法 at-rule 里的 token。
git hook 没拦住,是因为 hook 里没有运行能识别这个错误的检查。hook 只会执行配置过的命令;如果命令本身不跑 Tailwind utility 解析,它当然不会凭空发现 ease 不是 utility。
构建能发现,是因为构建链路真的跑到了 Tailwind 的 PostCSS 插件。Nuxt build 在处理样式时会走到 Tailwind,Tailwind 展开 @apply,找不到 ease 这个 class,于是报错。
可以把这条链路压成一句话:越靠前的工具越轻,越靠后的构建越真实;这次缺的是“足够早、但又真的调用 Tailwind”的中间层。
先修代码:标准写法是 ease-[ease]
当前样式修复本身很简单。原来类似这样:
/* app/components/.../Contacts/index.vue */
.contacts__tab {
@apply relative flex transition-colors duration-[160ms] ease;
}改成:
/* app/components/.../Contacts/index.vue */
.contacts__tab {
@apply relative flex transition-colors duration-[160ms] ease-[ease];
}ease-[ease] 展开的仍然是:
transition-timing-function: ease;这个写法比退回普通 CSS 更合适。项目希望推动 Tailwind @apply,那就应该继续使用 Tailwind 的表达方式,而不是因为一次错误就把样式退回传统 CSS。真正要补的是工具链:同类错误以后不要等 build 才发现。
第一层护栏:把小黑名单接进 Stylelint
第一层一开始可以写成普通 Node 脚本,但更稳的落点是 Stylelint 自定义规则。Stylelint 的插件文档本来就提供 createPlugin、report、ruleMessages 和 validateOptions,适合承载这种“只检查 CSS AST 里的某类 at-rule”的项目规则。
// stylelint/rules/tailwind-apply-no-css-value/forbidden-tokens.mjs
export const forbiddenTailwindApplyTokens = new Map([
[
'ease',
[
'`ease` 是 CSS timing function 值,不是 Tailwind utility。',
'需要保持 CSS 原生 `ease` 曲线时,写 Tailwind arbitrary utility:`ease-[ease]`。',
'也可以按语义改成 `ease-in` / `ease-out` / `ease-in-out` / `ease-linear`。',
].join(' '),
],
[
'linear',
[
'`linear` 是 CSS timing function 值,不是 Tailwind utility。',
'需要线性曲线时,写 Tailwind utility:`ease-linear`;需要原样 arbitrary value 时写 `ease-[linear]`。',
].join(' '),
],
])真正的规则只做一件事:遍历 @apply,把 token 和这张小黑名单对一下,命中就报错。
// stylelint/rules/tailwind-apply-no-css-value/index.mjs
root.walkAtRules('apply', (atRule) => {
const tokens = atRule.params.trim().split(/\s+/).filter(Boolean)
for (const token of tokens) {
const suggestion = forbiddenTailwindApplyTokens.get(token)
if (!suggestion) continue
report({
result,
ruleName,
message: messages.rejected(token, suggestion),
node: atRule,
word: token,
})
}
})这层放进 Stylelint 后,日常 stylelint、编辑器的 Stylelint 插件、lint-staged 和单测都可以共享同一个规则。黑名单数据只维护一份,pnpm lint:tailwind-apply 也可以通过 Stylelint Node API只运行这一条规则,继续作为 prebuild 前的轻量全量检查。
它的价值是快,错误信息也能直接告诉开发者怎么改。它的风险是边界太窄,所以不能把它扩成“CSS 值大全”。
很多词既是 CSS 值,又是 Tailwind utility。例如 absolute、relative、flex、block、hidden 都是合法 Tailwind class。如果把所有 CSS 值都塞进黑名单,工具会迅速变成噪音制造器。这条规则只适合收录两类 token:
- 项目里真实出现过问题。
- Tailwind 默认确实没有同名 utility,且推荐替代写法清楚。
这就是它的权宜性:它不是一个完备的 Tailwind parser,只是把已知坑提前暴露。Stylelint 黑名单适合做第一道护栏,但不应该替代 Tailwind 自己的编译校验。
第二层护栏:文件级跑一次 Tailwind 编译
黑名单能挡住 ease,但挡不住所有 unknown utility。更接近真实构建的办法,是对本次改动的样式文件跑一次 Tailwind/PostCSS 编译。
脚本的大致流程是:
- 收集传入的
.vue、.css、.scss文件。 - 如果是 Vue SFC,用
@vue/compiler-sfc解析<style>块。 - 如果是 SCSS / Sass,用
sass-embedded先编译成 CSS。 - 构造一个最小输入,把样式交给 Tailwind 的 PostCSS 插件。
- 如果 Tailwind 报 unknown utility,就把文件、style 块起始行和建议打印出来。
核心片段大概是这样:
// scripts/check-tailwind-compile.mjs
function createCompileInput(block) {
const css = isScssLang(block.lang) ? compileScss(block) : block.content
return ['@tailwind utilities;', '', `/* file: ${styleBlockLabel(block)} */`, css].join('\n')
}
function createProcessor() {
return postcss([
tailwindcss({
config: TAILWIND_CONFIG_PATH,
content: [],
}),
])
}
async function checkStyleBlock(block) {
const processor = createProcessor()
const input = createCompileInput(block)
await processor.process(input, {
from: `${block.filePath}${block.index == null ? '' : `?style=${block.index}`}`,
})
}这层比黑名单准,因为它真的让 Tailwind 判断 utility 是否存在。它也比完整 nuxt build 轻,因为它只编译传入文件里的 style,不启动整站构建。
为什么默认不做全量扫描
文件级编译检查刚写好时,我试过不传参数就默认扫整个 app。结果很快暴露出另一个问题:全量扫描要解析大量 Vue SFC 和 SCSS,成本明显高于轻量黑名单。它适合专项清理,不适合每次提交都静默执行。
最后把脚本改成了显式目标:
pnpm lint:tailwind-compile app/components/foo没有传目标时,它只提示用法并退出:
Tailwind 文件级编译检查跳过:请传入需要检查的 .vue / .css / .scss 文件或目录。提交前则交给 lint-staged。lint-staged 会把 staged 文件路径传给命令,于是这条命令天然变成“只检查本次改动的样式文件”:
{
"*.{css,scss,vue}": [
"stylelint --fix",
"pnpm lint:tailwind-apply",
"pnpm lint:tailwind-compile",
"prettier --write"
]
}构建前保留轻量黑名单。这里没有直接运行完整 stylelint,而是让 lint:tailwind-apply 只跑本地黑名单规则,避免把历史 stylelint 规则债务一起压到 prebuild 上:
{
"scripts": {
"prebuild": "pnpm lint:tailwind-apply && pnpm build:emoji-sprite",
"lint:tailwind-apply": "node scripts/check-tailwind-apply.mjs",
"lint:tailwind-compile": "node scripts/check-tailwind-compile.mjs"
}
}这套组合的目标很明确:
- 日常提交:只检查本次改动,尽早挡住新增问题。
- 构建前:只跑轻量黑名单规则,避免已知坑混进构建,同时不放大历史 stylelint 问题。
- 专项治理:需要时显式传目录,扫历史样式。
这个方案的局限
这套脚本是当前项目里的实用权宜方案,不是 Tailwind 官方 lint,也不是完整 Nuxt 构建替代品。
第一,Stylelint 黑名单只适合挡少量明确 token。它能定位到 CSS AST 里的 @apply,比手写正则更稳,但它仍然不负责理解 Tailwind 全量 utility 体系。没有写进黑名单的 unknown utility,它不会报;已经写进黑名单的 token,也必须确保替代写法明确、误报成本可控。
第二,文件级编译脚本只模拟了“SCSS -> Tailwind PostCSS”这段关键链路。它能发现 Tailwind unknown utility,但不等于完整 Nuxt/Vite/PostCSS 构建。比如项目里的其他 PostCSS 插件、CSS Modules 行为、pxtorem、最终 bundle 顺序、组件 scoped 选择器重写,都不是它要验证的对象。
第三,文件级编译脚本当前只处理 .css、.scss 和 Vue SFC 里的 css/scss/sass style 块。项目如果还有 Less、Stylus 或特殊预处理器,需要继续扩展。扩展前也要问一个问题:这些样式是不是项目真实在用,是否值得增加脚本复杂度。
第四,它解决的是“新增错误提前暴露”,不是“历史问题一次清零”。全量扫描太重,所以默认不做;历史清理要单独开任务,显式传目录,接受更长耗时和可能暴露出来的旧问题。
第五,工具只能拦一类错误。开发者和 Agent 仍然需要知道 @apply 的语义边界;工具负责把容易忘的规则变成即时反馈。
这次真正学到什么
这次经验对 AI 写代码尤其有提醒意义。模型很容易把“普通 CSS 可以写 ease”迁移到“Tailwind @apply 也可以写 ease”。这个错误不是完全胡来,它来自相邻语境的错误泛化。人写代码时也会犯,只是 Agent 更容易在批量改样式时放大这种错。
所以 Skill、注释和口头规则都不够。它们能帮助下一个人理解,但不能保证下一次批量修改一定不出错。真正稳定的做法是把高频坑转成自动检查:
- 规则层写清楚:
@apply接收 Tailwind utility,不接收普通 CSS 值。 - 代码层采用标准写法:保留原生
ease时用ease-[ease]。 - 工具层补 Stylelint 黑名单:先挡住项目里真实踩过的
ease/linear。 - 编译层补文件级检查:让 Tailwind 自己判断 utility 是否存在。
- 流程层接入 lint-staged:只检查本次改动,控制成本。
工程护栏通常不是一次到位的。理想状态可能是成熟的 Tailwind-aware stylelint 生态规则、缓存友好的文件级编译器,或者框架官方更完整的编辑器诊断。当前没有马上拿到这么顺手的现成方案时,写一条本地 Stylelint 规则,再用小脚本把它接到 prebuild,也是一种务实做法;只要它边界清楚、误报可控、接入位置合理,就能先把真实问题从构建阶段提前到提交阶段。
总结
ease是合法 CSS 值,但不是 Tailwind v3 默认 transition timing utility。@apply里应该写 Tailwind utility;要保留原生ease曲线时,用ease-[ease]。- 编辑器、stylelint、git hook 和 build 覆盖的检查层不同,不能把“某一层没报错”当成“构建一定能过”。
- 轻量 Stylelint 黑名单适合挡项目里真实踩过、替代写法明确的 token,不适合扩成通用 CSS 值检查。
- 文件级 Tailwind 编译能更真实地发现 unknown utility,但成本比黑名单高,适合接到 lint-staged 按本次改动运行。
- 当前方案是权宜之策。它不能替代完整构建,也不能保证历史代码全干净;它的价值是把新增同类问题提前暴露,让构建失败少一点出现在最后一刻。
