【Monorepo 实战】手把手封装一个内部代码生成器 CLI:pnpm repo generate(Vue/React 通用,附完整源码 + 演进路线)
写在前面:在 Monorepo 里,新建一个组件、一个业务模块(feature)、一个共享 package,往往要手动复制一堆文件、改类名、补 export、建测试文件……重复又容易出错。很多团队第一反应是"写个脚本来复制文件",结果脚本越堆越多、到处失效、没人敢改。
这篇文章带你把"复制粘贴脚本"升级成一个真正可维护的工程化代码生成器:用
Commander + Inquirer + Handlebars搭一套 Vue/React 通用的pnpm repo generateCLI,交互式 + 参数式双模式,支持工作区自动扫描、防覆盖、按框架(Vue/React)生成不同结构。最后给出一条稳扎稳打的演进路线,让你避开"上来就改 AST 导致维护噩梦"的大坑。全文源码可直接 copy 上手,适合有一定前端工程化基础的同学。
📖 目录
- 我们要解决什么问题?
- 技术选型:为什么是这一套?
- 最终目录结构
- 创建 CLI 包并装依赖
- 配置 package.json / tsconfig / tsup
- 实现 CLI 入口与命令注册
- 工作区扫描(核心!别把 app 写死)
- 命名转换 + 校验
- 模板渲染(含 Vue
{{}}转义大坑) - Vue / React 组件模板与生成器
- feature 业务模块生成器(通用映射,告别手写一堆 render)
- 根目录接入 CLI(三种方式对比)
- 路由自动注册:import.meta.glob 远胜 ts-morph(强烈推荐)
- 第一版范围(只做该做的)+ 完整演进路线
- 避坑速查(踩过的真实坑)
- 总结
一、我们要解决什么问题?
先看一个真实痛点。假设你的 Monorepo 里有 vue-admin 和 react-admin 两个应用,每次加一个组件 UserCard,你都要:
- 在
src/components/下新建UserCard/目录 - 新建
UserCard.vue(或.tsx) - 新建
types.ts定义 Props - 新建
index.ts做 export - 可能还要补一个
UserCard.test.ts - 如果是业务模块(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.json的bin字段把构建后的 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"
}
}
⚠️ 注意三点:
"type": "module"—— 用 ESM,import 要带.js后缀。"bin"—— 把构建产物dist/cli.js注册成repo命令。- 版本号按你实际安装的为准。
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-admin、react-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-app、next-app,扫描逻辑天然适配。
八、命名转换 + 校验
用户输入的名字千奇百怪:user-card、UserCard、user_card、user 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 |
文件名用
kebab(order-list.ts),类名/组件名用pascal(OrderList),变量名用camel(orderList)——一套规范自动生成,再也不用人工纠结。
九、模板渲染(含 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 / 约定优于配置 |
🎯 第一版核心四要求
- 能识别 Vue 和 React 应用(工作区扫描)。
- 能生成规范目录 + 测试文件。
- 能防止覆盖已有文件(安全底线)。
- 能同时支持交互模式和参数模式(人 + 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 + 防覆盖 + 双模式",你就拥有了一个团队级的、可维护的代码生成器,而不是又一个会失效的脚本。
觉得有用欢迎点赞收藏 👍,遇到坑欢迎评论区交流~
更多推荐


所有评论(0)