背景

前段时间接到个需求,需要开发一个业务组件的包提供给业务组的小伙伴们去用。在此之前也开发过类似的包,除却包的作用不同之外,有些内容基本是大差不差的。比如git commit的规范,一些必备的诸如 src/index.js 的文件等等。

如果要新建一个包的工程,诸如上述所说的文件大多会去既有的工程中拷贝一份过来修修改改,继而再次基础上完成接下来的开发。等到下一次有新的类似需求来的时候,周而复始。。。Orz

那既然如此,我为啥不去搞一个脚手架工具去把这些人工操作的事情自动化掉呢~

常言道:临渊羡鱼,不如...退回去自己织个网,干!

准备工作

简单三步做好准备工作~

第一步:注册一个npm的账号,用于后面发包

第二步:mkdir cli-tool && cd cli-tool && npm init -y

第三步:之后在 `package.json` 文件中填入一些基本的信息即可
{
  "name": "cli-tool",
  "version": "1.0.0",
  "description": "A tool for cli creation",
  "main": "index.js",
  "scripts": {
    "pub:beta": "npm publish --tag=beta",
    "pub": "npm publish"
  },
  "publishConfig": {
    "registry": "https://registry.npmjs.org/"
  },
  "keywords": [
    "create",
    "cli"
  ],
  "author": "your name",
  "license": "ISC"
}

开始造轮子

第一步:定义命令

相信大家都用过脚手架,脚手架工具一般都会提供相应的脚本命令以完成一些操作。比如查看版本号的 --version, 查看帮助信息的 --help 等等。在这一步骤中,我们也先来定义下属于我们自己脚手架的命令。

创建脚本文件

首先,我们在项目的根目录下创建 bin 目录,然后在该文件夹下创建定义脚本的文件 ct.js。创建完成后,在 package.json 文件中定义命令对应的文件:

{
  // ...
  "bin": {
    "ct": "bin/ct.js"
  }
}

其中,ct 指定了我们日后在使用脚手架时所需要输入的命令,bin/ct.js 指定输入命令后所执行的脚本文件路径。

编写脚本

我们在脚本中定义以下两个基本的命令:

1. 用于查看版本信息的命令:--version/-v
2. 用于创建项目的命令:--create/-c
#! /usr/bin/env node

const process = require('process')
const packageJson = require('../package.json')

/*
* 接收命令行传过来的指令参数
* 由于第一第二个为path信息,我们用不到,所以从第三个开始取
*/
const argvs = process.argv.slice(2)

switch (argvs[0] || '-c') {
    /** 查看当前插件版本 */
    case '-v':
    case '--version':
      console.log(`v${packageJson.version}`)
      break
    /** 创建项目 */
    case '-c':
    case '--create':
      console.log('Create project')
      break
}

编写完成后,如果想要在命令行中进行验证,此时我们可以在命令行中输入以下命令:

npm link

然后在命令行中输入

ct -v

之后我们便可以在命令行工具中看到打印出的版本信息了:

image.png

到这里,我们基本的命令定义环节就完成了。

第二步:实现项目创建逻辑

接下来我们就要开始创建项目了。在开始实现具体逻辑之前,我们可以先捋一下可能会经历哪些步骤:

第一步:创建项目
第二步:创建项目所需基本文件以及文件夹。比如 package.json 文件,src 目录等
第三步:创建完成后,跳转到对应的目录下执行安装步骤

基于以上的几个步骤,我们来实现具体的逻辑。

创建项目

首先,我们在 src/create 路径下,创建 index.js 文件,用于编写具体的创建逻辑。

module.exports.run = () => {}

之后,我们在 bin/ct.js 文件中引入,并在执行 -c/--create 时调用这个方法:

const { run } = require('../src/create/index')

// ....省略好多好多代码

/** 创建包项目 */
case '-c':
case '--create':
  run()
  break

// ....省略好多好多代码

参照 vue-cli 的模式,在创建项目的时候,我们可以通过 交互式命令 的方式让创建的过程更加友好。这里我们会使用到一个包:inquirer。通过它我们可以定义交互式命令。

首先,我们在本地对其进行安装

yarn add -D inquirer

之后,我们在 src/constants 目录下,创建 questions.js,用于定义具体的步骤:

/**
 * 定义交互式命令行的问题
 */
