写在前面:在 Monorepo 里,新建一个组件、一个业务模块(feature)、一个共享 package,往往要手动复制一堆文件、改类名、补 export、建测试文件……重复又容易出错。很多团队第一反应是"写个脚本来复制文件",结果脚本越堆越多、到处失效、没人敢改。

这篇文章带你把"复制粘贴脚本"升级成一个真正可维护的工程化代码生成器:用 Commander + Inquirer + Handlebars 搭一套 Vue/React 通用的 pnpm repo generate CLI,交互式 + 参数式双模式,支持工作区自动扫描、防覆盖、按框架(Vue/React)生成不同结构。最后给出一条稳扎稳打的演进路线,让你避开"上来就改 AST 导致维护噩梦"的大坑。

全文源码可直接 copy 上手,适合有一定前端工程化基础的同学。


📖 目录

  1. 我们要解决什么问题?
  2. 技术选型:为什么是这一套?
  3. 最终目录结构
  4. 创建 CLI 包并装依赖
  5. 配置 package.json / tsconfig / tsup
  6. 实现 CLI 入口与命令注册
  7. 工作区扫描(核心!别把 app 写死)
  8. 命名转换 + 校验
  9. 模板渲染(含 Vue {{}} 转义大坑)
  10. Vue / React 组件模板与生成器
  11. feature 业务模块生成器(通用映射,告别手写一堆 render)
  12. 根目录接入 CLI(三种方式对比)
  13. 路由自动注册:import.meta.glob 远胜 ts-morph(强烈推荐)
  14. 第一版范围(只做该做的)+ 完整演进路线
  15. 避坑速查(踩过的真实坑)
  16. 总结

一、我们要解决什么问题?

