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.

ease 在普通 CSS 里是合法值,@apply 接收的却是 Tailwind utility class。前几层工具只检查了语法,或者只把 @apply 当成允许使用的 at-rule;直到 Tailwind 的 PostCSS 插件解析 utility,才发现项目里没有一个叫 ease 的 class。

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

这次先把错法改成 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 属性值写。

CSS 值和 Tailwind utility 的边界

为什么编辑器和 stylelint 都没挡住

这次提交前的检查没有调用 Tailwind 解析 @apply,所以漏过了这个 token。

编辑器能做很多提示,但它不等于项目构建。尤其在 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 utility 解析。缺少的是提交前也能调用 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;

在这个项目推动 Tailwind @apply 的约定下,ease-[ease] 比改回普通 CSS 更合适:它保留原生 ease 曲线,也符合 utility 的表达方式。同类错误还需要由工具链提前发现。

第一层护栏:把小黑名单接进 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 里是值,在 Tailwind 里也是 utility。例如 absolute、relative、flex、block、hidden 都是合法 Tailwind class。把所有 CSS 值加入黑名单会产生大量误报,收录的 token 需要同时符合两个条件:

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

黑名单负责快速提示这些已知错法,其他 unknown utility 交给 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-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 问题。
  • 专项治理:需要时显式传目录,扫历史样式。

文件级检查和全量检查的取舍

这个方案的局限

这套检查覆盖已知错误和单文件样式编译,接入时需要看清它没有运行哪些构建环节。

  • Stylelint 黑名单只挡少量明确 token。它能定位到 CSS AST 里的 @apply,比手写正则更稳,但不理解 Tailwind 全量 utility 体系。未收录的 unknown utility 不会报,已收录的 token 也要确保替代写法明确、误报成本可控。
  • 文件级编译脚本只模拟「SCSS -> Tailwind PostCSS」这段链路。它能发现 Tailwind unknown utility,其他 PostCSS 插件、CSS Modules 行为、pxtorem、最终 bundle 顺序、组件 scoped 选择器重写仍需完整 Nuxt/Vite/PostCSS 构建验证。
  • 当前只处理 .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 问题后来整理成了更完整的 @apply 边界文章和可复制源码包。源码包跟着主文章维护:《Tailwind @apply 不是把 class 搬进 CSS:哪些写法不能内联》;里面包含 Stylelint 规则、轻量扫描脚本、文件级 Tailwind 编译检查脚本和 Agent 接入 prompt。

让 Tailwind 提前说不

  • 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 按本次改动运行。
  • 完整构建继续检查整站插件链和产物;历史样式清理时显式传入目录。提交前检查负责尽早发现本次新增的同类错误。