每次自己写一个项目之前,都是create-react-app来快速搭建的项目,但是每次都要在基础之上做一些修改,大多时候都是从之前的项目中copy代码过来,感觉每次做这些重复的操作是在浪费时间,于是决定学习一下如何自己搭建一个脚手架
目前想到的需要的功能
- 可以通过终端命令
my-cli create my-app
来创建my-app项目 - 创建的模板包含
git
,react-router
,axios
,webpack
等常用插件 - 创建
package.json
等文件内容 - 自动安装当前的依赖
初始化
# 终端创建项目根文件夹并进入到根文件夹
mkdir my-cli && cd my-cli
# 创建bin和src文件夹
mkdir bin src
# 初始化npm的包管理文件,该命令会询问你很多配置参数,如果不想询问直接在后面加-y参数即可
npm init
# bin下创建init.js作为脚本的入口文件
cd bin && touch init.js
# 并在init.js中键入如下内容:
#!/usr/bin/env node
console.log('Hello,my bin!')
接下来配置package.json
文件,添加bin
字段:
{
"bin": {
"my-cli": "bin/init.js"
},
}
这里定义了一个my-cli
命令,执行该命令时,会运行配置的bin/init.js
脚本,然后为了方便测试,通过执行npm link
将这个包挂载到全局,然后执行my-cli
:
Commander.js
我们在用一些脚手架的时候,可以通过命令行做一些交互,如配置author,description,name,或者带一些参数执行命令,为了达到上述目标,可以使用commander.js,参考文档:https://github.com/tj/commander.js/blob/HEAD/Readme_zh-CN.md
安装
npm install commander -S
使用
#! /usr/bin/env node
// 引入依赖
const { Command } = require('commander');
const program = new Command();
const package = require('../package');
// 定义版本和参数选项
program
.version(package.version, '-v, --version')
.option('-i, --init', 'init something')
//解析对应参数
program.parse(process.argv);
//如果用户输入了上述参数,则会触发的事件
if(program.init) {
console.log('init something')
}
创建子命令
// 调用command方法,创建一个create命令,同时create命令后面必须跟一个命令参数<project-name>
program.command('create <project-name>')
// 定义该命令的描述
.description('创建<project-name>项目')
// 指定额外参数
.option('-f, --force', '忽略文件夹检查,如果已存在则直接覆盖')
/**
* 定义实现逻辑
* source表示<project-name>参数
* destination表示终端的cmd对象
*/
.action((source, destination) => {
console.log(`创建了${source}项目`)
// new CreateCommand(source, destination)
});
我们需要关注一下destination
这个参数,这个参数中的options
包含了该命令所定义的参数列表,当执行命令时带了参数,则destination
对象中对应的长命令键的值为true
,因此为了解析到参数,我们从options
拿到定义的所有长命令去匹配,并返回键值对:
// src/util/util.js
const isFunction = val => val && typeof val === 'function';
/**
* parseCmdParams
* @description 解析用户输入的参数
* @param {} cmd Cammander.action解析出的cmd对象
* @returns { Object } 返回一个用户参数的键值对象
*/
exports.parseCmdParams = (cmd) => {
if (!cmd) return {}
const resOps = {}
cmd.options.forEach(option => {
const key = option.long.replace(/^--/, '');
if (cmd[key] && !isFunction(cmd[key])) {
resOps[key] = cmd[key]
}
})
return resOps
}
Creator
class Creator {
constructor(source, destination, ops = {}) {
this.source = source
//解析得到的参数
this.cmdParams = parseCmdParams(destination)
this.RepoMaps = Object.assign({
// 项目模板地址
repo: RepoPath,
// 临时缓存地址
temp: path.join(__dirname, '../../__temp__'),
// 项目目标存放地址
target: this.genTargetPath(this.source)
}, ops);
// git信息
this.gitUser = {}
// 实例化菊花图
this.spinner = ora()
this.init()
}
async init() {
try {
// 检查文件夹是否存在
await this.checkFolderExist();
// 下载git上到项目模板到临时文件夹
await this.downloadRepo();
// 将资源文件复制到模板文件夹
await this.copyRepoFiles();
// 修改package.json内容
await this.updatePkgFile();
// 初始化git
await this.initGit();
// 安装依赖
await this.runApp();
} catch (error) {
log.error(error);
exit(1)
} finally {
// 菊花图停止转动
this.spinner.stop();
}
}
checkFolderExist(){};
downloadRepo(){};
copyRepoFiles(){};
updatePkgFile(){};
initGit(){};
runApp(){};
}
exports.CreateCommand = Creator;
可以看到,init
其实就是依次执行了一些操作,下面会详细说明上面调用的方法。
外观(ora和chalk)
可以看到上图到终端里,有一个一直在转到东西,这个就是ora
在控制台显示的这种五彩斑斓到效果就是chalk
使用chalk封装log
// src/util/util.js
const chalk = require('chalk');
exports.log = {
warning(msg = '') {
console.warning(chalk.yellow(`${msg}`));
},
error(msg = '') {
console.error(chalk.red(`${msg}`));
},
success(msg = '') {
console.log(chalk.green(`${msg}`));
}
}
配合ora使用的例子
const chalk = require('chalk');
const ora = require('ora');
const spinner = ora('Loading start')
// 开启菊花转转
spinner.start(chalk.yellow('打印一个yellow色的文字'));
checkFolderExist()
// 检查文件夹是否存在
checkFolderExist() {
return new Promise(async (resolve, reject) => {
const { target } = this.RepoMaps
if (this.cmdParams.force) {
await fs.removeSync(target)
return resolve()
}
try {
// 否则进行文件夹检查
const isTarget = await fs.pathExistsSync(target)
if (!isTarget) return resolve()
// 后文引入inquirer的配置
const { recover } = await inquirer.prompt(InquirerConfig.folderExist);
if (recover === 'cover') {
await fs.removeSync(target);
return resolve();
} else if (recover === 'newFolder') {
const { inputNewName } = await inquirer.prompt(InquirerConfig.rename);
this.source = inputNewName;
this.RepoMaps.target = this.genTargetPath(`./${inputNewName}`);
return resolve();
} else {
exit(1);
}
} catch (error) {
log.error(`[my-cli]Error:${error}`)
exit(1);
}
})
}
在调用checkFolderExist
之前,我们解析得到了命令行的参数,如果当force
为true
,则直接删除该文件夹即可,如果没有该参数则询问用户想要怎么做。后面我们用到了fs-extra和Inquirer两个库,一个用于操作文件,另一个用于命令行交互,fs
的操作非常简单这里不做展开。
Inquirer使用
// src/command/config.js
exports.InquirerConfig = {
// 文件夹已存在的名称的询问参数
folderExist: [{
type: 'list',
name: 'recover',
message: '当前文件夹已存在,请选择操作:',
choices: [
{ name: '创建一个新的文件夹', value: 'newFolder' },
{ name: '覆盖', value: 'cover' },
{ name: '退出', value: 'exit' },
]
}],
// 重命名的询问参数
rename: [{
name: 'inputNewName',
type: 'input',
message: '请输入新的项目名称: '
}]
}
首先根据我们的需求,将我们要使用的命令行交互写下来:
- 如果文件夹存在时,对应
folderExist
操作,给用户一个选择,type
表示当前的命令行交互形式为一个列表,name
表示这个操作返回的字段名,message
为提示信息,然后choice
给用户三个选项,对应三个不同的返回值value
- 如果用户选择新建文件夹,对应
rename
操作,需要用户input
一个新的名称
downloadRepo()
完成文件夹检测之后,我们通过download-git-repo库从git
上根据RepoMaps
的配置拉取模板
const download = require('download-git-repo');
// 下载repo资源
downloadRepo() {
this.spinner.start('正在拉取项目模板...');
const { repo, temp } = this.RepoMaps
return new Promise(async (resolve, reject) => {
await fs.removeSync(temp);
download(repo, temp, async err => {
if (err) return reject(err);
this.spinner.succeed('模版下载成功');
return resolve()
})
})
}
copyRepoFiles()
const { copyFiles } = require('../util/util');
// 拷贝repo资源
async copyRepoFiles() {
const { temp, target } = this.RepoMaps
await copyFiles(temp, target, ['./git', './changelogs']);
}
// src/util/util.js
// 拷贝下载的repo资源
exports.copyFiles = async (tempPath, targetPath, excludes = []) => {
// 资源拷贝
await fs.copySync(tempPath, targetPath)
// 删除额外日志等文件
if (excludes && excludes.length) {
await Promise.all(excludes.map(file => async () =>
await fs.removeSync(path.resolve(targetPath, file))
));
}
}
将模板从下载的临时文件夹拷贝至目标文件夹,并删除git
相关文件
updatePkgFile()
因为目前的项目是从git
上拉取下来等,所以还需要更新package.json
// 更新package.json文件
async updatePkgFile() {
this.spinner.start('正在更新package.json...');
const pkgPath = path.resolve(this.RepoMaps.target, 'package.json');
// 定义需要移除的不必要字段
const unnecessaryKey = ['keywords', 'license', 'files']
// 获取git信息
const { name = '', email = '' } = await getGitUser();
// 读取package.json
const jsonData = fs.readJsonSync(pkgPath);
// 移除字段
unnecessaryKey.forEach(key => delete jsonData[key]);
// 更新package.json,覆盖下列字段并写入
Object.assign(jsonData, {
name: this.source,
author: name && email ? `${name} ${email}` : '',
provide: true,
version: "1.0.0"
});
await fs.writeJsonSync(pkgPath, jsonData, { spaces: '\t' })
this.spinner.succeed('package.json更新完成!');
}
getGitUser()
// src/util/util.js
// 获取git用户信息
exports.getGitUser = () => {
return new Promise(async (resolve) => {
const user = {}
try {
const [name] = await runCmd('git config user.name')
const [email] = await runCmd('git config user.email')
if (name) user.name = name.replace(/\n/g, '');
if (email) user.email = `<${email || ''}>`.replace(/\n/g, '')
} catch (error) {
log.error('获取用户Git信息失败')
reject(error)
} finally {
resolve(user)
}
});
}
// src/util/util.js
const childProcess = require('child_process');
// 运行cmd命令
const runCmd = (cmd) => {
return new Promise((resolve, reject) => {
childProcess.exec(cmd, (err, ...arg) => {
if (err) return reject(err)
return resolve(...arg)
})
})
}
通过启动一个node
子进程,执行上述命令即可获取到git
用户信息
initGit() && runApp()
async initGit() {
this.spinner.start('正在初始化Git管理项目...');
await runCmd(`cd ${this.RepoMaps.target}`);
// 改变node进程执行位置
process.chdir(this.RepoMaps.target);
await runCmd(`git init`);
this.spinner.succeed('Git初始化完成!');
}
// 安装依赖
async runApp() {
try {
this.spinner.start('正在安装项目依赖文件,请稍后...');
await runCmd(`npm install --registry=https://registry.npm.taobao.org`);
this.spinner.succeed('依赖安装完成!');
console.log('请运行如下命令启动项目吧:\n');
log.success(` cd ${this.source}`);
log.success(` npm run dev`);
} catch (error) {
console.log('项目安装失败,请运行如下命令手动安装:\n');
log.success(` cd ${this.source}`);
log.success(` npm install`);
}
}