每次自己写一个项目之前,都是create-react-app来快速搭建的项目,但是每次都要在基础之上做一些修改,大多时候都是从之前的项目中copy代码过来,感觉每次做这些重复的操作是在浪费时间,于是决定学习一下如何自己搭建一个脚手架

目前想到的需要的功能

  • 可以通过终端命令my-cli create my-app来创建my-app项目
  • 创建的模板包含gitreact-routeraxioswebpack等常用插件
  • 创建package.json等文件内容
  • 自动安装当前的依赖

image.png

初始化

  1. # 终端创建项目根文件夹并进入到根文件夹
  2. mkdir my-cli && cd my-cli
  3. # 创建bin和src文件夹
  4. mkdir bin src
  5. # 初始化npm的包管理文件,该命令会询问你很多配置参数,如果不想询问直接在后面加-y参数即可
  6. npm init
  7. # bin下创建init.js作为脚本的入口文件
  8. cd bin && touch init.js
  9. # 并在init.js中键入如下内容:
  10. #!/usr/bin/env node
  11. console.log('Hello,my bin!')

image (1).png

接下来配置package.json文件,添加bin字段:

  1. {
  2. "bin": {
  3. "my-cli": "bin/init.js"
  4. },
  5. }

这里定义了一个my-cli命令,执行该命令时,会运行配置的bin/init.js脚本,然后为了方便测试,通过执行npm link将这个包挂载到全局,然后执行my-cli
image.png

Commander.js

我们在用一些脚手架的时候,可以通过命令行做一些交互,如配置author,description,name,或者带一些参数执行命令,为了达到上述目标,可以使用commander.js,参考文档:https://github.com/tj/commander.js/blob/HEAD/Readme_zh-CN.md

安装
  1. npm install commander -S

使用
  1. #! /usr/bin/env node
  2. // 引入依赖
  3. const { Command } = require('commander');
  4. const program = new Command();
  5. const package = require('../package');
  6. // 定义版本和参数选项
  7. program
  8. .version(package.version, '-v, --version')
  9. .option('-i, --init', 'init something')
  10. //解析对应参数
  11. program.parse(process.argv);
  12. //如果用户输入了上述参数,则会触发的事件
  13. if(program.init) {
  14. console.log('init something')
  15. }

创建子命令

  1. // 调用command方法,创建一个create命令,同时create命令后面必须跟一个命令参数<project-name>
  2. program.command('create <project-name>')
  3. // 定义该命令的描述
  4. .description('创建<project-name>项目')
  5. // 指定额外参数
  6. .option('-f, --force', '忽略文件夹检查,如果已存在则直接覆盖')
  7. /**
  8. * 定义实现逻辑
  9. * source表示<project-name>参数
  10. * destination表示终端的cmd对象
  11. */
  12. .action((source, destination) => {
  13. console.log(`创建了${source}项目`)
  14. // new CreateCommand(source, destination)
  15. });

我们需要关注一下destination这个参数,这个参数中的options包含了该命令所定义的参数列表,当执行命令时带了参数,则destination对象中对应的长命令键的值为true,因此为了解析到参数,我们从options拿到定义的所有长命令去匹配,并返回键值对:

  1. // src/util/util.js
  2. const isFunction = val => val && typeof val === 'function';
  3. /**
  4. * parseCmdParams
  5. * @description 解析用户输入的参数
  6. * @param {} cmd Cammander.action解析出的cmd对象
  7. * @returns { Object } 返回一个用户参数的键值对象
  8. */
  9. exports.parseCmdParams = (cmd) => {
  10. if (!cmd) return {}
  11. const resOps = {}
  12. cmd.options.forEach(option => {
  13. const key = option.long.replace(/^--/, '');
  14. if (cmd[key] && !isFunction(cmd[key])) {
  15. resOps[key] = cmd[key]
  16. }
  17. })
  18. return resOps
  19. }

