搭建一个简单的脚手架吧!

137 阅读8分钟

脚手架解决的问题

大家对脚手架应该不陌生,它能帮助我们快速搭建相同或自定义的骨架,如果我们在项目开发中想要统一相同的配置,如代码组织架构,模块依赖,工具配置等等,那么就可以开发一个脚手架,提升项目创建和维护的效率。

常见的脚手架

  • vue-cli
  • create-react-app
  • angular-cli

开发一个脚手架工具

新建项目并启动cli

  1. 这里新建一个项目 my-cli
  2. 执行npm init -y
  3. 创建bin目录,新建文件cli.js作为入口文件:
#! /usr/bin/env node
// #! 符号的名称叫 Shebang,用于指定脚本的解释程序
// Node CLI 应用入口文件必须要有这样的文件头
console.log('hello my-cli')

此时的package.json文件如下:

{
  "name": "my-cli",
  "version": "1.0.0",
  "description": "",
  "main": "bin/cli.js",
  "bin": {
    "my-cli": "bin/cli.js"
  },
  "directories": {
    "lib": "lib"
  },
  "scripts": {
    "test": "echo "Error: no test specified" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
}
  1. 执行npm link,将当前目录作为全局命令
  2. 测试一下,直接执行my-cli,命令行中打印hello my-cli,成功!

学习常用的搭建脚手架的工具

常用的工具有:

名称功能
commander改造命令行终端,自定义指令
inquirer命令行交互
chalk设置当前命令行的样式
ora用于在命令行中添加loading
download-git-repo下载远程模版

commander

基本使用

#! /usr/bin/env node
const { program } = require('commander')
// 新增一些自定义的可选属性
program.option('-f --framework <framework>', 'select your framework')
program.option('-d --dest <dest>', 'a folder')

// 处理帮助信息
const examples = {
  create: ['my-cli create|crt <project>'],
  config: ['my-cli config|cfg set <k> <v>', 'my-cli config|cfg get']
}
// 自定义事件监听
program.on('--help', () => {
  console.log('examples:')
  Object.keys(examples).forEach((actionName) => {
    examples[actionName].forEach((item) => {
      console.log("  " + item)
    })
  })
})

// .parse的第一个参数是要解析的字符串数组
// process.argv指明按 node 约定
program.parse(process.argv)

执行my-cli --help

自定义command命令

// .command添加自定义命令, project必填 others选填
// .alias 设置别名
// .description 设置描述
// .action 回调,执行后续操作

program
  .command('create <project> [others...]')
  .alias('crt')
  .description('create new project')
  .action((name, args) => {
    console.log(name, args)
  })
program.parse(process.argv)

chalk 美化命令行

基本使用

const chalk = require('chalk')
// 文字颜色
console.log(chalk.red('绿色文字'))
console.log(chalk.keyword('green')('内容'))
console.log(chalk.bgGreen('hello world'))
// 格式化输出
console.log(chalk.green.bold`
{red 字体}
加粗`)

效果如下:

ora

设置加载中效果

const ora = require('ora')
const chalk = require('chalk')
const spinner = ora('正在查询').start()
spinner.color = 'red'
spinner.text = 'searching'
setTimeout(() => { spinner.succeed(chalk.green('查询成功')) }, 2000)

inquirer 命令行交互

基本使用

const inquirer = require('inquirer')
// 定义问题,按照格式定义
const queList = [
  {
    type: 'input',  // 类型
    name: 'username', // 答案的键名
    message: 'please input your name', // 提示信息
    validate(val) {
      if (!val) {
        return '当前为必填项'
      } else {
        return true
      }
    }
  }, {
    type: 'input',
    name: 'age',
    message: 'please input your age'
  }
]

inquirer.prompt(queList).then((value) => {
  // 打印交互结果
  console.log(value)
})

cross-spawn 自动执行shell命令

github.com/moxystudio/…

在 Node 中,可以通过 child_process 模块来创建子进程,并且通过 child_process.spawn 方法来使用指定的命令行参数创建新进程,执行完之后返回执行结果。而 cross-spawn 包就是提供了关于 spawn 函数的跨平台写法,不用开发者处理跨平台的逻辑。

const spawn = require('cross-spawn');
// 安装全部依赖
spawn.sync('npm', ['install'], { stdio: 'inherit' });
// 安装指定依赖
spawn.sync('npm', ['install', 'lodash', '--save'], { stdio: 'inherit' });
#! /usr/bin/env node 
const spawn = require('cross-spawn');
const chalk = require('chalk')
// 定义需要按照的依赖
const dependencies = ['vue', 'vuex', 'vue-router'];
// 执行安装
const child = spawn('npm', ['install', '-D'].concat(dependencies), { 
    stdio: 'inherit' 
});

// 监听执行结果
child.on('close', function(code) {
    // 执行失败
    if(code !== 0) {
        console.log(chalk.red('Error occurred while installing dependencies!'));
        process.exit(1);
    }
    // 执行成功
    else {
        console.log(chalk.cyan('Install finished'))   
    }
})

如果用node提供的child_process的话 需要判断一下平台。

正式开始搭建

明确需要实现的功能

  1. 使用my-cli crt 作为创建项目的入口(commander实现)
  2. 让用户选择需要下载的项目模板inquirer(模版放在平台,所以需要查询远端模版,查询选中的模版是否存在多个版本)
  3. 下载模版(download-git-repo

详细步骤

封装功能模块

基于前面的基础,改造一下cli.js文件和项目结构,将功能模块划分的更加清晰点。

  1. 创建lib/core文件夹,创建help.js,actions.js,commander.js三个文件。
  • help.js 新增一些自定义的可选属性和添加帮助信息。
const helpOptions = function (program) {
  // 新增自定义的可选属性
  program.option('-f --framework <framework>', 'select your framework')
  program.option('-d --dest <dest>', 'a folder')

  // 处理帮助信息
  const examples = {
    create: ['my-cli create|crt <project>'],
    config: ['my-cli config|cfg set <k> <v>', 'my-cli config|cfg get']
  }

  // 触发事件
  program.on('--help', () => {
    console.log('examples:')
    Object.keys(examples).forEach((actionName) => {
      examples[actionName].forEach((item) => {
        console.log("  " + item)
      })
    })
  })
}
module.exports = helpOptions
  • command.js 定义command命令
const { createAction } = require('./actions')
const helpCommand = function (program) {
  program
    .command('create <project> [others...]')
    .alias('crt')
    .description('create new project')
    .action(createAction)
}
module.exports = helpCommand
  • actions.js commandaction回调内容,也就是执行my-cli crt [project-name]后将要继续执行的操作。

actions中的内容较多,且是核心部分,下面会详细介绍。

  1. 改造 cli.js 文件
#! /usr/bin/env node
const { program } = require('commander')
const helpOptions = require('../lib/core/help')
const helpCommand = require('../lib/core/command')
// 处理自定义的帮助信息提示
helpOptions(program)
// 添加自定义命令
helpCommand(program)

// 配置版本号信息
program.version(require('../package.json').version).parse(process.argv)

下载模版

现在actions.js是这样,导出createAction作为command.action的回调。

const createAction = function (project) {
  console.log('project', project) // command.js中create的项目
  // 1. 查询模板信息
  // 2. 准备问题-》选择哪个模板
  // 3. 查询模板tag信息, 区别不同版本的下载,无tag时直接下载
  // 4. 检查是否目录已存在,如果存在,取缓存内容(这部分可以自定义)
  // 5. 下载完成后,在本地目录中生成项目
}

module.exports = {
  createAction
}
  1. 查询模版信息

获取github项目信息,我这里在github上新创建一个专门用于维护项目模板的项目组organizaition,也可以直接访问仓库。这里携带token请求验证的原因是github项目信息的api调用次数有限,所以再申请一个token。这里需要具体到要下载模版的那个版本。

const fetchInfo = async function (repoName, tmpName) {
  // 若直接下载个人仓库 repoName 为github用户名,若为项目组 repoName 为项目组的名称
  const headers = { "Authorization": "token: " + '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' }
  // 个人仓库 repoUrl
  // const repoUrl = `https://api.github.com/users/${repoName}/repos`
  // 项目组 repoUrl
  const repoUrl = `https://api.github.com/orgs/${repoName}/repos`
  // 获取所有的版本
  const tmpUrl = `https://api.github.com/repos/${repoName}/${tmpName}/tags`
  const url = !tmpName ? repoUrl : tmpUrl
  const { data } = await axios({ url, method: 'get', headers: headers })
  return data.map(item => item.name) // 返回所有的仓库名或版本名
}

const createAction = function (project) {
  // 1. 查询模板信息
  const repos = await fetchInfo('my-cli-template')
}

为了优化用户体验,可以加一层使用ora实现的loading效果,借用柯里化思想实现。

const addLoading = function (fn) {
  return async function (...args) {
    const spinner = ora('searching').start()
    const ret = await fn(...args)
    spinner.succeed('search finished')
    return ret
  }
}

const createAction = function (project) {
  // 1. 查询模板信息
  const repos = await addLoading(fetchInfo)('my-cli-template')
}
  1. 用户选择模板

查询出所有的模版后,可以准备问题来询问用户选择哪个模版了。

  ...
  // 2. 准备问题
  const queList = [
    {
      type: 'list',
      name: 'tmpRepo',
      message: 'please select the template repo',
      choices: repos
    }
  ]
  const { tmpRepo } = await inquirer.prompt(queList)

用户选择完模版后,需要去查询对应tags。

  const tags = await addLoading(fetchInfo)('my-cli-template', tmpRepo)
  1. 用户选择版本
  // 区别不同版本数的下载逻辑
  // destUrl 是缓存目录,不需要重复下载
  let destUrl = null
  if (tags.length) {
    // 询问用户选择哪个版本
    const quesTag = [
      {
        type: 'list',
        name: 'tmpTag',
        message: 'please select the template tag',
        choices: tags
      }
    ]
    const { tmpTag } = await inquirer.prompt(quesTag) 
    destUrl =  await downLoadRepo(tmpRepo, tmpTag)

  } else {
    // tags为空 只有一个版本 直接下载
    destUrl = await downLoadRepo(tmpRepo)
  }

选择完版本后,已经指定好对应的模版了,接下来需要使用download-git-repo下载该模版,因为可能需要多次下载模版,所以顺便设置了一个缓存目录,如果模版有改动可以重新打tag,重新下载指定版本。这部分可以自定义,比如询问用户是否要覆盖已存在的目录等等,看实际情况吧。

let downLoadFn = require('download-git-repo')
downLoadFn = promisify(downLoadFn)

// 处理路径
const toUnixPath = function (path) {
  return path.replace(/\\/g, '/')
}

const downLoadRepo = async function (repo, tag) {
  // 定义缓存目录
  const cacheDir = toUnixPath(`${process.env[process.platform === 'win32' ? 'USERPROFILE' : 'HOME']}/.tmp`)
  // 下载
  let api = `my-cli-template/${repo}`
  if (tag) api += `#/${tag}`
  // 完善缓存目录
  const dest = tag ? toUnixPath(path.resolve(cacheDir, repo, tag)) : toUnixPath(path.resolve(cacheDir, repo))
  // 判断dist目录是否有缓存
  if (!fs.existsSync(dest)) {
    // 将api对应的内容下载到dist目录中
    await addLoading(downLoadFn)(api, dest)
  }
  // 将目录返回回去,用于将来拷贝内容
  return dest
}

拷贝项目

const ncp = require('ncp')

// 拷贝项目,拷贝缓存中的内容到创建的项目中
// 还记得吧,project是my-cli crt 创建的项目名
ncp(destUrl, project)

发布项目

项目提交git后,可以发布项目到npm上了,之后就可以用自己开发的脚手架创建项目了。

  1. 完善一下 package.json 文件
{
  "name": "zhp-cli-demo",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "bin": {
    "zc": "bin/cli.js"
  },
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/murph-1999/my-cli.git"
  },
  "keywords": [
    "zhp-cli",
    "zc",
    "脚手架"
  ],
  "author": "",
  "license": "ISC",
  "bugs": {
    "url": "https://github.com/murph-1999/my-cli/issues"
  },
  "homepage": "https://github.com/murph-1999/my-cli#readme",
  "dependencies": {
    "axios": "^0.27.2",
    "chalk": "4",
    "commander": "^9.4.0",
    "consolidate": "^0.16.0",
    "download-git-repo": "^3.0.2",
    "ejs": "^3.1.8",
    "inquirer": "8",
    "metalsmith": "^2.5.0",
    "ncp": "^2.0.0",
    "ora": "5"
  }
}
  1. 创建一个 npm 账号
  2. 命令行 npm publish,第一次应该需要登录一下,执行npm adduser

image.png

  1. 重新发布时注意更新版本号,如果出错了,照着 err 的信息来没什么大问题

image.png

测试

发布完脚手架后,本地创建项目测试一下。

npm i zhp-cli-demo
zc create demo1

总结

以上就是一个脚手架雏形的实现,具体还有很多可扩展的内容和功能,不过通过这篇文章已经可以对脚手架的设计有一个初步的认识了。我们要学习的不仅仅是如何搭建一个脚手架,搭建完不等于掌握了,希望最后还是能应用到实际项目中,解决实际问题。