const DEFAULT_QUESTIONS = [{
  type: 'input',
  name: 'PROJECT_NAME',
  message: 'Project name:',
  validate: function (name) {
    const done = this.async()
    // 如果目录已经存在,提示修改目录名称
    if (['', null, undefined].includes(name)) {
      done('Please enter the project name!', true)
      return
    }
    if (fs.existsSync(name)) {
      done(`The directory "${name}" is exist!!Please reset the dirname.`, true)
      return
    }
    done(null, true)
  }
}, {
  type: 'input',
  name: 'PROJECT_DESCRIPTION',
  message: 'Project description:'
}, {
  type: 'input',
  name: 'PROJECT_AUTHOR',
  message: 'Project author:'
}]

module.exports = { DEFAULT_QUESTIONS }

这里对用到的配置项做下简单的解释,关于更多其他的配置项,有兴趣的小伙伴可以

 type ${string} 交互类型
 name ${string} 用来接收用户输入内容的key
 message ${string} 交互信息
 validate ${function} 校验方法

创建完成后,在 src/create/index.js 中引入 inquirer 以及上述定义的交互步骤:

const inquirer = require('inquirer')
const { DEFAULT_QUESTIONS } = require('../constants/questions')

module.exports.run = async () => {
  try {
    // 获取输入的信息
    const answers = await inquirer.prompt(DEFAULT_QUESTIONS)
  } catch (error) {
    console.log(chalk.red(`Create objet defeat! The error message is: ${error}`))
  }
}

此时,我们在控制台中执行 ct -c 命令时,便可以看到交互式的输入界面:

image.png

输入完成后,我们将 answers 输出到控制台:

image.png

结果是我们输入的内容,对应的key是我们在自定义问题时设置的name属性。我们可以针对输入的 PROJECT_NAME 属性,创建对应的项目文件夹。

我们可以通过nodejs中提供的 fs.mkdir 的方式来创建文件夹,这里我推荐给大家一个谷歌开源的库:zx。这个库封装了一系列的shell命令,以便让我们可以像在控制台中一样使用命令:

yarn add -D zx

回到 src/create/index.js 文件,我们在其中引入 zx,并通过它创建我们的项目文件夹。注意:在使用zx时,需要在文件顶部指明 #!/usr/bin/env zx

#!/usr/bin/env zx
// ....省略好多好多代码

const answers = await inquirer.prompt(questions)
// 创建新的目录
await $`mkdir ${answers.PROJECT_NAME}`

// ....省略好多好多代码

创建常用的文件

在开始这个步骤前,我们可以先思考下,在新建一个项目的时候,比较常用的文件可能会有哪些?相信小伙伴们的心里都有自己的答案。

在这里,我将创建包括 package.json.gitignoresrc/index.js 等文件,并事先将文件内容模板定义在 src/create/constants/defaultInit.js中:

// 定义index.js内容
const INDEX_CONTENT = {
    filename: 'src/index.js',
    content: ''
}
// 定义package.json内容
const PACKAGE_CONTENT = {
    filename: 'package.json',
    content: JSON.stringify({
        "version": "1.0.0",
        "main": "index.js",
        "scripts": {
            "ca": "git add -A && git-cz -av",
            "commit": "git-cz"
        },
        "keywords": [],
        "license": "ISC",
        "devDependencies": {
            "husky": "^5.0.9",
            "commitizen": "^4.2.3",
            "cz-conventional-changelog": "^3.3.0",
            "lint-staged": "^10.5.4"
        }
    })
}

// 定义.czrc内容
const CZRC_CONTENT = {
    filename: '.czrc',
    content: '{ "path": "cz-conventional-changelog" }'
}
// 定义.huskyrc内容
const HUSKYRC_CONTENT = {
    filename: '.huskyrc.yml',
    content: `hooks:
      pre-commit: lint-staged
      commit-msg: 'commitlint -E HUSKY_GIT_PARAMS'
    `
}
// 定义.commitlintrc内容
const COMMITLINTRC_CONTENT = {
    filename: '.commitlintrc.yml',
    content: `extends:
      - '@commitlint/config-conventional'
    `
}
// 定义.lintstagedrc内容
const LINTSTAGEDRC_CONTENT = {
    filename: '.lintstagedrc.yml',
    content: `'**/*.{js, jsx, vue}':
      - 'eslint --fix'
      - 'git add'
    '**/*.{less, md}':
      - 'prettier --write'
      - 'git add'
    `
}

// 定义.gitignore
const GIT_IGNORE_CONTENT = {
    filename: '.gitignore',
    content: '/node_modules'
}

module.exports = {
    INDEX_CONTENT,
    PACKAGE_CONTENT,
    CZRC_CONTENT,
    HUSKYRC_CONTENT,
    COMMITLINTRC_CONTENT,
    LINTSTAGEDRC_CONTENT,
    GIT_IGNORE_CONTENT
}

而后,在 src/create 目录下创建 default.js,并将文件模板引入,编写创建文件的逻辑。该文件抛出一个方法,接受上一步骤中的问题答案,并依此将文件填入对应的路径中。话不多说,上代码:

#!/usr/bin/env zx

require('zx/globals')
const {
  INDEX_CONTENT,
  PACKAGE_CONTENT,
  CZRC_CONTENT,
  HUSKYRC_CONTENT,
  COMMITLINTRC_CONTENT,
  LINTSTAGEDRC_CONTENT,
  GIT_IGNORE_CONTENT
} = require('../../constants/defaultInit')

/**
 * 复制template目录下的所有文件到指定目录
 * @param {Object} answers 输入的信息
 */
module.exports = (answers) => {
    const { PROJECT_NAME } = answers
    const baseDir = `${process.cwd()}/${PROJECT_NAME}`
    // 创建src目录,并创建index.js文件
    fs.mkdir(`${baseDir}/src`, { recursive: true }, err => {
      if (err) {
        console.log({err})
        return
      }
      // 创建文件
      const files = [
        INDEX_CONTENT,
        PACKAGE_CONTENT,
        CZRC_CONTENT,
        HUSKYRC_CONTENT,
        COMMITLINTRC_CONTENT,
        LINTSTAGEDRC_CONTENT,
        GIT_IGNORE_CONTENT
      ]
      files.forEach(file => {
        const { filename, content } = file
        let fileContent = content
        // 如果是package.json,则填入相应的信息
        if (filename === 'package.json') {
          const { PROJECT_NAME, PROJECT_DESCRIPTION, PROJECT_AUTHOR } = answers
          const packageContent = {
            name: PROJECT_NAME,
            author: PROJECT_AUTHOR,
            description: PROJECT_DESCRIPTION,
            ...JSON.parse(content)
          }
          fileContent = JSON.stringify(packageContent, null, '\t')
        }
        fs.writeFile(`${baseDir}/${file.filename}`, fileContent, {
          encoding: 'utf-8'
        }, err => {
          err && console.log({ type: 'Create index.js failed: ', err })
        })
      })
    })
}

再次回到 scr/create/index.js 文件中,将该方法引入并执行:

const initObject = require('../utils/default')

// ....省略好多好多代码

// 获取输入的信息
const answers = await inquirer.prompt(questions)
// 创建新的目录
await $`mkdir ${answers.PROJECT_NAME}`
// 创建文件
await initObject(answers)

// ....省略好多好多代码

跳转到对应的目录,执行安装操作

这一步就很简单了,只需要跳转到我们创建好的目录下,并安装所需的包即可:

// src/create/index.js

// ....省略好多好多代码

// 获取输入的信息
const answers = await inquirer.prompt(questions)
// 创建新的目录
await $`mkdir ${answers.PROJECT_NAME}`
// 创建文件
await initObject(answers)
// 跳转至目录
cd(dirName)
// 执行安装命令
await $`yarn`

// ....省略好多好多代码

至此,所有的基本逻辑就都完成啦~

第三步:发布这个工具到npm

完成编写后,我们直接执行事先设置好的 pub 命令发布即可。

扩展阅读

关于命令解析

本文中我们通过 process.argv 来解析输入的命令,如果是简单的项目还好,要是后续脚手架功能日益复杂,诸多命令若仍需要通过这种方式去解析未免有些繁琐了,这里推荐大家一个第三方包:commander,它可以让这个过程更加简便一些,并且可以通过它配置 help 信息,有兴趣的小伙伴快去试试看吧~

关于打印信息的颜色

针对不同类别的控制台打印信息,其实可以设置相应的颜色,这样看起来更加直观,也更加美观。在上文中提到的 zx中,也封装了 chalk,只要引入了 zx 便可直接使用。通过它可以为控制台输出信息“上色”。举个🌰:

console.log(chalk.red(`Create objet defeat! The error message is: ${error}`))

更多的颜色跟使用方式,小伙伴们也可以去研究一下,让控制台输出也多姿多彩起来~

结束语

行文至此,我们已经完成了一个属于自己的简单脚手架工具。大家后期可以根据自己的需求对其持续优化,也可以思考下别的小伙伴在使用你的脚手架的时候会需要些什么,怎么做才可以帮助更多的小伙伴提升人效。

关于cli工具,崔大在B站也有一期详细的讲解视频,大家也可以去参考:传送门

本文中的脚手架工具已经上传到git, 欢迎⭐️交流~

同时也已上传至 npm, 有兴趣的小伙伴们可以全局安装尝试下~

看到这里,快动手织个网吧~