(三)「造轮子」我也写了个Vue3脚手架!(命令行读取配置)
本期介绍内容如下
使用commaner读取命令行参数
使用inquirer让用户选取对应配置
删除和生成项目目录
commander读取命令行参数
commander具体文档说明使用见这里
index.ts
#! /usr/bin/env node
import packageJson from './package.json'
import { program } from 'commander'
interface Options {
force?: boolean
}
program
.name(packageJson.name)
.description('cli to create a project of vue3')
.version(packageJson.version)
program
.command('create')
.description('create a new project of vue3')
.argument('<string>', 'project name')
.option('-f, --force', 'overwirte target directory if it already exists')
.action((name: string, options: Options) => {
console.log(name, options)
})
program.parse(process.argv)
我们使用的命令比较简单,用create xxx
命令来创建名为xxx
的项目;配置就使用-f
和--force
用来在当前路径下已经存在xxx
名字的目录时候直接强制删除
#! /usr/bin/env node
这个标识系统应该使用 node
来执行这个脚本文件,咱们开发一个命令行工具这个当然需要带上
修改一下package.json的命令,这里调用create
命令创建叫jqq
的项目
package.json
{
"scripts": {
"create": "npx esno ./index.ts create jqq",
},
}
运行一下程序看下效果,也加上-f
或--force
看看运行效果(这里用npm run create
跑npm script的时候,要用--
符号隔开在npx esno ./index.ts create jqq
后面追加其他参数):

inquirer选取对应配置
我们在index.ts中,用一个 create 函数来写程序的主要创建流程,在 program 的 action 的回调函数中调用create函数
processTargetDirectory
函数是询问用户是否需要对已经存在的目录进行覆盖的,inquireConfig
是询问用户的配置的。这两个函数我们后面介绍
index.ts
const create = async (name: string, options: Options) => {
// 项目目录预处理
await processTargetDirectory(name, options)
// 询问用户需要的配置
const { features } = await inquireConfig()
const needsTypeScript = features.includes('typescript')
const needsJsx = features.includes('jsx')
const needsPrettier = features.includes('prettier')
const needsRouter = features.includes('router')
const needsPinia = features.includes('pinia')
const needsVitest = features.includes('vitest')
const needsEslint = features.includes('eslint')
// 创建一下目录
const targetDir = name
const cwd = process.cwd()
const root = path.join(cwd, targetDir)
fs.mkdirSync(root)
}
processTargetDirectory

