背景
前段时间接到个需求,需要开发一个业务组件的包提供给业务组的小伙伴们去用。在此之前也开发过类似的包,除却包的作用不同之外,有些内容基本是大差不差的。比如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
之后我们便可以在命令行工具中看到打印出的版本信息了:
到这里,我们基本的命令定义环节就完成了。
第二步:实现项目创建逻辑
接下来我们就要开始创建项目了。在开始实现具体逻辑之前,我们可以先捋一下可能会经历哪些步骤:
第一步:创建项目
第二步:创建项目所需基本文件以及文件夹。比如 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
命令时,便可以看到交互式的输入界面:
输入完成后,我们将 answers
输出到控制台:
结果是我们输入的内容,对应的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
、.gitignore
、src/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, 有兴趣的小伙伴们可以全局安装尝试下~
看到这里,快动手织个网吧~