每次自己写一个项目之前,都是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 nodeconsole.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.jsconst 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();// 初始化gitawait 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.jsconst 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.RepoMapsif (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.jsexports.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.RepoMapsreturn 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.RepoMapsawait 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.jsonconst 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.jsconst 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`);}}