先看一个真实痛点。假设你的 Monorepo 里有 vue-adminreact-admin 两个应用,每次加一个组件 UserCard,你都要:

  1. src/components/ 下新建 UserCard/ 目录
  2. 新建 UserCard.vue(或 .tsx
  3. 新建 types.ts 定义 Props
  4. 新建 index.ts 做 export
  5. 可能还要补一个 UserCard.test.ts
  6. 如果是业务模块(feature),还要建 api/stores/views/……

一个组件 5-6 个文件,一次复制粘贴几分钟,还容易漏改类名、漏建测试。 一年下来几百次重复劳动。

目标:把它变成一条命令——

pnpm generate component
# 或参数式(CI/AI 友好)
pnpm generate component --app vue-admin --name UserCard --framework vue

敲回车,规范目录 + 测试文件 + 正确命名全部就位,且绝不会覆盖已有文件


二、技术选型:为什么是这一套?

干什么 为什么选它
Commander 命令 & 参数解析、--help Node 生态最成熟,子命令管理清晰
@inquirer/prompts 交互式提问(选择/输入/确认) 比老的 inquirer 更现代,TS 支持好
Handlebars 模板变量替换 {{name}} 语法简单,模板文件可读性高
fs-extra 文件复制/创建/检查 比 node fs 多了 ensureDir/readJson 等好用 API
fast-glob 查找 apps/*packages/* 扫描工作区,新增 app 不用改代码
picocolors 终端彩色输出 体积小、性能好
change-case 命名格式转换(kebab/pascal/camel) 一个库搞定所有命名转换
tsup 打包 CLI 成单文件 基于 esbuild,秒级打包
tsx 本地开发直接跑 .ts 免编译,改完即跑,开发体验拉满
ts-morph(后期) 修改 TS 源码 AST(如路由) 第一版别上,见第十五节

💡 核心思路:Commander 管"命令怎么解析",package.jsonbin 字段把构建后的 JS 注册成可执行命令。


三、最终目录结构

在你的 frontend-monorepo-starter 中新增 tooling/repo-cli/

frontend-monorepo-starter/
├─ apps/
│  ├─ vue-admin/
│  └─ react-admin/
├─ packages/
│  ├─ http/
│  ├─ utils/
│  └─ ui/
├─ tooling/
│  └─ repo-cli/
│     ├─ src/
│     │  ├─ commands/           # 命令注册(把选项挂到 commander)
│     │  │  ├─ generate.ts
│     │  │  ├─ component.ts
│     │  │  ├─ feature.ts
│     │  │  ├─ package.ts
│     │  │  └─ app.ts
│     │  ├─ generators/         # 真正干活的生成逻辑
│     │  │  ├─ component.generator.ts
│     │  │  ├─ feature.generator.ts
│     │  │  └─ package.generator.ts
│     │  ├─ templates/          # Handlebars 模板
│     │  │  ├─ vue-component/
│     │  │  ├─ react-component/
│     │  │  ├─ vue-feature/
│     │  │  └─ react-feature/
│     │  ├─ utils/              # 工具函数
│     │  │  ├─ paths.ts
│     │  │  ├─ workspace.ts
│     │  │  ├─ template.ts
│     │  │  ├─ naming.ts
│     │  │  └─ validation.ts
│     │  ├─ types.ts
│     │  └─ cli.ts              # 入口
│     ├─ package.json
│     ├─ tsconfig.json
│     └─ tsup.config.ts
├─ package.json
├─ pnpm-workspace.yaml
└─ turbo.json

关键设计:CLI 和模板都放在仓库里不发布到公共 npm。团队 clone 仓库就能用,版本和业务代码一起演进。


四、创建 CLI 包并装依赖

mkdir -p tooling/repo-cli
cd tooling/repo-cli
pnpm init

安装运行时依赖:

pnpm add commander @inquirer/prompts handlebars fs-extra fast-glob picocolors change-case

安装开发依赖:

pnpm add -D typescript tsx tsup @types/node @types/fs-extra

🔧 后期需要自动修改路由/源码时再装:pnpm add ts-morph第一版强烈建议先不装,原因见第十五节)。


五、配置 package.json / tsconfig / tsup

5.1 tooling/repo-cli/package.json

{
  "name": "@repo/repo-cli",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "bin": {
    "repo": "./dist/cli.js"
  },
  "scripts": {
    "dev": "tsx src/cli.ts",
    "build": "tsup",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@inquirer/prompts": "^7.0.0",
    "change-case": "^5.4.4",
    "commander": "^14.0.0",
    "fast-glob": "^3.3.3",
    "fs-extra": "^11.3.0",
    "handlebars": "^4.7.8",
    "picocolors": "^1.1.1"
  },
  "devDependencies": {
    "@types/fs-extra": "^11.0.4",
    "@types/node": "^24.0.0",
    "tsx": "^4.20.0",
    "tsup": "^8.5.0",
    "typescript": "^5.8.0"
  }
}

⚠️ 注意三点

  1. "type": "module" —— 用 ESM,import 要带 .js 后缀。
  2. "bin" —— 把构建产物 dist/cli.js 注册成 repo 命令。
  3. 版本号按你实际安装的为准。

5.2 tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "skipLibCheck": true,
    "types": ["node"],
    "outDir": "dist"
  },
  "include": ["src/**/*.ts"]
}

5.3 tsup.config.ts

import { defineConfig } from 'tsup'

export default defineConfig({
  entry: ['src/cli.ts'],
  format: ['esm'],
  platform: 'node',
  target: 'node20',
  clean: true,
  sourcemap: true,
  banner: {
    js: '#!/usr/bin/env node',
  },
})

⚠️ banner 里的 #!/usr/bin/env node 非常重要!它告诉操作系统用 Node.js 执行这个文件,少了它 bin 注册的命令会报 “exec format error”。

六、实现 CLI 入口与命令注册

6.1 入口 src/cli.ts

#!/usr/bin/env node
import { Command } from 'commander'
import { registerGenerateCommand } from './commands/generate.js'

const program = new Command()

program
  .name('repo')
  .description('Frontend Monorepo code generator')
  .version('0.1.0')

registerGenerateCommand(program)

program.parseAsync(process.argv).catch((error: unknown) => {
  const message = error instanceof Error ? error.message : String(error)
  console.error(`\nCLI 执行失败:${message}`)
  process.exitCode = 1
})

Command 负责命令解析、参数解析、--help、错误提示、子命令管理。parseAsync 配合 try/catch 避免进程崩溃时把原生报错直接甩给用户。

6.2 注册 generate 命令 src/commands/generate.ts

import type { Command } from 'commander'
import { generateComponent } from '../generators/component.generator.js'
import { generateFeature } from '../generators/feature.generator.js'
import { generatePackage } from '../generators/package.generator.js'