先看用户的force
是否有命令行直接指定,没有指定并且已经有对应的目录,就询问用户是否要覆盖对应的目录。然后根据用户的选择来进行操作
-
- 有目录,不能强制覆盖,提示用户并且退出进程
-
- 有目录,可以强制覆盖,删除目录并提示删除成功
如下代码我们使用了 inquirer 让用户进行确认选择,chalk 就是让用户能够改变命令行的文本颜色,ora 用来做loading效果
index.ts
// 目标目录可能存在,就要提醒用户是否进行删除
// 同时要配合用户的强制删除options,用户的配置优先级更高
const processTargetDirectory = async (name: string, options: Options) => {
return new Promise(async (resolve) => {
const cwd = process.cwd()
const target = path.join(cwd, name)
let force = options.force
const isExistTarget = fs.existsSync(target)
// 有目录,无force配置时候才询问用户
if (isExistTarget && typeof options.force === 'undefined') {
// 询问用户
const answer = await inquirer.prompt<{ force: boolean }>([
{
type: 'confirm',
name: 'force',
message: `Do you want to overwrite directory ${name}`,
default: false,
},
])
force = answer.force
}
// 有目录,不能强制覆盖
if (isExistTarget && !force) {
console.log(chalk.red('✖') + ' ' + `The directory ${name} already exists`)
process.exit(0)
}
// 有目录,可以强制覆盖
if (isExistTarget && force) {
const spinner = ora(`${name} is deleting`)
spinner.start()
fs.remove(target, (err) => {
if (err) {
console.error(err)
process.exit(0)
}
spinner.succeed(`delete ${name} success`)
return resolve(true)
})
} else {
return resolve(true)
}
})
}
inquireConfig
我们未来会支持的配置见 FEATURE_OPTIONS
,这一期先能让用户选择这些配置
这里代码也比较简单,直接调用inquirer的方法给出个命令行的多选框让用户进行选择,用户选择返回的是一个字符串数组
index.ts
const FEATURE_OPTIONS = [
{
value: 'typescript',
name: 'TypeScript',
},
{
value: 'jsx',
name: 'JSX 支持',
},
{
value: 'router',
name: 'Router(单页面应用开发)',
},
{
value: 'pinia',
name: 'Pinia(状态管理)',
},
{
value: 'vitest',
name: 'Vitest(单元测试)',
},
{
value: 'eslint',
name: 'ESLint(错误预防)',
},
{
value: 'prettier',
name: 'Prettier(代码格式化)',
},
] as const
type Feature = (typeof FEATURE_OPTIONS)[number]['value'][]
const inquireConfig = async () => {
const answer = await inquirer.prompt<{ features: Feature }>([
{
type: 'checkbox',
name: 'features',
message: 'Please select the features',
choices: FEATURE_OPTIONS,
},
])
return answer
}
代码运行测试
这里给出部分运行情况的结果,其他情况可以自行测试
-
没有文件夹的时候
-
已经存在文件夹的时候
-
配合
-f
本期最终代码
index.ts
#! /usr/bin/env node
import packageJson from './package.json'
import { program } from 'commander'
import fs from 'fs-extra'
import inquirer from 'inquirer'
import ora from 'ora'
import chalk from 'chalk'
import path from 'path'
interface Options {
force?: boolean
}
// 目标目录可能存在,就要提醒用户是否进行删除
// 同时要配合用户的强制删除options,用户的配置优先级更高
const processTargetDirectory = async (name: string, options: Options) => {
return new Promise(async (resolve) => {
const cwd = process.cwd()
const target = path.join(cwd, name)
let force = options.force
const isExistTarget = fs.existsSync(target)
// 有目录,无force配置时候才询问用户
if (isExistTarget && typeof options.force === 'undefined') {
// 询问用户
const answer = await inquirer.prompt<{ force: boolean }>([
{
type: 'confirm',
name: 'force',
message: `Do you want to overwrite directory ${name}`,
default: false,
},
])
force = answer.force
}
// 有目录,不能强制覆盖
if (isExistTarget && !force) {
console.log(chalk.red('✖') + ' ' + `The directory ${name} already exists`)
process.exit(0)
}
// 有目录,可以强制覆盖
if (isExistTarget && force) {
const spinner = ora(`${name} is deleting`)
spinner.start()
fs.remove(target, (err) => {
if (err) {
console.error(err)
process.exit(0)
}
spinner.succeed(`delete ${name} success`)
return resolve(true)
})
} else {
return resolve(true)
}
})
}
const FEATURE_OPTIONS = [
{
value: 'typescript',
name: 'TypeScript',
},
{
value: 'jsx',
name: 'JSX 支持',
},
{
value: 'router',
name: 'Router(单页面应用开发)',
},
{
value: 'pinia',
name: 'Pinia(状态管理)',
},
{
value: 'vitest',
name: 'Vitest(单元测试)',
},
{
value: 'eslint',
name: 'ESLint(错误预防)',
},
{
value: 'prettier',
name: 'Prettier(代码格式化)',
},
] as const
type Feature = (typeof FEATURE_OPTIONS)[number]['value'][]
const inquireConfig = async () => {
const answer = await inquirer.prompt<{ features: Feature }>([
{
type: 'checkbox',
name: 'features',
message: 'Please select the features',
choices: FEATURE_OPTIONS,
},
])
return answer
}
const create = async (name: string, options: Options) => {
// 项目目录预处理
await processTargetDirectory(name, options)
// 询问用户需要的配置
const { features } = await inquireConfig()
const needsTypeScript = features.includes('typescript')
const needsJsx = features.includes('jsx')
const needsPrettier = features.includes('prettier')
const needsRouter = features.includes('router')
const needsPinia = features.includes('pinia')
const needsVitest = features.includes('vitest')
const needsEslint = features.includes('eslint')
// 创建一下目录
const targetDir = name
const cwd = process.cwd()
const root = path.join(cwd, targetDir)
fs.mkdirSync(root)
}
program
.name(packageJson.name)
.description('cli to create a project of vue3')
.version(packageJson.version)
program
.command('create')
.description('create a new project of vue3')
.argument('<string>', 'project name')
.option('-f, --force', 'overwirte target directory if it already exists')
.action((name: string, options: Options) => {
create(name, options)
})
program.parse(process.argv)
package.json
{
"name": "create-jqq",
"version": "0.0.0",
"type": "module",
"bin": {
"create-jqq": "./index.js"
},
"scripts": {
"create": "npx esno ./index.ts create jqq",
"format": "prettier --write .",
"prepare": "husky"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"devDependencies": {
"@types/ejs": "^3.1.5",
"@types/fs-extra": "^11.0.4",
"@types/lodash-es": "^4.17.12",
"@types/node": "^22.14.0",
"chalk": "^5.4.1",
"commander": "^13.1.0",
"ejs": "^3.1.10",
"esno": "^4.8.0",
"fs-extra": "^11.3.0",
"husky": "^9.1.7",
"inquirer": "^12.5.2",
"lint-staged": "^15.5.1",
"lodash-es": "^4.17.21",
"ora": "^8.2.0",
"prettier": "3.5.3"
},
"lint-staged": {
"*.{js,ts,vue,json}": [
"prettier --write"
]
}
}
下期预告
下期我们就开始讲如何进行模板文件的拆分和组织,主要是这个核心思路的处理