1. vite的 create-app CLI整体架构
我们可以看到除了我们常用的npm库enquirer(命令行提示)外,还用到了minimist和kolorist这两个库。接下来,
- 将对create-app CLI中用到的库进行介绍
- 逐步拆解、分析create-app CLI源码
2. create-app CLI中用到的库
2.1 enquirer 可交互的(对话式的)命令行提示
2.1.1 获取单一值
const { prompt } = require('enquirer'); const response = await prompt({ type: 'input', name: 'username', message: 'What is your username?' }); console.log(response); // { username: 'jonschlinkert' }
通过这个例子,我们可以简单了解一个简单的enquirer实例的用法。关于prompt的属性,type, name, message是必需参数。详见:https://www.npmjs.com/package/enquirer#prompt-options
2.1.2 内置prompt
enquirer提供了一系列内置prompt
- AutoComplete Prompt
- BasicAuth Prompt
- Confirm Prompt
- Form Prompt
- Input Prompt
- Invisible Prompt
- List Prompt
- MultiSelect Prompt
- Numeral Prompt
- Password Prompt
- Quiz Prompt
- Survey Prompt
- Scale Prompt
- Select Prompt
- Sort Prompt
- Snippet Prompt
- Toggle Prompt
以 Form Prompt 为例,我们可以通过选择合适类型的内置prompt,得到相应的用户输入。
const { Form } = require('enquirer'); const prompt = new Form({ name: 'user', message: 'Please provide the following information:', choices: [ { name: 'firstname', message: 'First Name', initial: 'Jon' }, { name: 'lastname', message: 'Last Name', initial: 'Schlinkert' }, { name: 'username', message: 'GitHub username', initial: 'jonschlinkert' } ] }); prompt.run() .then(value => console.log('Answer:', value)) .catch(console.error);
2.2 minimist 轻量级的用于解析命令行参数的工具。
与常用的命令行解析工具commander相比, minimist更加轻量, commander(7.1.0)的大小为144kb, 而enquirer(1.2.5)的大小只有32.4kb.
(minimist命令行参数解析,解析后以对象的形式进行访问)
var args = require('minimist')(process.argv.slice(2));
console.log(args.hello);
$ node test.js --hello=world
// world
$ node test.js --hello world
// world
_参数
var args = require('minimist')(process.argv.slice(2), {
boolean: ["hello"] // hello只能被解析为true或者false
});
console.log(args.hello);
console.log(args._);
$ node test.js --hello world
// true
// [ 'world' ] // 可以从argv._中读取传入的参数值
// src/print.js
var argv = require('minimist')(process.argv.slice(0)); console.log(argv);
命令行直接运行:
node src/print.js
结果:
{ _: [ '/Users/cecelia/.nvm/versions/node/v10.21.0/bin/node', '/Users/cecelia/lesson1/src/print.js' ] }
命令行运行:
node src/print.js hello mama
结果:
{ _: [ '/Users/cecelia/.nvm/versions/node/v10.21.0/bin/node', '/Users/cecelia/lesson1/src/print.js', 'hello', 'mama' ] }
所以如果想从命令行拿到传入的两个参数,可以对argv稍加处理
得到:
hello, mama
如果想直接将数据的值传给对应的属性名,可以在命令行运行:
node src/print.js -a alex -b bama -def --boom=beef
得到:
{ _: [],
a: 'alex',
b: 'bama',
d: true,
e: true,
f: true,
boom: 'beef' }
2.3 kolorist
kolorist
是一个轻量级的使命令行输出带有色彩的工具。并且,说起这类工具,我想大家很容易想到的就是 chalk
。不过相比较 chalk
而言,两者包的大小差距并不明显,kolorist为 49.9 kB,chalk(4.1.0)为 33.6 kB。不过 kolorist
可能较为小众,npm 的下载量大大不如后者 chalk
,相应地 chalk
的 API 也较为详尽。const { red, cyan } = require('kolorist'); console.log(red(`Error: something failed in ${cyan('my-file.js')}.`));
3. 拆解分析create-app CLI源码
在创建CLI时,我们通常把命令放在package.json的bin中。create-app
CLI 对应的文件根目录下该文件的 bin
配置是这样:// pacakges/create-app/package.json "bin": { "create-app": "index.js", "cva": "index.js" }
可以看到create-app的命令就是在这里被注册的,它指向了根目录下的index.js文件。在上面的配置中,我们看到还注册了另外一条命令cva,同样指向index.js,即:运行cva与create-app是等效的。
下面我们来看下index.js中的实现:
3.1 依赖引入
const fs = require('fs') const path = require('path') const argv = require('minimist')(process.argv.slice(2)) const { prompt } = require('enquirer') const { yellow, green, cyan, magenta, lightRed, stripColors } = require('kolorist')
了解node的同学知道,fs和path是node的内置模块,前者用于与文件相关的功能,后者用于路径相关的操作。
除此之外,引入了我们上述的三个库,enquirer, minimist, kolorist
3.2 定义项目模板(含颜色)和文件
const TEMPLATES = [ yellow('vanilla'), green('vue'), green('vue-ts'), cyan('react'), cyan('react-ts'), magenta('preact'), magenta('preact-ts'), lightRed('lit-element'), lightRed('lit-element-ts') ]
TEMPLATES中定义了不同的模板,并给予不同的模板以不同的颜色。
此外,由于.gitignore
文件的特殊性,每个项目模版下都是先创建_gitignore
文件,在后续创建项目的时候再替换掉该文件的命名(替换为.gitignore
)。所以,CLI 会预先定义一个对象来存放需要重命名的文件:
const renameFiles = { _gitignore: '.gitignore' }
3.3 相关工具函数
copy 函数:用于文件或文件夹复制,将src复制到dest。首先,判断 src 的stat,如果是文件夹(stat.isDirectory()返回true时),进行的是文件夹的复制;否则,将进行文件复制。
function copy(src, dest) { const stat = fs.statSync(src) if (stat.isDirectory()) { copyDir(src, dest) } else { fs.copyFileSync(src, dest) } }
copyDir 函数:用于文件夹的复制。首先创建文件夹srcDir, 然后通过枚举的方式,将destDir中的每一个文件/文件夹复制到srcDir中
function copyDir(srcDir, destDir) { fs.mkdirSync(destDir, { recursive: true }) for (const file of fs.readdirSync(srcDir)) { const srcFile = path.resolve(srcDir, file) const destFile = path.resolve(destDir, file) copy(srcFile, destFile) } }
emptyDir 函数:用于清空文件夹。首先判断下给出的路径dir是否存在,如果不存在直接返回;若存在则枚举文件夹下的每一个文件/文件夹。当为文件时,调用fs.unlinkSync删除文件;当为文件夹时,递归调用emptyDir函数清空文件夹下的每个文件,然后再调用fs.rmdirSync删除该文件夹。
function emptyDir(dir) { if (!fs.existsSync(dir)) { return } for (const file of fs.readdirSync(dir)) { const abs = path.resolve(dir, file) // baseline is Node 12 so can't use rmSync :( if (fs.lstatSync(abs).isDirectory()) { emptyDir(abs) fs.rmdirSync(abs) } else { fs.unlinkSync(abs) } } }
4. 核心函数
4.1 基础依赖引入
我们会使用create-app my-project来创建项目(间接定义了目录)。
我们在上面讲minimist提到过 argv._ 是一个读取命令行参数的数组,这里,argv._[0]
代表 create-app
后的第一个参数(my-project),如果没有读到这个参数的值,就会通过命令行提示(enquirer prompt)的方式,让你输入或直接回车使用默认值vite-project。然后,通过 path.join
函数构建的完整文件路径root。接下来,在命令行中会输出提示,告述你脚手架(Scaffolding)项目创建的文件路径。
let targetDir = argv._[0] if (!targetDir) { /** * @type {{ name: string }} */ const { name } = await prompt({ type: 'input', name: 'name', message: `Project name:`, initial: 'vite-project' }) targetDir = name } const root = path.join(cwd, targetDir) console.log(` Scaffolding project in ${root}...`)
接下来,会判断root是否存在:不存在会创建新的目录
if (!fs.existsSync(root)) { fs.mkdirSync(root, { recursive: true }) } else { // 文件夹存在时 }
反之,若文件夹存在,会进一步判断文件夹下是否存在文件。当存在文件,即: if (existing.length) 的结果为true,会提示是否清空已有文件夹下的内容。命令行输入 Y , 会清空文件夹;输入N, 不清空该文件夹,同时整个 CLI 的执行会退出。
const existing = fs.readdirSync(root) if (existing.length) { /** * @type {{ yes: boolean }} */ const { yes } = await prompt({ type: 'confirm', name: 'yes', initial: 'Y', message: `Target directory ${targetDir} is not empty. ` + `Remove existing files and continue?` }) if (yes) { emptyDir(root) } else { return } }
4.2 确定项目模板
在创建好项目文件夹后,CLI 会获取 --template(或--t)
选项
npm init @vitejs/app --template 文件夹名
如果没有--template或者--t,会通过提示让用户选择一个模板。
let template = argv.t || argv.template if (!template) { /** * @type {{ t: string }} */ const { t } = await prompt({ type: 'select', name: 't', message: `Select a template:`, choices: TEMPLATES }) template = stripColors(t) }
TEMPLATES
中只是定义了模版的类型,对比起 packages/create-app
目录下的项目模版文件夹命名有点差别(缺少 template
前缀),所以需要给 template
拼接前缀和构建完整目录:const templateDir = path.join(__dirname, `template-${template}`)
4.3 写入项目目录
读取 templateDir 目录下的文件名,除package.json外,依次写入。
const files = fs.readdirSync(templateDir) for (const file of files.filter((f) => f !== 'package.json')) { write(file) }
上述过程用到了write函数。
write
函数则接受两个参数 file
和 content
,其具备两个能力:
-
对指定的文件
file
写入指定的内容content
,调用fs.writeFileSync
函数来实现将内容写入文件 -
复制模版文件夹下的文件到指定文件夹下,调用前面介绍的
copy
函数来实现文件的复制
在write函数中,首先对即将写入的文件名进行判断,是否是先前定义的 renameFiles 中的属性名(_gitignore), 若是,则替换文件名,并进行路径拼接;否则无需替换,直接拼接。然后,进行内容写入。如果没有传入content, 则将模板目录下的相应的文件复制过来。
const write = (file, content) => { const targetPath = renameFiles[file] ? path.join(root, renameFiles[file]) : path.join(root, file) if (content) { fs.writeFileSync(targetPath, content) } else { copy(path.join(templateDir, file), targetPath) } }
package.json
文件。之所以单独处理 package.json
文件的原因是每个项目模版内的 package.json
的 name
都是写死的,而当用户创建项目后,name
都应该为该项目的文件夹命名。这个过程对应的代码会是这样:const pkg = require(path.join(templateDir, `package.json`)) pkg.name = path.basename(root) write('package.json', JSON.stringify(pkg, null, 2))
其中,path.basename
函数则用于获取一个完整路径的最后的文件夹名。
const pkgManager = /yarn/.test(process.env.npm_execpath) ? 'yarn' : 'npm' console.log(` Done. Now run: `) if (root !== cwd) { console.log(` cd ${path.relative(cwd, root)}`) } console.log(` ${pkgManager === 'yarn' ? `yarn` : `npm install`}`) console.log(` ${pkgManager === 'yarn' ? `yarn dev` : `npm run dev`}`) console.log() }
源码地址: https://github.com/vitejs/vite/blob/main/packages/create-app/index.js
参考:https://juejin.cn/post/6926648505008128008#heading-7