export function registerGenerateCommand(program: Command): void {
  const generate = program
    .command('generate')
    .alias('g')
    .description('生成代码')

  generate
    .command('component')
    .alias('c')
    .description('生成 Vue 或 React 组件')
    .option('-a, --app <app>', '目标应用')
    .option('-n, --name <name>', '组件名称')
    .option('-f, --framework <framework>', 'vue 或 react')
    .option('--no-test', '不生成测试文件')
    .action(generateComponent)

  generate
    .command('feature')
    .alias('f')
    .description('生成业务模块')
    .option('-a, --app <app>', '目标应用')
    .option('-n, --name <name>', '模块名称')
    .option('-f, --framework <framework>', 'vue 或 react')
    .action(generateFeature)

  generate
    .command('package')
    .alias('p')
    .description('生成共享 package')
    .option('-n, --name <name>', 'package 名称')
    .action(generatePackage)
}

这样就支持两种调用方式

# 交互式(缺什么问什么)
pnpm repo generate component

# 参数式(一把梭,适合 CI / AI 调用)
pnpm repo generate component \
  --app vue-admin \
  --name UserCard \
  --framework vue

🎯 设计要点action 接收的 options 里某个字段没传(undefined),就进交互式提问补全;传了就直接用。这是"双模式"的关键,后面生成器里会体现。


七、工作区扫描(核心!别把 app 写死)

新手最常犯的错:把 vue-adminreact-admin 写死在代码里。结果一加新应用就得改 CLI 源码。正确做法是动态扫描

src/utils/workspace.ts

import path from 'node:path'
import fg from 'fast-glob'
import fs from 'fs-extra'

export interface WorkspaceApp {
  name: string
  path: string
  framework: 'vue' | 'react' | 'unknown'
}

// 向上查找 pnpm-workspace.yaml,定位 monorepo 根目录
export async function findWorkspaceRoot(
  startDirectory = process.cwd(),
): Promise<string> {
  let currentDirectory = path.resolve(startDirectory)
  while (true) {
    const workspaceFile = path.join(currentDirectory, 'pnpm-workspace.yaml')
    if (await fs.pathExists(workspaceFile)) {
      return currentDirectory
    }
    const parentDirectory = path.dirname(currentDirectory)
    if (parentDirectory === currentDirectory) {
      throw new Error('没有找到 pnpm-workspace.yaml,请在 Monorepo 中执行命令')
    }
    currentDirectory = parentDirectory
  }
}

// 扫描 apps/*,通过依赖判断框架
export async function getWorkspaceApps(
  rootDirectory: string,
): Promise<WorkspaceApp[]> {
  const packageFiles = await fg('apps/*/package.json', {
    cwd: rootDirectory,
    absolute: true,
  })

  return Promise.all(
    packageFiles.map(async (packageFile) => {
      const packageJson = await fs.readJson(packageFile)
      const dependencies = {
        ...packageJson.dependencies,
        ...packageJson.devDependencies,
      }
      let framework: WorkspaceApp['framework'] = 'unknown'
      if (dependencies.vue) framework = 'vue'
      else if (dependencies.react) framework = 'react'

      const appDirectory = path.dirname(packageFile)
      return {
        name: path.basename(appDirectory),
        path: appDirectory,
        framework,
      }
    }),
  )
}

💡 妙处:新增应用后,生成器零改动自动识别。通过 dependencies.vue / dependencies.react 还能自动推断框架,不用手动选。下次团队新增一个 uni-appnext-app,扫描逻辑天然适配。


八、命名转换 + 校验

用户输入的名字千奇百怪:user-cardUserCarduser_carduser card,全要统一。

src/utils/naming.ts

import { kebabCase, pascalCase, camelCase } from 'change-case'

export interface NormalizedName {
  kebab: string
  pascal: string
  camel: string
}

export function normalizeName(name: string): NormalizedName {
  const trimmedName = name.trim()
  if (!trimmedName) {
    throw new Error('名称不能为空')
  }
  if (!/^[a-zA-Z][a-zA-Z0-9 _-]*$/.test(trimmedName)) {
    throw new Error(
      '名称只能包含字母、数字、空格、下划线和连字符,并且必须以字母开头',
    )
  }
  return {
    kebab: kebabCase(trimmedName),
    pascal: pascalCase(trimmedName),
    camel: camelCase(trimmedName),
  }
}

效果举例:无论输入下面哪种,都统一转成 ↓

用户输入 kebab pascal camel
user-card user-card UserCard userCard
UserCard user-card UserCard userCard
user_card user-card UserCard userCard
user card user-card UserCard userCard

