Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

通过 Vite 的 create-app 学习如何实现一个简易版 CLI #13

Open
WJCHumble opened this issue Feb 20, 2021 · 0 comments
Open

通过 Vite 的 create-app 学习如何实现一个简易版 CLI #13

WJCHumble opened this issue Feb 20, 2021 · 0 comments

Comments

@WJCHumble
Copy link
Owner

前言

前段时间,尤雨溪回答了一个广大网友都好奇的一个问题:Vite 会不会取代 Vue CLI?

答案是:是的!

那么,你开始学 Vite 了吗?用过 Vite 的同学应该都熟悉,创建一个 Vite 的项目模版是通过 npm init @vitejs/app 的方式。而 npm init 命令是在 [email protected] 开始支持的,实际上它是先帮你安装 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 中使用到的库(minimistkolorist

  • 逐步拆解、分析 create-app CLI 源码

create-app CLI 中使用到的库

create-app CLI 实现用到的库(npm)确实很有意思,既有我们熟悉的 enquirer(用于命令行的提示),也有不熟悉的 minimistkolorist。 那么,后面这两者又是拿来干嘛的?下面,我们就来了解一番~

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 引入了 minimistenquirekolorist 等依赖,所以首先是引入它们:

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')

其中,fspath 是 Node 内置的模块,前者用于文件相关操作、后者用于文件路径相关操作。接着就是引入 minimistenquirerkolorist,它们相关的介绍上面已经提及,这里就不重复论述~

定义项目基础模版(颜色)和文件

/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

你可以选择通过输入 yn 来告知 CLI 是否要清空该目录。并且,如果此时你输入的是 n,即不清空该文件夹,那么整个 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 函数则接受两个参数 filecontent,其具备两个能力:

  • 对指定的文件 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.jsonname 都是写死的,而当用户创建项目后,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 的代码 😎 ~

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant