7个变换器管中窥豹:shadcn CLI如何用AST安全修改你的源码

一、引言:一个真实的痛点

2025年的一个周二下午,我在公司KMS知识管理平台上接到一个需求:把项目中所有 @/components/ui/button 的导入路径批量替换为 @workspace/ui/components/button

"简单,"我想,“一个正则就搞定了。”

sed -i 's|@/components/ui/|@workspace/ui/components/|g' src/**/*.tsx

跑完一看:300 多个文件被修改,看起来没问题。但 Code Review 时同事指出一个致命问题——字符串字面量也被替换了:

// 这行也被改了!
const docPath = "@/components/ui/button/docs.md"
// 变成了:
const docPath = "@workspace/ui/components/button/docs.md"

正则替换,它根本不知道什么是"代码",什么是"注释",什么是"字符串"。它只是在文本层面做暴力替换。

这引出了一个前端工程化领域里被严重低估的问题:如何安全地在代码层面做批量变换?

答案藏在 shadcn/ui 这个 GitHub 70k+ Star 的开源项目里。它的 CLI 工具用了一套 AST 变换管道,用 7 个变换器串联,把组件代码从 registry 注册表安全地"编译"成用户项目中的代码。

今天我们就来深入拆解这套管道架构,看看业界顶尖的开源工具是如何优雅解决这个问题的。

说到 shadcn/ui,对前端开发者来说应该不陌生。它的核心理念是"复制粘贴组件,而不是安装依赖"——你用 npx shadcn@latest add button 这样的命令把组件源码下载到自己的项目里,然后随意修改。这种模式给了开发者极大的自由度,但也带来了一个工程挑战:组件代码存储在官方的 registry 注册表里,使用的是标准的 shadcn 约定(比如 @/components/ui/ 这样的导入路径),而每个用户的目录结构和配置千差万别。怎么把这一套通用代码"编译"成适配不同项目形态的代码?

这就需要一个安全、可靠、可扩展的代码变换系统。而这个系统的核心就是 AST 变换管道。

本文适合有以下需求的读者:

  • 正在做代码重构或批量迁移的前端工程师
  • 对 shadcn/ui 内部实现感兴趣的开发者
  • 想了解 ts-morph 和 Compiler API 实际应用的 TypeScript 用户
  • 需要实现自定义代码生成或代码变换工具的技术负责人

二、正则 vs AST:两种路线的对决

在进入正题之前,咱们先建立一个共识。先看一张表格:

维度正则替换AST 变换
理解层次文本 / 字符语法结构
能否区分代码与字符串不能能(区分 StringLiteral / ImportDeclaration)
能否处理嵌套结构极难天然支持
能否感知 JSX 属性不能能(JsxAttribute / StringLiteral)
误伤率高(注释、字符串、模板都可能被改)接近零
实现复杂度中高
执行速度较慢(但可接受)
可维护性差(正则地狱)好(每个变换器独立)

如果你用正则去替换 cn() 函数调用里的类名,你可能会写出这样的正则:

(?<=cn\(["'][^"']*)class-name(?=[^"']*["'])

这个正则能工作吗?勉强。但当遇到嵌套调用时——cn("base", condition && "active")——正则几乎无能为力。

而 AST 是怎么做的?它先解析整个文件成抽象语法树,然后在树上精准定位到 CallExpression → Arguments → StringLiteral,再修改它。

这就是精准手术瞎猫碰死耗子的区别。


三、为什么是 ts-morph?

shadcn CLI 选择了 ts-morph 这个库,而不是直接使用 TypeScript Compiler API。

这里是一个容易被踩的坑:TypeScript Compiler API 虽然强大,但直接使用的体验很糟糕

给一个直观的对比:

// 原生 TypeScript Compiler API:查询文件中的所有 ImportDeclaration
const imports: ts.ImportDeclaration[] = [];
function visit(node: ts.Node) {
  if (ts.isImportDeclaration(node)) {
    imports.push(node);
  }
  ts.forEachChild(node, visit);
}
ts.forEachChild(sourceFile, visit);

// ts-morph:同样的事情
const imports = sourceFile.getImportDeclarations();

ts-morph 是对 TypeScript Compiler API 的封装包装器。它提供了面向对象的 API,链式调用友好,类型完善。shadcn CLI 用它做了以下几件事:

  1. 创建内存文件系统中的虚拟源文件(不会写入磁盘)
  2. 遍历 AST 节点getDescendantsOfKind(SyntaxKind.XXX)
  3. 修改节点replaceWithText, setLiteralValue, remove
  4. 输出最终文本sourceFile.getText()

它本质上是把 TypeScript/TSX 代码当作文档对象模型来操作——理解代码的结构,而不仅仅是代码的文本。


四、管道架构:一条流水线上的七个工人

理解了 ts-morph 这个工具后,我们来看 shadcn CLI 是如何组织这 7 个变换器的。

在这里插入图片描述

核心入口代码极其简洁——遍历变换器数组,依次调用:

// packages/shadcn/src/utils/transformers/index.ts
export async function transform(
  opts: TransformOpts,
  transformers: Transformer[] = [
    transformImport,    // 1. 导入路径
    transformRsc,       // 2. RSC 指令
    transformCssVars,   // 3. CSS 变量
    transformTwPrefixes,// 4. Tailwind 前缀
    transformRtl,       // 5. RTL 支持
    transformIcons,     // 6. 图标替换
    transformCleanup,   // 7. 清理标记
  ]
) {
  const tempFile = await createTempSourceFile(opts.filename)
  const sourceFile = project.createSourceFile(tempFile, opts.raw, {
    scriptKind: ScriptKind.TSX,
  })

  for (const transformer of transformers) {
    await transformer({ sourceFile, ...opts })
  }

  return sourceFile.getText()
}

这里有几个值得细看的设计点:

1. 为什么用临时文件而不是内存文件系统? shadcn CLI 最初使用的是 ts-morph 的 useInMemoryFileSystem,后来才切换到 tmpdir() 创建真实临时文件。原因是内存文件系统在处理 TypeScript 模块解析时存在严重局限性——当一个 SourceFile 引用了另一个模块时,ts-morph 需要实际的文件路径来解析类型和符号信息。真实临时文件可以让 ts-morph 像处理普通项目文件一样,充分利用其类型推断能力。

这个决策背后是一个重要的取舍:内存操作更快但功能受限,文件操作稍慢(其实差异微乎其微)但功能完整。在"安全正确"面前,"快几毫秒"毫无意义。

2. 统一接口。 所有变换器都遵循同一个 Transformer 类型签名:

export type Transformer<Output = SourceFile> = (
  opts: TransformOpts & {
    sourceFile: SourceFile
  }
) => Promise<Output>

接收同一个 SourceFile 对象,返回同一个。这保证了管道的不可变性——每个变换器只读自己需要的字段,修改 AST,然后原样传递。

3. 链式处理,无状态。 每个变换器只关心自己的职责,不依赖前一个变换器的输出(除了 AST 本身)。这意味着你可以随意排列、增减变换器,而不会破坏管道。


五、七大变换器逐一拆解

5.1 transformImport — 导入路径的"搬家大师"

这是管道第一环,也是最常用的一环。

它做了什么? 遍历所有 ImportDeclaration,根据用户的 config.aliases 配置,替换导入路径。

比如你在自己的项目里定义了:

{
  "aliases": {
    "components": "@workspace/components",
    "ui": "@workspace/components/ui",
    "lib": "@workspace/lib",
    "utils": "@workspace/lib/utils",
    "hooks": "@workspace/hooks"
  }
}

那么:

// 变换前
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { useMediaQuery } from "@/hooks/use-media-query"

// 变换后
import { Button } from "@workspace/components/ui/button"
import { cn } from "@workspace/lib/utils"
import { useMediaQuery } from "@workspace/hooks/use-media-query"

实现关键: 它用 sourceFile.getImportStringLiterals() 获取所有导入的字符串字面量,而不是用正则去匹配 import ... from "..." 模式。这一点看似微小,却彻底杜绝了误伤字符串字面量的可能。

还有一个巧妙的处理:对于 cn 工具函数的导入,它会单独判断。因为这涉及到项目的 utils 别名可能指向 @/lib/utils 也可能指向 #/lib/utils,需要通过 getWorkspaceAliasFromUtilsAlias 函数做特殊处理。

5.2 transformRsc — “use client” 指令守卫

React Server Components 时代,每个客户端组件文件顶部需要 "use client" 指令。但并非所有项目都用 RSC 模式。

这个变换器的逻辑极简:

export const transformRsc: Transformer = async ({ sourceFile, config }) => {
  if (config.rsc) {
    return sourceFile  // 用户项目是 RSC 模式,保留 "use client"
  }

  // 非 RSC 模式,移除 "use client"
  const first = sourceFile.getFirstChildByKind(SyntaxKind.ExpressionStatement)
  if (first && /^["']use client["']$/g.test(first.getText())) {
    first.remove()
  }

  return sourceFile
}

设计哲学:按需保留,默认移除。 这种"白名单"式策略比"黑名单"更安全——只有明确声明需要 RSC,才保留指令。

你可能会问:为什么要单独设计一个变换器来处理一行代码?直接把 "use client" 写在 registry 组件的模板里不就行了吗?

问题在于 shadcn/ui 要同时支持 RSC 和传统 React 两种模式。如果 registry 里的组件模板不带 "use client",那么 RSC 用户就会报错;如果带了,非 RSC 用户就会有多余的指令。用一个条件性的 AST 变换器来处理,比维护两套模板要优雅得多。

这就是 AST 管道的另一个优势:它让同一个组件源码可以输出到完全不同的目标环境。

5.3 transformCssVars — CSS 变量的"颜色翻译官"

这个变换器处理的是一个非常实际的问题:shadcn/ui 的组件用的是语义化 CSS 变量,比如 bg-backgroundtext-foreground。但有些用户可能不想用 CSS 变量体系,而是希望使用内联颜色值

于是,变换器把:

// 变换前
<div className="bg-background text-foreground border-border" />

// 变换后
<div className="bg-white dark:bg-slate-950 text-slate-900 dark:text-slate-50 border border-slate-200 dark:border-slate-800" />

核心算法:

输入: "hover:bg-background/80 text-foreground"
       │
       ▼ [splitClassName]
  ["hover", "bg-background", "80"]
  [null, "text-foreground", null]
       │
       ▼ [applyColorMapping + baseColor.inlineColors 映射表]
       │
  light: "hover:bg-white/80 text-slate-900"
  dark:  "dark:hover:bg-slate-950/80 dark:text-slate-50"
       │
       ▼ [合并]
  "hover:bg-white/80 text-slate-900 dark:hover:bg-slate-950/80 dark:text-slate-50"

这个变换器的 splitClassName 函数非常值得学习——它正确处理了 Tailwind 类名中的冒号嵌套问题:

// 正确处理括号内的冒号
"sm:group-data-[size=default]/alert-dialog-content:text-left"
// → variant: "sm:group-data-[size=default]/alert-dialog-content"
// → value: "text-left"

它不是简单地用 split(":") 切分,而是从右向左扫描,追踪括号深度,找到最后一个不在括号内的冒号。这个细节处理不好,变体选择器就会被错误拆分。

5.4 transformTwPrefixes — Tailwind 类名前缀保镖

如果用户的 Tailwind 配置里有 prefix: "tw-",那么所有的工具类都需要加 tw- 前缀以避免与其他 CSS 框架冲突。

这个变换器是管道里最复杂的一个——它要处理 4 种情况:

场景示例变换后
className 字符串className="flex items-center"className="tw-flex tw-items-center"
cn() 函数调用cn("flex", condition && "active")cn("tw-flex", condition && "tw-active")
cva() 调用cva("flex items-center", {...})cva("tw-flex tw-items-center", {...})
classNames 属性classNames="flex"classNames="tw-flex"

核心难点:模板字符串中的条件表达式。

// 需要处理这种嵌套结构
cn("base-styles", isActive && "active-styles")
// 既要改 "base-styles",也要改 "active-styles"

代码中通过 SyntaxKind.ConditionalExpressionSyntaxKind.BinaryExpression 来识别这种模式,然后递归获取其中的 StringLiteral 子节点。

if (node.isKind(SyntaxKind.ConditionalExpression) ||
    node.isKind(SyntaxKind.BinaryExpression)) {
  node.getChildrenOfKind(SyntaxKind.StringLiteral)
    .forEach((node) => {
      node.replaceWithText(`"${applyPrefix(...)}"`)
    })
}

5.5 transformRtl — 国际化布局的"镜像大师"

RTL(Right-to-Left)语言(阿拉伯语、希伯来语等)需要布局完全镜像。shadcn CLI 的 RTL 变换器是整个管道里最惊艳的一环。

它有 5 种变换策略

在这里插入图片描述

举例来说,一个按钮组件在 config.rtl = true 时:

// 变换前
<button className="ml-4 flex cursor-w-resize text-left">
  {children}
</button>

// 变换后
<button className="ms-4 flex cursor-w-resize rtl:cursor-e-resize text-start">
  {children}
</button>

不仅仅是 className,这个变换器还处理了 组件 prop 的 RTL 映射

// 变换前
<ContextMenuContent side="right" />

// 变换后
<ContextMenuContent side="inline-end" />

最后它还能正确跳过已经带有物理侧边变体的类名,避免二重转换:

// data-[side=left]:-right-1 中的 -right-1 是相对于 side=left 的定位
// 不应该被变换为 data-[side=left]:-end-1
const isPhysicalSideVariant =
  variant?.includes("data-[side=left]") ||
  variant?.includes("data-[side=right]")

5.6 transformIcons — 图标库的无感切换

shadcn/ui 2.0 之后支持多种图标库:Lucide、Tabler、HugeIcons 等。在 registry 注册表里,图标是用 IconPlaceholder 占位符表示的:

// Registry 中的原始代码
<IconPlaceholder lucide="Check" tabler="IconCheck" className="h-4 w-4" />

当用户选择了 Lucide 图标库时,变换器会:

// 变换后
<LuCheck className="h-4 w-4" />

同时自动生成导入语句:

import { LuCheck } from "lucide-react"

有趣的设计:多库支持。 IconPlaceholder 组件上可以同时携带多个图标库的名称映射:

<IconPlaceholder
  lucide="Check"
  tabler="IconCheck"
  hugeicons="CheckmarkCircle01"
/>

变换器根据 config.iconLibrary 的值选择对应的 prop,然后移除所有其他库的 prop。这种设计让同一个组件可以适配多个图标生态。

5.7 transformCleanup — 收尾清理工

最后一道工序,处理变换过程中产生的临时标记。

前几个变换器可能会在处理过程中留下 cn-* 格式的标记类名(类似编译器中间代码),这些标记在最终输出中不应该出现。

// 变换前(含临时标记)
<div className="cn-rtl-flip cn-font-heading flex items-center" />

// 变换后(移除临时标记,保留特殊标记)
<div className="cn-font-heading flex items-center" />

注意 cn-font-heading 被保留了——它是字体变换器的标记,需要在后续的 transformFont 阶段单独处理。所以这里的清理逻辑是有选择性地移除,而不是一刀切。

这个变换器还处理了一个容易被忽略的边界情况:如果 className 里的所有内容都是临时标记,清理后变成空字符串,那就应该把整个 className 属性移除,而不是留下一个空的 className=""

if (newValue === "") {
  // 移除整个属性
  attributesToRemove.push(attr)
}

六、从源码中学到的五个设计原则

通读完这整套变换器代码,我提炼出五个可以迁移到日常工作的设计原则:

原则一:关注点分离到极致

每个变换器文件(transform-xxx.ts)加上对应的测试文件(transform-xxx.test.ts),形成独立的模块。它们之间不存在循环依赖,新加一个变换器只需要在数组里多写一个元素。

在 KMS 项目中,我们也模仿了这个模式:把"代码生成管道"拆成 normalizeImports → addLicenseHeader → injectDeps → prettify 四个独立步骤。

原则二:白名单优于黑名单

transformRsc 里,默认移除 "use client",只有用户明确声明 config.rsc = true 时才保留。transformRtl 同理。默认不做多余的事情,这是一种防御性设计。

原则三:永远用语义化 API 而非字符串匹配

// 不要这样
sourceFile.getText().replace(/import.*from.*/g, ...)

// 要这样
sourceFile.getImportStringLiterals().forEach(...)
sourceFile.getImportDeclarations().forEach(...)

AST 提供了精准的语义化节点查询,不需要自己解析字符串。

原则四:处理所有合法的 AST 结构

一个 className 可能出现在:

  • StringLiteral 直接值:className="flex"
  • JsxExpression 表达式:className={cn("flex")}
  • ConditionalExpression 条件:cn(isActive && "flex")
  • BinaryExpression 二元运算:同上
  • TemplateExpression 模板:cn(\flex ${dynamic}`)`
  • NoSubstitutionTemplateLiteral:全静态模板

transformTwPrefixestransformRtl 都逐一处理了这些情况。遗漏任何一种,管道就会"漏水"。

原则五:中间标记要可追溯、可清理

cn-rtl-flipcn-font-heading 这些标记类名是变换器的"内部通信协议"。它们有清晰的命名约定(cn- 前缀),有明确的生命周期(某个变换器产生,transformCleanup 清理),不会泄漏到最终输出中。


七、生产环境避坑指南:三个真实踩坑经历

踩坑一:临时文件 vs 内存文件系统

现象: 在 CI 环境里运行 shadcn CLI,报错 “ENOENT: no such file or directory”。

根因: shadcn 用 tmpdir() 创建临时文件来承载 SourceFile。在某些 CI 容器里,/tmp 目录可能不存在或无写权限。

解决: 检查 CI 执行环境的临时目录配置,确保 TMPDIR 环境变量指向一个可写目录。或者 Fork 源码,在 createTempSourceFile 里加 fallback 到 process.cwd() + '/.shadcn-tmp'

踩坑二:cn() 函数包裹的条件表达式遗漏

现象: 开启了 Tailwind prefix 功能后,部分条件类名没有被加前缀。具体是 cn("base", condition && "px-4") 里的 "px-4" 没变成 "tw-px-4"

根因: 早期版本的 transformTwPrefixes 只处理了 StringLiteral 类型的参数,没有处理 ConditionalExpression 的右侧分支。

解决: 当前版本已经修复——对所有参数做了 SyntaxKind 分支判断:

callExpression.getArguments().forEach((node) => {
  if (node.isKind(SyntaxKind.ConditionalExpression) ||
      node.isKind(SyntaxKind.BinaryExpression)) {
    node.getChildrenOfKind(SyntaxKind.StringLiteral)
      .forEach((node) => { /* 变换 */ })
  }
  if (node.isKind(SyntaxKind.StringLiteral)) {
    /* 变换 */
  }
})

踩坑三:AST 修改后格式变丑

现象: 经过 7 个变换器处理后,输出代码的缩进和换行变得混乱。

根因: ts-morph 的 replaceWithText 只是替换节点文本,不会自动格式化。经过多次替换后,代码格式自然变差。

解决: 在管道最后添加 prettier 格式化步骤。shadcn CLI 使用 sourceFile.getText() 获取原始输出,然后在写入文件前调用 prettier API 格式化。这是一个**“先保证正确,再追求美观”**的策略。

具体来说,代码流程是:

// 1. 跑完所有变换器
for (const transformer of transformers) {
  await transformer({ sourceFile, ...opts })
}

// 2. 获取原始文本(格式可能是乱的)
const rawText = sourceFile.getText()

// 3. 用 prettier 格式化
const formatted = await prettier.format(rawText, {
  parser: 'typescript',
  ...userPrettierConfig  // 读取用户项目的 prettier 配置
})

// 4. 写入文件
await fs.writeFile(targetPath, formatted)

关键在于读取用户项目的 prettier 配置,这样输出代码的格式能和用户项目的风格保持一致。

踩坑四(额外赠送):变换器加载顺序的重要性

现象: 如果你把 transformTwPrefixes 放在 transformCssVars 前面,CSS 变量替换会失效。

根因: transformCssVars 依赖原始类名(比如 bg-background)做颜色映射。如果先被 transformTwPrefixes 加上了 tw- 前缀,类名变成了 tw-bg-background,而 applyColorMapping 里的 PREFIXES["bg-", "text-", "border-", "ring-offset-", "ring-"],不会匹配到 tw-bg-。结果就是颜色映射被静默跳过。

解决: 管道的顺序不是随意的——它反映了变换依赖关系。transformCssVarstransformTwPrefixes 之前执行,确保颜色映射基于原始类名。这个顺序是在经历多次 Bug 之后沉淀下来的最佳实践。


八、如果自己实现一个迷你管道:从零搭建示例

看到这里,你可能会想:“理念很好,但我能自己实现一个吗?”

完全可以。下面是一个极简的示例——只处理导入路径替换和 Tailwind 前缀。你可以把它作为起点:

import { Project, ScriptKind, SyntaxKind } from "ts-morph"

const project = new Project({ compilerOptions: {} })

// 变换器类型定义
type TransformFn = (sourceFile: SourceFile) => void

// 变换器 1:替换导入路径
const replaceImports: TransformFn = (sourceFile) => {
  sourceFile.getImportStringLiterals().forEach((node) => {
    const path = node.getLiteralValue()
    if (path.startsWith("@/components/ui/")) {
      node.setLiteralValue(
        path.replace("@/components/ui/", "@workspace/ui/components/")
      )
    }
  })
}

// 变换器 2:给 Tailwind 类名加前缀
const addTwPrefix: TransformFn = (sourceFile) => {
  sourceFile.getDescendantsOfKind(SyntaxKind.JsxAttribute)
    .filter((attr) => attr.getNameNode().getText() === "className")
    .forEach((attr) => {
      const init = attr.getInitializer()
      if (init?.isKind(SyntaxKind.StringLiteral)) {
        const classes = init.getLiteralValue()
          .split(" ")
          .map((c) => `tw-${c}`)
          .join(" ")
        init.setLiteralValue(classes)
      }
    })
}

// 管道执行引擎
function runPipeline(code: string, transforms: TransformFn[]) {
  const sourceFile = project.createSourceFile("temp.tsx", code, {
    scriptKind: ScriptKind.TSX,
    overwrite: true,
  })
  for (const fn of transforms) {
    fn(sourceFile)
  }
  return sourceFile.getText()
}

// 使用
const input = `
import { Button } from "@/components/ui/button"
export default () => <div className="flex items-center gap-4">Hello</div>
`

const output = runPipeline(input, [replaceImports, addTwPrefix])
console.log(output)
// 输出:
// import { Button } from "@workspace/ui/components/button"
// export default () => <div className="tw-flex tw-items-center tw-gap-4">Hello</div>

虽然这个示例只有 50 行代码,但它已经具备了管道模式的核心要素:统一的变换器接口、可组合的变换步骤、基于 AST 而非正则的安全修改。

从这个小种子开始,你可以逐步扩展——加上测试、加上错误处理、加上格式化步骤——最终长成一棵和 shadcn CLI 一样健壮的大树。


九、总结

回顾一下我们从这篇文章中学到了什么:

在这里插入图片描述

如果用一句话总结:AST 变换的价值不在于"能做什么",而在于"能安全地做什么"

正则替换也能改文件,但只有 AST 能区分 import { X } from "@/path"const docPath = "@/path"。在金融科技这种零容错的环境里,这种"安全"不是锦上添花,而是底线。

回到开头那个场景:在 KMS 项目中,我们把导入路径替换从正则方案切换到了基于 ts-morph 的 AST 管道方案后,零误伤,零线上事故

下次当你在做代码迁移、重构、批量替换时,不妨问问自己:我是在修文本,还是在修代码?

如果是后者,AST 变换管道就是你工具箱里不可或缺的一把手术刀。


本文代码示例基于 shadcn/ui 源码分析,版本为 2025 年 6 月最新 main 分支。


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值