通过 Vite 的 create-app 学习如何实现一个简易版 CLI
前言
前段时间,尤雨溪回答了一个广大网友都好奇的一个问题:Vite 会不会取代 Vue CLI?
   答案是:是的!
那么,你开始学 Vite 了吗?用过 Vite 的同学应该都熟悉,创建一个 Vite 的项目模版是通过 npm init @vitejs/app 的方式。而 npm init 命令是在 npm@6.1.0 开始支持的,实际上它是先帮你安装 Vite 的 @vitejs/create-app 包(package),然后再执行 create-app 命令。
至于 @vitejs/create-app  则是在 Vite 项目的 packages/create-app 文件夹下。其整体的目录结构:
// packages/create-app
|———— template-lit-element
|———— template-lit-element-ts
|———— template-preact
|———— template-preact-ts
|———— template-react
|———— template-react-ts
|———— template-vanilla
|———— template-vue
|———— template-vue-ts
index.js
package.json
   Vite 的 create-app CLI(以下统称为 create-app CLI)具备的能力不多,目前只支持基础模版的创建,所以全部代码加起来只有 160 行,其整体的架构图:
   可以看出确实非常简单,也因此 create-app CLI 是一个很值得入门学习如何实现简易版 CLI 的例子。
那么,接下来本文将会围绕以下两个部分带着大家一起通过 create-app CLI 来学习如何制作一个简易版的 CLI:
- 
     
create-app中使用到的库(minimist、kolorist) - 
     
逐步拆解、分析
create-appCLI 源码 
create-app CLI 中使用到的库
create-app CLI 实现用到的库(npm)确实很有意思,既有我们熟悉的 enquirer(用于命令行的提示),也有不熟悉的 minimist 和 kolorist。那么,后面这两者又是拿来干嘛的?下面,我们就来了解一番~
minimist
   minimist 是一个轻量级的用于解析命令行参数的工具。说起解析命令行的工具,我想大家很容易想到 commander。相比较 commander 而言,minimist 则以轻取胜!因为它只有 32.4 kB,commander 则有 142 kB,即也只有后者的约 1/5。
那么,下面我们就来看一下 minimist 的基础使用。
例如,此时我们在命令行中输入:
node index.js my-project
   那么,在 index.js 文件中可以使用 minimist 获取到输入的 myproject 参数:
var argv = require('minimist')(process.argv.slice(2));
console.log(argv._[0]); 
// 输出 my-project
   这里的 argv 是一个对象,对象中 _ 属性的值则是解析 node index.js 后的参数所形成的数组。
kolorist
   kolorist 是一个轻量级的使命令行输出带有色彩的工具。并且,说起这类工具,我想大家很容易想到的就是 chalk。不过相比较 chalk 而言,两者包的大小差距并不明显,前者为 49.9 kB,后者为 33.6 kB。不过 kolorist 可能较为小众,npm 的下载量大大不如后者 chalk,相应地 chalk 的 API 也较为详尽。
同样的,下面我们也来看一下 kolorist 的基础使用。
例如,当此时应用发生异常的时候,需要打印出红色的异常信息告知用户发生异常,我们可以使用 kolorist 提供的 red 函数:
import { red } from 'kolorist'
console.log(red("Something is wrong"))
   又或者,可以使用 kolorist 提供的 stripColors 来直接输出带颜色的字符串:
import { red, stripColors } from 'kolorist'
console.log(stripColors(red("Something is wrong"))
   逐步拆解、分析 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 文件。并且,值得一提的是这里注册了 2 个命令,也就是说我们还可以使用 cva 命令来创建基于 Vite 的项目模版(想不到吧 😲)。
而 create-app CLI 实现的核心就是在 index.js 文件。那么,下面我们来看一下 index.js 中代码的实现~
基础依赖引入
上面我们也提及了 create-app CLI 引入了 minimist、enquire、kolorist 等依赖,所以首先是引入它们:
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')
   其中,fs 和 path 是 Node 内置的模块,前者用于文件相关操作、后者用于文件路径相关操作。接着就是引入 minimist、enquirer 和 kolorist,它们相关的介绍上面已经提及,这里就不重复论述~
定义项目模版(颜色)和文件
从 /packages/create-app 目录中,我们可以看出 create-app CLI 为我们提供了 9 种项目基础模版。并且,在命令行交互的时候,每个模版之间的颜色各有不同,即 CLI 会使用 kolorist 提供的颜色函数来为模版定义好对应的颜色:
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')
]
   其次,由于 .gitignore 文件的特殊性,每个项目模版下都是先创建的 _gitignore 文件,在后续创建项目的时候再替换掉该文件的命名(替换为 .gitignore)。所以,CLI 会预先定义一个对象来存放需要重命名的文件:
const renameFiles = {
  _gitignore: '.gitignore'
}
   定义文件操作相关的工具函数
由于创建项目的过程中会涉及和文件相关的操作,所以 CLI 内部定义了 3 个工具函数:
copyDir 函数
copyDir 函数用于将某个文件夹 srcDir 中的文件复制到指定文件夹 destDir中。它会先调用 fs.mkdirSync 函数来创建制定的文件夹,然后枚举从 srcDir 文件夹下获取的文件名构成的数组,即 fs.readdirSync(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)
  }
}
   copy 函数
copy 函数则用于复制文件或文件夹 src 到指定文件夹 dest。它会先获取 src 的状态 stat,如果 src 是文件夹的话,即 stat.isDirectory() 为 true 时,则会调用上面介绍的 copyDir 函数来复制 src 文件夹下的文件到 dest 文件夹下。反之,src 是文件的话,则直接调用 fs.copyFileSync 函数复制 src 文件到 dest 文件夹下。
其对应的代码如下:
function copy(src, dest) {
  const stat = fs.statSync(src)
  if (stat.isDirectory()) {
    copyDir(src, dest)
  } else {
    fs.copyFileSync(src, dest)
  }
}
   emptyDir 函数
emptyDir 函数用于清空 dir 文件夹下的代码。它会先判断 dir 文件夹是否存在,存在则枚举该问文件夹下的文件,构造该文件的路径 abs,调用 fs.unlinkSync 函数来删除该文件,并且当 abs 为文件夹时,则会递归调用 emptyDir 函数删除该文件夹下的文件,然后再调用 fs.rmdirSync 删除该文件夹。
其对应的代码如下:
function emptyDir(dir) {
  if (!fs.existsSync(dir)) {
    return
  }
  for (const file of fs.readdirSync(dir)) {
    const abs = path.resolve(dir, file)
    if (fs.lstatSync(abs).isDirectory()) {
      emptyDir(abs)
      fs.rmdirSync(abs)
    } else {
      fs.unlinkSync(abs)
    }
  }
}
   CLI 实现核心函数
CLI 实现核心函数是 init,它负责使用前面我们所说的那些函数、工具包来实现对应的功能。下面,我们就来逐点分析 init 函数实现的过程:
1. 创建项目文件夹
通常,我们可以使用 create-app my-project 命令来指定要创建的项目文件夹,即在哪个文件夹下:
let targetDir = argv._[0]
// cwd = process.cwd()
const root = path.join(cwd, targetDir)
console.log(`Scaffolding project in ${root}...`)
   其中,argv._[0] 代表 create-app 后的第一个参数,root 是通过 path.join 函数构建的完整文件路径。然后,在命令行中会输出提示,告述你脚手架(Scaffolding)项目创建的文件路径:
Scaffolding project in /Users/wjc/Documents/project/vite-project...
   当然,有时候我们并不想输入在 create-app 后输入项目文件夹,而只是输入 create-app 命令。那么,此时 tagertDir 是不存在的。CLI 则会使用 enquirer 包的 prompt 来在命令行中输出询问:
? project name: > vite-project
   你可以在这里输入项目文件夹名,又或者直接回车使用 CLI 给的默认项目文件夹名。这个过程对应的代码:
if (!targetDir) {
  const { name } = await prompt({
    type: "input",
    name: "name",
    message: "Project name:",
    initial: "vite-project"
  })
  targetDir = name
}
   接着,CLI 会判断该文件夹是否存在当前的工作目录(cwd)下,如果不存在则会使用 fs.mkdirSync 创建一个文件夹:
if (!fs.existsSync(root)) {
  fs.mkdirSync(root, { recursive: true })
}
   反之,如果存在该文件夹,则会判断此时文件夹下是否存在文件,即使用 fs.readdirSync(root) 获取该文件夹下的文件:
const existing = fs.readdirSync(root)
   这里 existing 会是一个数组,如果此时数组长度不为 0,则表示该文件夹下存在文件。那么 CLI 则会询问是否删除该文件夹下的文件:
Target directory vite-project is not empty. 
Remove existing files and continue?(y/n): Y
   你可以选择通过输入 y 或 n 来告知 CLI 是否要清空该目录。并且,如果此时你输入的是 y,即不清空该文件夹,那么整个 CLI 的执行就会退出。这个过程对应的代码:
if (existing.length) {
  const { yes } = await prompt({
    type: 'confirm',
    name: 'yes',
    initial: 'Y',
    message:
      `Target directory ${targetDir} is not empty.\n` +
      `Remove existing files and continue?`
  })
  if (yes) {
    emptyDir(root)
  } else {
    return
  }
}
   2. 确定项目模版
在创建好项目文件夹后,CLI 会获取 --template 选项,即当我们输入这样的命令时:
npm init @vitejs/app --template 文件夹名
   如果 --template 选项不存在(即 undefined),则会询问要选择的项目模版:
let template = argv.t || argv.template
if (!template) {
  const { t } = await prompt({
    type: "select",
    name: "t",
    message: "Select a template:",
    choices: TEMPLATES
  })
  template = stripColors(t)
}
   由于,TEMPLATES 中只是定义了模版的类型,对比起 packages/create-app 目录下的项目模版文件夹命名有点差别(缺少 template 前缀)。例如,此时 template 会等于 vue-ts,那么就需要给 template 拼接前缀和构建完整目录:
const templateDir = path.join(__dirname, `template-${template}`)
   所以,现在 templateDir 就会等于当前工作目录 + template-vue-ts。
3. 写入项目模版文件
确定完需要创建的项目的模版后,CLI 就会读取用户选择的项目模版文件夹下的文件,然后将它们一一写入此时创建的项目文件夹下:
可能有点绕,举个例子,选择的模版是
vue-ts,自己要创建的项目文件夹为vite-project,那么则是将create-app/template-vue-ts文件夹下的文件写到vite-project文件夹下。
const files = fs.readdirSync(templateDir)
for (const file of files.filter((f) => f !== 'package.json')) {
  write(file)
}
   由于通过 fs.readdirSync 函数返回的是该文件夹下的文件名构成的数组 ,所以这里会通过 for of 枚举该数组,每次枚举会调用 write 函数进行文件的写入。
注意此时会跳过
package.json文件,之后我会讲解为什么需要跳过package.json文件。
而 write 函数则接受两个参数 file 和 content,其具备两个能力:
- 
     
对指定的文件
file写入指定的内容content,调用fs.writeFileSync函数来实现将内容写入文件 - 
     
复制模版文件夹下的文件到指定文件夹下,调用前面介绍的
copy函数来实现文件的复制 
write 函数的定义:
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)
  }
}
   并且,值得一提的是 targetPath 的获取过程,会针对 file 构建完整的文件路径,并且兼容处理 _gitignore 文件的情况。
在写入模版内的这些文件后,CLI 就会处理 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函数则用于获取一个完整路径的最后的文件夹名
最后,CLI 会输出一些提示告诉你项目已经创建结束,以及告诉你接下来启动项目需要运行的命令:
console.log(`\nDone. Now run:\n`)
if (root !== cwd) {
  console.log(`  cd ${path.relative(cwd, root)}`)
}
console.log(`  npm install (or \`yarn\`)`)
console.log(`  npm run dev (or \`yarn dev\`)`)
console.log()
   结语
虽然 Vite 的 create-app CLI 的实现仅仅只有 160 行的代码,但是它也较为全面地考虑了创建项目的各种场景,并做对应的兼容处理。简而言之,十分小而美。所以,我相信大家经过学习 Vite 的 create-app CLI 的实现,都应该可以随手甩出(实现)一个 CLI 的代码 😎 ~
点赞 👍、在看 👀
通过阅读本篇文章,如果有收获的话,可以点个赞和在看,这将会成为我持续分享的动力,感谢~
    
     
      
       
        
         
          
           
            
             
             
              
               
                
                 
                  
                   
                   
                  
                  
                  
                  
                 
                
               
              
             
            
           
          
         
        
       
      
     
   ·END·
汇聚精彩的免费实战教程
喜欢本文,点个“在看”告诉我
                   