Creator

  1. class Creator {
  2. constructor(source, destination, ops = {}) {
  3. this.source = source
  4. //解析得到的参数
  5. this.cmdParams = parseCmdParams(destination)
  6. this.RepoMaps = Object.assign({
  7. // 项目模板地址
  8. repo: RepoPath,
  9. // 临时缓存地址
  10. temp: path.join(__dirname, '../../__temp__'),
  11. // 项目目标存放地址
  12. target: this.genTargetPath(this.source)
  13. }, ops);
  14. // git信息
  15. this.gitUser = {}
  16. // 实例化菊花图
  17. this.spinner = ora()
  18. this.init()
  19. }
  20. async init() {
  21. try {
  22. // 检查文件夹是否存在
  23. await this.checkFolderExist();
  24. // 下载git上到项目模板到临时文件夹
  25. await this.downloadRepo();
  26. // 将资源文件复制到模板文件夹
  27. await this.copyRepoFiles();
  28. // 修改package.json内容
  29. await this.updatePkgFile();
  30. // 初始化git
  31. await this.initGit();
  32. // 安装依赖
  33. await this.runApp();
  34. } catch (error) {
  35. log.error(error);
  36. exit(1)
  37. } finally {
  38. // 菊花图停止转动
  39. this.spinner.stop();
  40. }
  41. }
  42. checkFolderExist(){};
  43. downloadRepo(){};
  44. copyRepoFiles(){};
  45. updatePkgFile(){};
  46. initGit(){};
  47. runApp(){};
  48. }
  49. exports.CreateCommand = Creator;

可以看到,init其实就是依次执行了一些操作,下面会详细说明上面调用的方法。

外观(ora和chalk)

如何自己搭建一个脚手架 - 图4

可以看到上图到终端里,有一个一直在转到东西,这个就是ora
如何自己搭建一个脚手架 - 图5

在控制台显示的这种五彩斑斓到效果就是chalk

使用chalk封装log
  1. // src/util/util.js
  2. const chalk = require('chalk');
  3. exports.log = {
  4. warning(msg = '') {
  5. console.warning(chalk.yellow(`${msg}`));
  6. },
  7. error(msg = '') {
  8. console.error(chalk.red(`${msg}`));
  9. },
  10. success(msg = '') {
  11. console.log(chalk.green(`${msg}`));
  12. }
  13. }

配合ora使用的例子
  1. const chalk = require('chalk');
  2. const ora = require('ora');
  3. const spinner = ora('Loading start')
  4. // 开启菊花转转
  5. spinner.start(chalk.yellow('打印一个yellow色的文字'));

checkFolderExist()

  1. // 检查文件夹是否存在
  2. checkFolderExist() {
  3. return new Promise(async (resolve, reject) => {
  4. const { target } = this.RepoMaps
  5. if (this.cmdParams.force) {
  6. await fs.removeSync(target)
  7. return resolve()
  8. }
  9. try {
  10. // 否则进行文件夹检查
  11. const isTarget = await fs.pathExistsSync(target)
  12. if (!isTarget) return resolve()
  13. // 后文引入inquirer的配置
  14. const { recover } = await inquirer.prompt(InquirerConfig.folderExist);
  15. if (recover === 'cover') {
  16. await fs.removeSync(target);
  17. return resolve();
  18. } else if (recover === 'newFolder') {
  19. const { inputNewName } = await inquirer.prompt(InquirerConfig.rename);
  20. this.source = inputNewName;
  21. this.RepoMaps.target = this.genTargetPath(`./${inputNewName}`);
  22. return resolve();
  23. } else {
  24. exit(1);
  25. }
  26. } catch (error) {
  27. log.error(`[my-cli]Error:${error}`)
  28. exit(1);
  29. }
  30. })
  31. }

在调用checkFolderExist之前,我们解析得到了命令行的参数,如果当forcetrue,则直接删除该文件夹即可,如果没有该参数则询问用户想要怎么做。后面我们用到了fs-extraInquirer两个库,一个用于操作文件,另一个用于命令行交互,fs的操作非常简单这里不做展开。

Inquirer使用
  1. // src/command/config.js
  2. exports.InquirerConfig = {
  3. // 文件夹已存在的名称的询问参数
  4. folderExist: [{
  5. type: 'list',
  6. name: 'recover',
  7. message: '当前文件夹已存在,请选择操作:',
  8. choices: [
  9. { name: '创建一个新的文件夹', value: 'newFolder' },
  10. { name: '覆盖', value: 'cover' },
  11. { name: '退出', value: 'exit' },
  12. ]
  13. }],
  14. // 重命名的询问参数
  15. rename: [{
  16. name: 'inputNewName',
  17. type: 'input',
  18. message: '请输入新的项目名称: '
  19. }]
  20. }

首先根据我们的需求,将我们要使用的命令行交互写下来:

  • 如果文件夹存在时,对应folderExist操作,给用户一个选择,type表示当前的命令行交互形式为一个列表,name表示这个操作返回的字段名,message为提示信息,然后choice给用户三个选项,对应三个不同的返回值value
  • 如果用户选择新建文件夹,对应rename操作,需要用户input一个新的名称

downloadRepo()

