搭建简易版cli

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 

以 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稍加处理

var argv = require('minimist')(process.argv.slice(2));
console.log(argv._.join(', '));

得到:

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(`\nScaffolding 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.\n` +
          `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 函数则接受两个参数 filecontent,其具备两个能力:

  • 对指定的文件 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)
    }
  }
在写入模版内的这些文件后,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 函数则用于获取一个完整路径的最后的文件夹名。

当这一切都完成后,命令行输出,依赖安装和启动命令提示。(要判断一下使用的是npm还是yarn, 还要判断下root是否是当前工作路径,并视情况予以输入提示)
 const pkgManager = /yarn/.test(process.env.npm_execpath) ? 'yarn' : 'npm'

  console.log(`\nDone. Now run:\n`)
  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

 

posted @ 2021-02-15 21:21  cecelia  阅读(220)  评论(0编辑  收藏  举报