shadcn 为何重写 cn 引擎

Tailwind 项目里 `p-2 p-4` 该听谁的?`flex flex-col` 冲突时为什么最后一个不一定生效?shadcn-ui/cn 用一套全新的合并引擎给出了和 tailwind-merge 完全不同的答案,本文拆解它的 5 个核心问题与可执行解法。

源仓库: shadcn-ui/cn

shadcn-ui/cn 是 shadcn 团队在 2026 年 8 月开源的 Tailwind 类名合并与冲突解析引擎,目标非常明确:替代已经成为事实标准的 tailwind-merge。它在 GitHub Trending 一周内冲到 1165 stars,登上日榜前三。和上一代方案不同,cn 不再做简单的字符串去重,而是把 Tailwind 的 utility 设计成一棵可推导的语义树,按 CSS property 分组裁决冲突。本文不评测、不种草,只从开发者视角拆解 5 个你迟早会撞上的真实问题,每个问题都给出可运行的代码示例。

问题一:为什么 cn('p-2 p-4') 不是简单去重?

现象:在 React 组件里写 <div className={cn('p-2', isLarge && 'p-4')} />,渲染后类名是 p-2 p-4,但你期望只生效 p-4

根因:CSS 的层叠规则是「后定义覆盖先定义」,但 utility 类在构建产物里的顺序并不固定,依赖 purge 配置和你引入顺序。简单字符串去重要么错杀(删掉有用的 modifier),要么漏网(两个 padding 真的同时存在)。

解法:cn 内置了一个完整的 Tailwind utility 解析器,会把 p-2p-4 识别为同一个 CSS property(padding)的不同 utility,然后查内部的优先级表(值越大越优先),只保留赢家。

import { cn } from "cn";
cn("p-2 p-4");           // => "p-4"
cn("text-sm text-lg");   // => "text-lg"
cn("hover:p-2 p-4");     // => "hover:p-2 p-4" (不同 selector 维度)

问题二:flex flex-col 为何不该被合并?

现象:直觉上 flexflex-col 看起来都是 flex-* 前缀,但合并器如果按前缀去重就会把 flex-col 干掉。

根因:旧方案把 Tailwind 类名当字符串处理,按 - 分词后比较「同一前缀组」,这套规则对 flex 系列天然失灵。cn 的做法是按 CSS property 分组:flex 影响 displayflex-col 影响 flex-direction,它们根本不在同一组,因此共存。

cn("flex flex-col");          // => "flex flex-col"
cn("flex-row flex-col");      // => "flex-col"
cn("gap-2 gap-4");            // => "gap-4"

这种语义化分组让 cn 不需要维护一份日益膨胀的前缀黑名单,扩展新 utility 也不会破坏旧逻辑。

问题三:自定义 brand 类怎么接入?

现象:团队在 tailwind.config 里加了 bg-brandtext-fg-primary 这种品牌色,直接传给 cn 会因为「未知 utility」被当作普通字符串拼接。

解法:通过 createEngine 扩展 cn 的 utility 表,告诉解析器这些类属于哪个 CSS property 维度。

import { createEngine } from "cn";

const cn = createEngine({
  extend: {
    backgrounds: ["brand", "surface"],
    textColors:   ["fg-primary", "fg-muted"],
  },
});

cn("bg-brand bg-surface");         // => "bg-surface" (后写覆盖前写)
cn("text-fg-muted text-fg-primary"); // => "text-fg-primary"

这一步也顺带解决了 shadcn 主题切换场景下,bg-backgrounddark:bg-zinc-900 的合并难题。

问题四:大型列表里性能够用吗?

现象:在 1000 行虚拟列表里,每行都会调用 cn(...),担心解析开销。

数据:cn 在仓库 benchmark 里对一组混合 utility 的合并平均耗时 1.2μs,tailwind-merge 同场景约 4.8μs,差距来自 cn 把解析结果按 input 字符串做了内部 LRU 缓存,并且解析器是手写的状态机而不是正则。

解法:直接 import 单例,避免在组件里反复 createEngine

// lib/cn.ts
import { cn as base } from "cn";
export const cn = (...inputs: ClassValue[]) => base(inputs);

如果你用了 React Server Components,记得把 lib/cn.ts 标成 client component 边界,或在 server 端直接走同样的字符串处理,因为 cn 是纯函数没有副作用。

问题五:怎么从 tailwind-merge 平滑迁移?

现象:仓库里已经有几百处 import { twMerge } from 'tailwind-merge',一次性替换风险大。

迁移路径三步走:

  1. 全局替换 import 路径:tailwind-mergecn,并把 twMerge 改名 cn
  2. 跑一遍视觉回归(推荐 Chromatic 或 Playwright + screenshot diff);
  3. 处理 edge case:cn 对 !important modifier 和 arbitrary value(如 bg-[#fff])的裁决策略和 tailwind-merge 不完全一致,需要在测试里固化期望输出。
// 替换前
import { twMerge } from "tailwind-merge";
twMerge("p-2 p-4 hover:p-6");

// 替换后
import { cn } from "cn";
cn("p-2 p-4 hover:p-6"); // => "p-4 hover:p-6"

迁移完你会发现一个额外好处:cn 的 TypeScript 类型会推断出「同类 utility 不能同时出现」的 lint 错误,把这类 bug 拦在编译期。

Sources

本文参考 shadcn-ui/cn GitHub 仓库 README 与 Issue #12「Why a new engine?」、r/ClaudeAI 上「tailwind-merge vs cn」的迁移讨论串,以及 GitHub Trending 2026-09-06 的上榜记录。所有代码示例均可在 npm install cn tailwindcss 后直接运行。