文件名用 kebaborder-list.ts),类名/组件名用 pascalOrderList),变量名用 camelorderList)——一套规范自动生成,再也不用人工纠结。


九、模板渲染(含 Vue {{}} 转义大坑)

src/utils/template.ts

import path from 'node:path'
import { fileURLToPath } from 'node:url'
import fs from 'fs-extra'
import Handlebars from 'handlebars'

const currentFilePath = fileURLToPath(import.meta.url)
const currentDirectory = path.dirname(currentFilePath)

export function getTemplatesDirectory(): string {
  return path.resolve(currentDirectory, '../templates')
}

export async function renderTemplateFile(
  templatePath: string,
  outputPath: string,
  context: Record<string, unknown>,
): Promise<void> {
  // ✅ 防覆盖:目标文件已存在直接报错,保护已有代码
  if (await fs.pathExists(outputPath)) {
    throw new Error(`目标文件已经存在:${outputPath}`)
  }
  const templateContent = await fs.readFile(templatePath, 'utf8')
  const template = Handlebars.compile(templateContent)
  const outputContent = template(context)
  await fs.ensureDir(path.dirname(outputPath))
  await fs.writeFile(outputPath, outputContent, 'utf8')
}

🛡️ 防覆盖是底线renderTemplateFile 第一步就检查目标文件是否存在。这是把生成器和"危险的复制脚本"区分开的关键——它绝不会悄悄覆盖你的业务代码。一旦报错,就是文件已存在,安全。

十、Vue / React 组件模板与生成器

10.1 Vue 组件模板

目录结构:

src/templates/vue-component/
├─ Component.vue.hbs
├─ Component.test.ts.hbs
├─ types.ts.hbs
└─ index.ts.hbs

Component.vue.hbs —— ⚠️ 注意 Vue 的 {{ }} 和 Handlebars 的 {{ }} 冲突!

<script setup lang="ts">
import type { {{pascalName}}Props } from './types'
withDefaults(defineProps<{{pascalName}}Props>(), {
  title: '',
})
</script>

<template>
  <div class="{{kebabName}}">
    <!--  不能直接写 {{ title }},会被 Handlebars 当成变量 -->
    <!--  方案A:转义 -->
    \{{ title }}
    <!--  方案B:用 v-text 避开插值 -->
    <span v-text="title" />
  </div>
</template>

🔥 这是本方案最大的坑之一(见第十四节详述)。Vue 模板里的 {{ title }} 会被 Handlebars 误解析成模板变量。最稳妥的写法是 v-text,彻底避开双花括号

types.ts.hbs

export interface {{pascalName}}Props {
  title?: string
}

index.ts.hbs

export { default as {{pascalName}} } from './{{pascalName}}.vue'
export type { {{pascalName}}Props } from './types'

Component.test.ts.hbs

import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import {{pascalName}} from './{{pascalName}}.vue'

describe('{{pascalName}}', () => {
  it('renders title', () => {
    const wrapper = mount({{pascalName}}, {
      props: { title: 'Test title' },
    })
    expect(wrapper.text()).toContain('Test title')
  })
})

10.2 React 组件模板

src/templates/react-component/
├─ Component.tsx.hbs
├─ Component.test.tsx.hbs
├─ types.ts.hbs
└─ index.ts.hbs

Component.tsx.hbs(React 用 {title} 单花括号,不冲突,省心):

import type { {{pascalName}}Props } from './types'

export function {{pascalName}}({ title = '' }: {{pascalName}}Props) {
  return (
    <div className="{{kebabName}}">
      {title}
    </div>
  )
}

10.3 组件生成器 src/generators/component.generator.ts

这里体现了**"双模式"的核心**:每个选项 undefined 就走交互提问,有值就跳过。

import path from 'node:path'
import { confirm, input, select } from '@inquirer/prompts'
import pc from 'picocolors'
import fs from 'fs-extra'
import { findWorkspaceRoot, getWorkspaceApps } from '../utils/workspace.js'
import { normalizeName } from '../utils/naming.js'
import { getTemplatesDirectory, renderTemplateFile } from '../utils/template.js'

interface ComponentOptions {
  app?: string
  name?: string
  framework?: 'vue' | 'react'
  test?: boolean
}