完成文件夹检测之后,我们通过download-git-repo库从git上根据RepoMaps的配置拉取模板

  1. const download = require('download-git-repo');
  2. // 下载repo资源
  3. downloadRepo() {
  4. this.spinner.start('正在拉取项目模板...');
  5. const { repo, temp } = this.RepoMaps
  6. return new Promise(async (resolve, reject) => {
  7. await fs.removeSync(temp);
  8. download(repo, temp, async err => {
  9. if (err) return reject(err);
  10. this.spinner.succeed('模版下载成功');
  11. return resolve()
  12. })
  13. })
  14. }

copyRepoFiles()

  1. const { copyFiles } = require('../util/util');
  2. // 拷贝repo资源
  3. async copyRepoFiles() {
  4. const { temp, target } = this.RepoMaps
  5. await copyFiles(temp, target, ['./git', './changelogs']);
  6. }
  1. // src/util/util.js
  2. // 拷贝下载的repo资源
  3. exports.copyFiles = async (tempPath, targetPath, excludes = []) => {
  4. // 资源拷贝
  5. await fs.copySync(tempPath, targetPath)
  6. // 删除额外日志等文件
  7. if (excludes && excludes.length) {
  8. await Promise.all(excludes.map(file => async () =>
  9. await fs.removeSync(path.resolve(targetPath, file))
  10. ));
  11. }
  12. }

将模板从下载的临时文件夹拷贝至目标文件夹,并删除git相关文件

updatePkgFile()

因为目前的项目是从git上拉取下来等,所以还需要更新package.json

  1. // 更新package.json文件
  2. async updatePkgFile() {
  3. this.spinner.start('正在更新package.json...');
  4. const pkgPath = path.resolve(this.RepoMaps.target, 'package.json');
  5. // 定义需要移除的不必要字段
  6. const unnecessaryKey = ['keywords', 'license', 'files']
  7. // 获取git信息
  8. const { name = '', email = '' } = await getGitUser();
  9. // 读取package.json
  10. const jsonData = fs.readJsonSync(pkgPath);
  11. // 移除字段
  12. unnecessaryKey.forEach(key => delete jsonData[key]);
  13. // 更新package.json,覆盖下列字段并写入
  14. Object.assign(jsonData, {
  15. name: this.source,
  16. author: name && email ? `${name} ${email}` : '',
  17. provide: true,
  18. version: "1.0.0"
  19. });
  20. await fs.writeJsonSync(pkgPath, jsonData, { spaces: '\t' })
  21. this.spinner.succeed('package.json更新完成!');
  22. }

getGitUser()
  1. // src/util/util.js
  2. // 获取git用户信息
  3. exports.getGitUser = () => {
  4. return new Promise(async (resolve) => {
  5. const user = {}
  6. try {
  7. const [name] = await runCmd('git config user.name')
  8. const [email] = await runCmd('git config user.email')
  9. if (name) user.name = name.replace(/\n/g, '');
  10. if (email) user.email = `<${email || ''}>`.replace(/\n/g, '')
  11. } catch (error) {
  12. log.error('获取用户Git信息失败')
  13. reject(error)
  14. } finally {
  15. resolve(user)
  16. }
  17. });
  18. }
  1. // src/util/util.js
  2. const childProcess = require('child_process');
  3. // 运行cmd命令
  4. const runCmd = (cmd) => {
  5. return new Promise((resolve, reject) => {
  6. childProcess.exec(cmd, (err, ...arg) => {
  7. if (err) return reject(err)
  8. return resolve(...arg)
  9. })
  10. })
  11. }

通过启动一个node子进程,执行上述命令即可获取到git用户信息

initGit() && runApp()

  1. async initGit() {
  2. this.spinner.start('正在初始化Git管理项目...');
  3. await runCmd(`cd ${this.RepoMaps.target}`);
  4. // 改变node进程执行位置
  5. process.chdir(this.RepoMaps.target);
  6. await runCmd(`git init`);
  7. this.spinner.succeed('Git初始化完成!');
  8. }
  1. // 安装依赖
  2. async runApp() {
  3. try {
  4. this.spinner.start('正在安装项目依赖文件,请稍后...');
  5. await runCmd(`npm install --registry=https://registry.npm.taobao.org`);
  6. this.spinner.succeed('依赖安装完成!');
  7. console.log('请运行如下命令启动项目吧:\n');
  8. log.success(` cd ${this.source}`);
  9. log.success(` npm run dev`);
  10. } catch (error) {
  11. console.log('项目安装失败,请运行如下命令手动安装:\n');
  12. log.success(` cd ${this.source}`);
  13. log.success(` npm install`);
  14. }
  15. }

参考文章

详解前端脚手架开发排坑全指南【前端提效必须上干货】