背景
项目中建立了一套目录结构以及开发规范,还有一些公共文件,如果此时想要创建一个新的项目沿用之前项目的基本结构和开发规范怎么办。比如独立活动开发,创建不同的活动模板。新的项目要启用新的仓库
简单做法就是拷贝一份代码过去,然后删删改改,费时费力还容易出错,肯定不是一个合理的解决方案
这时脚手架的作用就显现出来了,搭建脚手架的目的就是快速的搭建项目的基本结构并提供项目规范和约定。目前日常工作中常用的脚手架有 vue-cli、create-react-app、angular-cli 等等。
原理
想象一下平时我们使用vue-cli时发生了什么。vue create <app-name>,其实主要就是下面几步
- 创建文件夹 <app-name>
- 询问用户需要使用的模板
- 查找本地是否有用户选择的模板
- 如果本地没有就从远程拉取,存放在本地
基础
写cli工具的时候首先需要具备一点点node基础,然后会使用一些工具库
ora 命令行的loading工具
更多使用方法详见文档ora
js
async function loading (fn, msg, ...args) {
// 使用 ora 初始化,传入提示信息 message
const loading = ora(msg)
loading.start()
try {
const res = await fn.apply(null, args)
// 把状态修改为成功
loading.succeed()
return res
} catch(err) {
// 把状态置为失败
loading.fail('Request failed')
}
}
const repoList = await loading(getRepoList, 'wait a moment,从远程获取模板信息')
commander 自定义命令行指令
更多使用方法详见文档commander
简单用法,另一篇文章里面有讲怎么创建自定义指令创建命令行工具
js
const { program } = require('commander');
program
.version(pkg.version)
.option('-i, --init', '初始化op文件')
.option('-c, --create <env>', '根据环境和op配置文件创建op')
.action(opts => {
// 约定初始化文件
if (opts.init) {
console.log('处理初始化op文件')
// creatOpInit()
}
if (opts.create) {
console.log('处理根据环境和op配置文件创建op')
// creatOp()
}
})
inquirer 命令行交互工具
更多使用方法详见文档inquirer
另一篇文章也有介绍
js
const inquirer = require('inquirer')
const questions = [{
type: 'list',
message: '请选择环境:',
name: 'env',
choices: [
"dev",
"fat",
"uat"
]
}]
inquirer
.prompt(
questions
)
.then(answers => {
console.log(JSON.stringify(answers,null,' '))
})
.catch(error => {
if (error.isTtyError) {
console.log('isTtyError: ',error)
} else {
console.log('Others err: ',error)
}
})
download-git-repo 下载远程仓库
更多使用方法详见文档download-git-repo
github暴露的api详见api.github.com/
当前示例代码中,我们把仓库上传到了github,所以只列举了github中获取api
当前使用了两个
- 获取模板列表:api.github.com/users/{user...
- 获取版本列表:api.github.com/repos/{user...
开始搭建自己的脚手架
首先我们创建一个指令my-cli,和vue-cli一样,我们的脚手架创建文件的命令也是my-cli create <app-name>
搭建项目目录
js
{
"name": "my-cli",
"version": "1.0.0",
"description": "",
"main": "index.js",
"bin": {
"my-cli": "./bin/index.js"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"publicConfig": {
"registry": "https://registry.npmjs.org/"
},
}
怎么创建命令行工具这里就不赘述了,直接开始逻辑梳理吧
入口文件 bin.js
bin.js中核心逻辑就是提供程序的入口
js
const createLib = require('../lib/create')
program
.command('create <app-name>')
.description('create a new project')
.option('-f, --force', '目标文件存在,强制重写目标文件')
.action((name, options) => {
// 开始创建
createLib.createCli(name, options)
})
create.js
想象一下在create.js中我们需要干什么
在创建目录的时候,如果当前目录下本来就存在要创建的目录怎么办
- 命令增加-f参数,直接移除掉原来的目录,然后创建目录文件
- 不加-f参数,需要询问用户是否需要覆盖
这样我们大概的代码逻辑就能写出来了
首先第一步先移除已有的目录
js
const path = require('path')
const fs = require('fs-extra')
const inquirer = require('inquirer')
const Generator = require('./generator')
/**
* 创建cli入口
* 1、如果目标目录不存在,直接创建目录
* 2、如果目标目录存在,询问用户是否需要覆盖
* @param {*} name
* @param {*} options
*/
const createCli = async function(name, options) {
const cwd = process.cwd()
// 需要创建的文件目录
const targetDir = path.join(cwd, name)
// 查看文件是否存在
if (fs.existsSync(targetDir)) {
if (options.force) {
// 删除目标文件
await fs.removeSync(targetDir)
} else {
// 询问是否删除,用到另一个库inquirer
console.log('是否需要删除已存在的文件')
let { action } = await inquirer.prompt([
{
name: 'action',
type: 'list',
message: '目标目录已经存在,请选择操作方式',
choices: [
{
name: 'Overwrite',
value: 'overwrite'
},
{
name: 'Cancel',
value: false
}
]
}
])
if(!action) {
return
} else if(action === 'overwrite') {
// 移除已存在的目录
console.log(`\r\nRemoving...`)
await fs.removeSync(targetDir)
}
}
}
}
代码执行到这一步时,你可以自己试一试,手动创建一个目录my-project,然后执行my-cli create my-project,执行完成之后会发现目录中已经没有my-project了
文件移除之后就到第二步,创建了,接下来就需要用到download-git-repo从远程下载模板了 在create.js中我们加入代码
js
const createCli = async function(name, options) {
...删除已有目录
// 创建项目
const generator = new Generator(name, targetDir)
generator.create()
}
generator.js
从远程仓库获取模板信息 我们在lib 目录下创建一个 http.js 处理请求信息
js
// 通过 axios 处理请求
// github暴露的常用api汇总
// https://www.cnblogs.com/ygunoil/p/13607491.html
const axios = require('axios')
axios.interceptors.response.use(res => {
return res.data
})
/**
* 获取模板列表
*/
async function getRepoList() {
return axios.get('https://api.github.com/users/houwenli/repos')
}
/**
* 获取版本列表
*/
async function getTagList(repo) {
return axios.get(`https://api.github.com/repos/houwenli/${repo}/releases`)
}
module.exports = {
getRepoList,
getTagList
}
获取模板信息(这里只是举个例子,仓库中可能会有vue2模板,vue3模板,vue3+ts等等)
获取模板后使用inquirer询问用户选择模板类型
最后返回用户选择
js
/**
* 获取用户模板
* 1、远程获取模板
* 2、用户自己选择模板信息
* 3、返回用户的选择
*/
async getRepo() {
const repoList = await loading(getRepoList, 'wait a moment,从远程获取模板信息')
if(!repoList) { return }
// 过滤我们需要的模板名称
const repos = repoList
.filter(item => {
return item.name.indexOf('template') > -1
})
.map(item => item.name)
const { repo } = await inquirer.prompt([
{
name: 'repo',
type: 'list',
message: '请选择仓库',
choices: repos
}
])
return repo
}
获取版本信息,这个就和获取模板信息一样的
js
/**
* 获取用户选择的版本,这个和getRepo逻辑基本一样
* 1、基于repo结果,远程拉取对应的tag列表
* 2、用户选择自己需要下载的tag
* 3、返回用户选择的tag
*/
async getTag(repo) {
const tagList = await loading(getTagList, 'wait a moment,从远程获取版本信息', repo)
// 过滤我们需要的模板名称
const tags = tagList
.map(item => item.tag_name)
const { tag } = await inquirer.prompt([
{
name: 'tag',
type: 'list',
message: '请选择版本',
choices: tags
}
])
return tag
}
生成文件的核心逻辑
获取用户选择的repo和tag后,我们就能使用download-git-repo获取模板了
js
/**
* 下载远程模板
* 1、拼接下载地址
* 2、调用下载方法
*/
async download(repo, tag) {
const requestUrl = `houwenli/${repo}${tag?'#'+tag:''}`;
// 调用下载方法
await loading(
this.downloadGitRepo, // 远程下载方法
'waiting download template', // 加载提示信息
requestUrl, // 参数1: 下载地址
this.targetDir
) // 参数2: 创建位置
}
js
async create() {
console.log('开始创建目录了')
const repo = await this.getRepo()
const tag = await this.getTag(repo)
console.log(`用户选择了,repo= ${repo},tag= ${tag}`)
await this.download(repo, tag)
}
到这一步实际上我们就从远程把模板下到本地了。
更进一步
每次执行my-cli create <app-name>的时候都会从远程下载,太耗时了。是否可以考虑把用户使用的模板下载到本地,然后再下一次在使用同样的模板时直接copy一份就可以了?
核心代码如下
js
// 修改download,下载到本地,然后复制到目标目录
/**
* 下载远程模板
* 1、拼接下载地址
* 2、调用下载方法
*/
async download(repo, tag) {
const requestUrl = `houwenli/${repo}${tag?'#'+tag:''}`;
// 本地下载一份,下次下载直接从备份获取
await loading(
this.downloadGitRepo, // 远程下载方法
'waiting download template', // 加载提示信息
requestUrl, // 参数1: 下载地址
path.resolve(__dirname, '../', `./${repo}/v${tag.replace(/\./g, '')}`)
) // 参数2: 创建位置
await copyFile(path.resolve(process.cwd(), `./${repo}/v${tag.replace(/\./g, '')}`), this.targetDir)
}
async create() {
....
// 先判断本地模板中有没有用户选择
// 拼接本地模板路径
let templatePath = path.resolve(__dirname, '../', `./${repo}/v${tag.replace(/\./g, '')}`)
if (fs.existsSync(templatePath)){
await copyFile(templatePath, this.targetDir)
} else {
await this.download(repo, tag)
}
....
}
接着我们还能做什么操作呢,vue-cli创建完后直接帮忙install了,是不是也能完成这一步呢
js
// 创建核心逻辑
async create() {
...完成目录文件的创建和拷贝
// 文件下载完成,执行npm install
console.log('开始执行npm install')
const execFn = util.promisify(exec)
await loading(
execFn, // 执行npm install
'npm installing...', // 加载提示信息
`cd ${this.targetDir} && npm install -D `
) // 参数2: 创建位置
}
到这里我们脚手架功能就完全实现了,剩下的就是发布到npm了npm publish,这个这里就不赘述了