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。

Tailwind @apply 错误为什么拖到构建才暴露

这篇文章记录的是一次工程护栏补齐:先把当前错法改成 ease-[ease],再补一条 Stylelint 黑名单规则和一个文件级 Tailwind 编译检查,把“构建才失败”的问题尽量提前到提交前。

ease 在两个语境里不是同一个东西

普通 CSS 里可以写:

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

这里的 easetransition-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-linearease-inease-outease-in-out;一次性自定义值要用方括号 arbitrary value,例如 ease-[cubic-bezier(...)]

这次需要保留 CSS 原生 ease 曲线,所以推荐写法是:

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

如果业务上接受 Tailwind 的默认曲线,也可以直接改成 ease-inease-outease-in-outease-linear进入 @apply 以后,脑子里要切到 Tailwind utility 语境,不要继续按普通 CSS 属性值写。

CSS 值和 Tailwind utility 的边界

为什么编辑器和 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 的插件文档本来就提供 createPluginreportruleMessagesvalidateOptions,适合承载这种“只检查 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。例如 absoluterelativeflexblockhidden 都是合法 Tailwind class。如果把所有 CSS 值都塞进黑名单,工具会迅速变成噪音制造器。这条规则只适合收录两类 token:

  • 项目里真实出现过问题。
  • Tailwind 默认确实没有同名 utility,且推荐替代写法清楚。

这就是它的权宜性:它不是一个完备的 Tailwind parser,只是把已知坑提前暴露。Stylelint 黑名单适合做第一道护栏,但不应该替代 Tailwind 自己的编译校验。

第二层护栏:文件级跑一次 Tailwind 编译

黑名单能挡住 ease,但挡不住所有 unknown utility。更接近真实构建的办法,是对本次改动的样式文件跑一次 Tailwind/PostCSS 编译。

脚本的大致流程是:

  1. 收集传入的 .vue.css.scss 文件。
  2. 如果是 Vue SFC,用 @vue/compiler-sfc 解析 <style> 块。
  3. 如果是 SCSS / Sass,用 sass-embedded 先编译成 CSS。
  4. 构造一个最小输入,把样式交给 Tailwind 的 PostCSS 插件。
  5. 如果 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,不启动整站构建。

两层 Tailwind @apply 护栏

为什么默认不做全量扫描

文件级编译检查刚写好时,我试过不传参数就默认扫整个 app。结果很快暴露出另一个问题:全量扫描要解析大量 Vue SFC 和 SCSS,成本明显高于轻量黑名单。它适合专项清理,不适合每次提交都静默执行。

最后把脚本改成了显式目标:

pnpm lint:tailwind-compile app/components/foo

没有传目标时,它只提示用法并退出:

Tailwind 文件级编译检查跳过:请传入需要检查的 .vue / .css / .scss 文件或目录。

提交前则交给 lint-stagedlint-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 按本次改动运行。
  • 当前方案是权宜之策。它不能替代完整构建,也不能保证历史代码全干净;它的价值是把新增同类问题提前暴露,让构建失败少一点出现在最后一刻。