脚手架解决的问题
大家对脚手架应该不陌生,它能帮助我们快速搭建相同或自定义的骨架,如果我们在项目开发中想要统一相同的配置,如代码组织架构,模块依赖,工具配置等等,那么就可以开发一个脚手架,提升项目创建和维护的效率。
常见的脚手架
- vue-cli
- create-react-app
- angular-cli
开发一个脚手架工具
新建项目并启动cli
- 这里新建一个项目
my-cli - 执行
npm init -y - 创建
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",
}
- 执行
npm link,将当前目录作为全局命令 - 测试一下,直接执行
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命令
在 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的话 需要判断一下平台。
正式开始搭建
明确需要实现的功能
- 使用
my-cli crt作为创建项目的入口(commander实现) - 让用户选择需要下载的项目模板
inquirer(模版放在平台,所以需要查询远端模版,查询选中的模版是否存在多个版本) - 下载模版(
download-git-repo)
详细步骤
封装功能模块
基于前面的基础,改造一下cli.js文件和项目结构,将功能模块划分的更加清晰点。
- 创建
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
command的action回调内容,也就是执行my-cli crt [project-name]后将要继续执行的操作。
actions中的内容较多,且是核心部分,下面会详细介绍。
- 改造 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
}
- 查询模版信息
获取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')
}
- 用户选择模板
查询出所有的模版后,可以准备问题来询问用户选择哪个模版了。
...
// 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)
- 用户选择版本
// 区别不同版本数的下载逻辑
// 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上了,之后就可以用自己开发的脚手架创建项目了。
- 完善一下 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"
}
}
- 创建一个 npm 账号
- 命令行
npm publish,第一次应该需要登录一下,执行npm adduser
- 重新发布时注意更新版本号,如果出错了,照着 err 的信息来没什么大问题
测试
发布完脚手架后,本地创建项目测试一下。
npm i zhp-cli-demo
zc create demo1
总结
以上就是一个脚手架雏形的实现,具体还有很多可扩展的内容和功能,不过通过这篇文章已经可以对脚手架的设计有一个初步的认识了。我们要学习的不仅仅是如何搭建一个脚手架,搭建完不等于掌握了,希望最后还是能应用到实际项目中,解决实际问题。