export async function generateComponent(options: ComponentOptions): Promise<void> {
  const rootDirectory = await findWorkspaceRoot()
  const apps = await getWorkspaceApps(rootDirectory)
  if (apps.length === 0) {
    throw new Error('当前工作区没有找到任何应用')
  }

  // ① 应用:没传就交互选
  const appName =
    options.app ??
    (await select({
      message: '请选择目标应用',
      choices: apps.map((app) => ({
        name: `${app.name} (${app.framework})`,
        value: app.name,
      })),
    }))

  const targetApp = apps.find((app) => app.name === appName)
  if (!targetApp) {
    throw new Error(`应用不存在:${appName}`)
  }

  // ② 框架:没传,且自动识别失败才交互选
  const framework =
    options.framework ??
    (targetApp.framework === 'unknown'
      ? await select<'vue' | 'react'>({
          message: '请选择框架',
          choices: [
            { name: 'Vue', value: 'vue' },
            { name: 'React', value: 'react' },
          ],
        })
      : targetApp.framework)

  // ③ 名称:没传就交互输入
  const rawName =
    options.name ??
    (await input({
      message: '请输入组件名称',
      validate(value) {
        return value.trim() ? true : '组件名称不能为空'
      },
    }))

  // ④ 测试:没传就交互确认
  const withTest =
    options.test ??
    (await confirm({ message: '是否生成测试文件?', default: true }))

  // 命名规范化
  const name = normalizeName(rawName)

  // 目标目录:apps/<app>/src/components/<Pascal>/
  const targetDirectory = path.join(
    targetApp.path, 'src', 'components', name.pascal,
  )
  if (await fs.pathExists(targetDirectory)) {
    throw new Error(`组件目录已经存在:${targetDirectory}`)
  }

  const templateDirectory = path.join(
    getTemplatesDirectory(), `${framework}-component`,
  )
  const context = {
    pascalName: name.pascal,
    kebabName: name.kebab,
    camelName: name.camel,
  }

  // 通用文件
  await renderTemplateFile(
    path.join(templateDirectory, 'types.ts.hbs'),
    path.join(targetDirectory, 'types.ts'),
    context,
  )
  await renderTemplateFile(
    path.join(templateDirectory, 'index.ts.hbs'),
    path.join(targetDirectory, 'index.ts'),
    context,
  )

  // 框架专属主文件
  if (framework === 'vue') {
    await renderTemplateFile(
      path.join(templateDirectory, 'Component.vue.hbs'),
      path.join(targetDirectory, `${name.pascal}.vue`),
      context,
    )
    if (withTest) {
      await renderTemplateFile(
        path.join(templateDirectory, 'Component.test.ts.hbs'),
        path.join(targetDirectory, `${name.pascal}.test.ts`),
        context,
      )
    }
  } else {
    await renderTemplateFile(
      path.join(templateDirectory, 'Component.tsx.hbs'),
      path.join(targetDirectory, `${name.pascal}.tsx`),
      context,
    )
    if (withTest) {
      await renderTemplateFile(
        path.join(templateDirectory, 'Component.test.tsx.hbs'),
        path.join(targetDirectory, `${name.pascal}.test.tsx`),
        context,
      )
    }
  }

  console.log()
  console.log(pc.green(`✔ 组件 ${name.pascal} 创建成功`))
  console.log(pc.dim(path.relative(rootDirectory, targetDirectory)))
}

运行效果举例

$ pnpm generate component --app vue-admin --name UserCard --framework vue

✔ 组件 UserCard 创建成功
apps/vue-admin/src/components/UserCard

生成的目录:

apps/vue-admin/src/components/UserCard/
├─ UserCard.vue
├─ UserCard.test.ts
├─ types.ts
└─ index.ts

🎉 命名、结构、测试、防覆盖一次到位。再次运行同名命令会直接报错"组件目录已经存在",绝不会覆盖。

十一、feature 业务模块生成器(通用映射,告别手写一堆 render)

组件只是"热身"。真实业务里,一个模块(feature)会带出 api / types / store(hooks) / views / tests 一整套目录。千万别在代码里把 renderTemplateFile() 逐个列出来,维护到吐。用一张映射表统一驱动。

11.1 Vue feature 目标结构

apps/vue-admin/src/features/order/
├─ api/
│  └─ order.api.ts
├─ components/
├─ stores/
│  └─ order.store.ts
├─ types/
│  └─ order.types.ts
├─ views/
│  └─ OrderList.vue
├─ tests/
│  └─ order.store.test.ts
└─ index.ts

11.2 React feature 目标结构

apps/react-admin/src/features/order/
├─ api/
│  └─ order.api.ts
├─ components/
├─ hooks/
│  └─ useOrderList.ts
├─ types/
│  └─ order.types.ts
├─ pages/
│  └─ OrderList.tsx
├─ tests/
│  └─ OrderList.test.tsx
└─ index.ts

对比可见:Vue 用 views + stores(Pinia),React 用 pages + hooks。这正是"按框架生成不同结构"的价值。

11.3 用映射表统一生成

interface TemplateMapping {
  template: string
  output: string
  condition?: boolean   // false 时跳过
}

const mappings: TemplateMapping[] = [
  {
    template: 'api.ts.hbs',
    output: `api/${name.kebab}.api.ts`,
  },
  {
    template: 'types.ts.hbs',
    output: `types/${name.kebab}.types.ts`,
  },
  {
    template: 'store.ts.hbs',
    output: `stores/${name.kebab}.store.ts`,
    condition: framework === 'vue',   // 只有 Vue 生成 store
  },
  {
    template: 'hook.ts.hbs',
    output: `hooks/use-${name.kebab}.ts`,
    condition: framework === 'react', // 只有 React 生成 hook
  },
  {
    template: 'view.vue.hbs',
    output: `views/${name.pascal}List.vue`,
    condition: framework === 'vue',
  },
  {
    template: 'page.tsx.hbs',
    output: `pages/${name.pascal}List.tsx`,
    condition: framework === 'react',
  },
  {
    template: 'index.ts.hbs',
    output: 'index.ts',
  },
]

// 统一渲染
for (const mapping of mappings) {
  if (mapping.condition === false) continue
  await renderTemplateFile(
    path.join(templateDirectory, mapping.template),
    path.join(targetDirectory, mapping.output),
    context,
  )
}

💡 好处:新增一个模板文件,只需在数组里加一行映射;要临时关闭某个文件,把 condition 设成 false 即可。再也不用改一长串 if/else


十二、根目录接入 CLI(三种方式对比)

方式 A:tsx 直跑(推荐第一版用,零构建)

根目录 package.json

{
  "scripts": {
    "repo": "tsx tooling/repo-cli/src/cli.ts",
    "generate": "tsx tooling/repo-cli/src/cli.ts generate"
  }
}
pnpm generate component

✅ 改完 .ts 立刻生效,开发体验最好。第一阶段就用它

方式 B:pnpm filter 转发(开发期)

{
  "scripts": {
    "repo": "pnpm --filter @repo/repo-cli dev --",
    "generate": "pnpm repo generate"
  }
}

⚠️ --filter 后面的参数转发在不同 pnpm 版本写法不一,容易踩坑,不推荐作为主方案

方式 C:build + bin 注册(发布/CI 期,最终形态)

先构建:

pnpm --filter @repo/repo-cli build

dist/cli.js 因为有 #!/usr/bin/env node banner,可通过 bin 注册成可执行命令,在任何地方直接:

pnpm --filter @repo/repo-cli exec repo generate component

🎯 建议路线:开发期用 A(tsx),稳定后用 C(build + bin)。两者共用同一份源码,零迁移成本。


十三、路由自动注册:import.meta.glob 远胜 ts-morph(强烈推荐)

第一版只生成文件,不改路由。后续要接路由时,有两条路。

❌ 路线一:ts-morph 改 AST(能用,但脆)

需要先安装:pnpm add ts-morph。它能把已有路由文件的 AST 读出来再塞一个元素进去:

import { Project, SyntaxKind } from 'ts-morph'

export async function addVueRoute(options: {
  routeFile: string
  routePath: string
  componentPath: string
}): Promise<void> {
  const project = new Project({ tsConfigFilePath: 'tsconfig.json' })
  const sourceFile = project.getSourceFileOrThrow(options.routeFile)
  const declaration = sourceFile.getVariableDeclarationOrThrow('routes')
  const arrayLiteral = declaration.getInitializerIfKindOrThrow(
    SyntaxKind.ArrayLiteralExpression,
  )

  // 防重复
  const exists = arrayLiteral
    .getElements()
    .some((el) => el.getText().includes(`path: '${options.routePath}'`))
  if (exists) {
    throw new Error(`路由已经存在:${options.routePath}`)
  }

  arrayLiteral.addElement(`{
    path: '${options.routePath}',
    component: () => import('${options.componentPath}'),
  }`)
  await sourceFile.save()
}

为什么尽量不用? 因为路由文件的写法千差万别(变量名、嵌套结构、带 meta、children……),AST 改写很容易在某次手改后突然失效,维护成本高。

✅ 路线二:import.meta.glob 自动发现(推荐!)

思路:让运行时自己去扫描,而不是生成器去改源码。生成器只需要生成一个约定文件 features/order/routes.ts,主路由文件用 import.meta.glob 自动加载:

// 主路由:自动发现所有 feature 的 routes.ts
const modules = import.meta.glob('../features/**/routes.ts', {
  eager: true,
})

🚀 这是更稳定的方案。生成器只管"按约定生成文件",主路由用约定自动收敛。约定优于修改——新增模块零侵入,删模块也不会残留脏路由。把 AST 当作"最后兜底手段"保留即可。


十四、第一版范围(只做该做的)+ 完整演进路线

✅ 第一版要实现

pnpm generate component
pnpm generate feature
pnpm generate package

❌ 第一版先不做(这些是"看似美好实则坑深"的功能)

暂不做 为什么
自动修改菜单 菜单结构各异,极易出错
自动修改复杂路由 用 import.meta.glob 代替,见第十三节
自动生成权限码 业务耦合太深
自动调用 Git 多一步失败点,让用户自己 commit
自动执行 pnpm install 可能改坏 lockfile
自动改大量 package.json 用 import.meta.glob / 约定优于配置

🎯 第一版核心四要求

  1. 识别 Vue 和 React 应用(工作区扫描)。
  2. 能生成规范目录 + 测试文件
  3. 防止覆盖已有文件(安全底线)。
  4. 同时支持交互模式和参数模式(人 + CI/AI 都能用)。

🗺️ 完整演进路线

第一阶段:Commander + Inquirer + Handlebars   ← 本文已覆盖
   ↓ 做稳 component / feature / package
第二阶段:增加模板配置 + 工作区扫描增强
   ↓ (如 .repocli.json 自定义模板路径)
第三阶段:增加 import.meta.glob 自动注册路由
   ↓ (生成器只生成约定文件)
第四阶段:必要时使用 ts-morph 修改 AST
   ↓ (仅做兜底,不作为主路径)

这样它才是一个可维护的工程工具,而不是一堆容易失效的文件复制脚本。


十五、避坑速查(踩过的真实坑)

现象 解决
Vue {{ }} 冲突 Handlebars 把 Vue 模板的 {{ title }} 当变量报错或输出空 模板里用 \{{ }} 转义,或改用 v-text 避开插值
#!/usr/bin/env node bin 命令报 “exec format error” tsup banner.js 加上 shebang
import.meta.url 在 CJS 报错 Cannot use import statement package.json 设 "type": "module",tsup 用 esm
.js 扩展名 import 报错 ERR_MODULE_NOT_FOUND NodeNext 下源码 import 必须带 .js 后缀(即使源是 .ts)
pnpm filter 参数转发混乱 命令参数没传进去 开发期直接用 tsx tooling/repo-cli/src/cli.ts
生成器覆盖业务代码 辛苦写的代码被模板冲掉 renderTemplateFile 起手 pathExists 检查
app 写死 新增应用要改 CLI getWorkspaceApps 动态扫描

📌 一句话总结防覆盖 + 双模式 + 工作区扫描 + 约定优于 AST 修改,是这个工具最值得记住的四个设计原则。


总结

把"复制粘贴脚本"做成 repo-cli,关键不在某个 API,而在工程化思维

  • Commander 管命令参数和 --help
  • Inquirer 管交互补全(undefined 才问,实现双模式);
  • Handlebars 管模板渲染(注意 Vue 花括号冲突);
  • fast-glob + fs-extra 管工作区扫描与文件操作;
  • picocolors 管终端输出美观;
  • import.meta.glob(运行时)> ts-morph(AST)做路由收敛。

四阶段演进 稳步推进,第一版守住"component / feature / package + 防覆盖 + 双模式",你就拥有了一个团队级的、可维护的代码生成器,而不是又一个会失效的脚本。

觉得有用欢迎点赞收藏 👍,遇到坑欢迎评论区交流~

更多推荐