准备工作(一些常用库)
ora
可以用于表示当前模板的状态
const oraIcon = ora().start(`正在下载模板\n`);
oraIcon.fail(`模板下载失败\n`);
oraIcon.succeed("模板下载成功");
figlet
粉笔字体库
const font = figlet.textSync('CLI-TOOL', {
font: 'bulbhead'
})
commander
用于在终端创建脚手架的执行指令
const program = require('commander')
program
.command('init template')
.description('初始化项目模板')
.action(async() => {
// 此处添加指令需要执行的逻辑
}
inquirer
添加选择列表
inquirer.prompt({
type: "list",
message: "请选择创建项目类型",
name: "select",
choices: [{
name: 'Electron项目模板',
value: 'electron'
},
{
name: 'PC项目模板',
value: 'pc'
}]
}).then((data) => {
// 此处执行条件选择后的逻辑
}
log-symbols
为信息输出添加图标
console.log(logSymbols.error, chalk.green("模板下载成功"))
chalk
添加文字颜色和样式
download-git-repo
下载远程仓库模板,因为我的脚手架中使用的是本地模板,所以没有用这个库,通常需要下载一些github上的公共模板的时候,需要通过这个库来调用
const download = require('download-git-repo')
downloa(repository, ProjectName, options, callback)
repository:远程仓库地址
http://github.com:hyf940760301/cli-template#main (地址需要按照这种格式来定义,代码存放的网站地址:用户名/项目名#分支名)
projectName:存放下载的文件路径,可以直接是文件名,默认是当前目录
options:配置项,如{clone: boolean}
表示用http download还是git clone下载
callback:回调函数,可以在里面对操作过程进行一些设置,比如添加文本颜色,文案内容或增加一些图标等
实现脚手架的搭建
1. 初始化项目
本地创建文件夹后
进入文件夹根目录,npm初始化配置,生成package.json文件
cd ./cli-tool
npm init
2. 创建脚手架入口文件
一般在根目录创建bin文件夹
在bin文件夹中创建index.js
对不不是很复杂的脚手架,index.js文件中就是我们脚手架实现的全部核心代码了
// ./bin/index.js
#!/usr/bin/env node // 注意:这一行一定要添加到你的入口文件顶部,否则在关联本地或者上传仓库后,执行指令会出现指令报错的情况。
console.log('this is cli-tool')
3. 修改入口指向,创建本地符号链接后测试指令
修改package.json中的内容
// package.json
{
"name": "cli-tool",
"version": "0.0.1",
"description": "前端脚手架",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"watch": "npm run compile -- --watch"
},
"author": "xxx",
"license": "ISC",
// 注:这里需要添加的是脚手架运行的指令以及指向的入口文件
"bin": {
"cli-tool": "./bin/index.js"
},
"dependencies": {
"babel-cli": "^6.26.0",
"babel-env": "^2.4.1",
"chalk": "^3.0.0",
"commander": "^5.0.0",
"figlet": "^1.5.2",
"glob": "^8.0.3",
"inquirer": "^7.1.0",
"log-symbols": "^3.0.0",
"ora": "^4.0.3"
},
"devDependencies": {
"babel-cli": "^6.26.0",
"babel-core": "^6.26.3",
"babel-env": "^2.4.1"
}
}
添加关联本地的符号链接,测试指令运行
(在项目的根目录执行, 然后就可以在我们本地C盘中node文件夹下找到我们新添加的指令了)
本地全局运行指令,查看打印结果
4. 走通整个流程后,我们开始改造入口文件,丰富脚手架的内容
// ./bin/index.js
const fs = require('fs')
const ora = require('ora')
const path = require('path')
const glob = require('glob')
const chalk = require('chalk')
const figlet = require('figlet')
const process = require('process')
const program = require('commander')
const inquirer = require('inquirer')
const logSymbols = require('log-symbols')
program
.version('0.1.0') // -v 或者 --version 的时候会输出该版本号
// 添加脚手架执行指令:cli-tool init template
program
.command('init template')
.description('初始化项目模板')
.action(async() => {
// 打印粉笔字
const font = figlet.textSync('CLI-TOOL', {
font: 'bulbhead'
})
// 第一次选择列表,选择Electron或者PC端前端项目模板
inquirer.prompt({
type: "list",
message: "请选择创建项目类型",
name: "select",
choices: [
{
name: 'Electron项目模板',
value: 'electron'
},
{
name: 'PC项目模板',
value: 'pc'
}
]
}).then((data) => {
console.log(`\n`)
if (data.select === 'electron') {
// 等待第一次选择结束后,执行第二次选择列表,这里因为只有两个列表选择,所以就直接在then()方法里执行了下一个promise
// 如果存在多个选择列表,建议使用Promise.all方法来实现
inquirer.prompt(defaultData.project).then((answers) => {
// 拷贝文件夹,将本地目标文件夹模板直接复制到当前新建的文件夹下
// 关于模板拷贝的细节,下一部分会把这段代码单独拎出来,这里先大致浏览一下就行
function copyDir(src, dist, callback) {
fs.access(dist, function(err){
if(err){
// 目录不存在时创建目录
fs.mkdirSync(dist);
}
_copy(null, src, dist);
});
function _copy(err, src, dist) {
if(err){
callback(err);
} else {
try {
const paths = fs.readdirSync(src)
paths.forEach(function(_path) {
const _src = path.join(src, _path);
const _dist = path.join(dist, _path);
fs.stat(_src, function(err, stat) {
if(err){
callback(err);
} else {
// 判断是文件还是目录
if(stat.isFile()) {
fs.writeFileSync(_dist, fs.readFileSync(_src));
} else if(stat.isDirectory()) {
// 当是目录是,递归复制
copyDir(_src, _dist, callback)
}
}
})
})
} catch (err) {
callback(err)
}
}
}
}
// 1. 获取当前命令所在目录
const dirPath = process.cwd();
console.log(dirPath)
// 2. 扫描当前目录, 判断是否有同名文件夹存在
const fileList = glob.sync('*')
console.log(fs.readdirSync(dirPath))
if (fileList.some((element) => element === answers.name)) {
console.log(logSymbols.error, `当前目录下存在同名文件夹\n`)
console.log(logSymbols.error, `创建失败\n`)
} else {
// 3. 如果没有,创建文件夹
try {
fs.mkdirSync(`${dirPath}${answers.name}`)
console.log(logSymbols.success, `目录创建成功\n`)
// 开始下载模板
const oraIcon = ora().start(`正在下载模板\n`);
// 4. 创建好文件夹后,复制本地模板到创建的目标文件夹
// __dirname:返回运行文件所在的目录 E:\dbt\cli-tool\bin
const dirName = __dirname.split('')
// 目的是为了删除目录中的\bin部分
dirName.splice(-4, 4)
const resultDirName = dirName.join('')
// 5. 复制目标文件夹内容
copyDir(path.join(resultDirName, 'packages'), path.join(dirPath, answers.name), (err) => {
if (err) {
oraIcon.fail(`模板下载失败\n`);
}
})
setTimeout(() => {
oraIcon.succeed("模板下载成功");
console.log(chalk.green(`
======================================================\n
进入项目根目录: cd ./${answers.name}\n
安装项目依赖: yarn install\n
启动项目: yarn run serve 或 npm run serve\n
======================================================
`))}, 2000)
} catch (err) {
console.log(logSymbols.error, chalk.green("目录创建失败"))
}
}
})
}
})
})
// 没有任何命令的时候输出使用帮助
if (!process.argv.slice(2).length) {
program.outputHelp()
}
program.parse(process.argv)
5. 我的脚手架实现思考
在实现脚手架的过程中,我个人觉得基本上可以分为两种方式:
- 先下载一个通用的公共模板,在公共模板的基础上,将结合业务的配置以及新增文件添加到公共模板当中,我有大致看过vue-cli的代码结构,很复杂,但是基本的结构也是这种模式,通过在一个基础模板上增加各种配置项来实现定制化的项目结构。
- 第二种方式其实是我自己想到的一种方式,将需要的项目模板整体添加在脚手架工具中(删除不必要的文件项,比如node-modules),通过node的文件系统来实现将项目模板整体复制到本地目标的路径当中。相比于第一种来说,实现成本比较低,我认为比较适合需要快速成型一个直接可用的内部项目结构。
// 第二种实现方式的核心代码逻辑
// ./bin/index.js
// 实现将指定项目模板文件夹复制到目标路径当中
// src:需要复制的项目模板根路径(node中通常使用绝对路径更保险一点),dist:本地新建文件夹的根路径,callback:产生错误时的回调
function copyDir(src, dist, callback) {
// 获取指定目录的权限,不指定第二个参数则为默认的F_OK,判断新建文件夹是否存在,不对rwx(读写)权限做任何说明
fs.access(dist, function(err){
if(err){
// 目录不存在时创建目录
fs.mkdirSync(dist);
}
_copy(null, src, dist);
});
function _copy(err, src, dist) {
if(err){
callback(err);
} else {
try {
// 获取当前项目模板目录下一层的所有子目录和子文件数组
const paths = fs.readdirSync(src)
paths.forEach(function(_path) {
// 实现路径拼接
const _src = path.join(src, _path);
const _dist = path.join(dist, _path);
// 获取当前目标文件或文件夹信息
fs.stat(_src, function(err, stat) {
if(err){
callback(err);
} else {
// 判断是文件还是目录
if(stat.isFile()) {
// 实现文件读写,将项目模板中的对应文件读出后,写入对应路径下对应的文件中,实现对应文件创建和内容复制
fs.writeFileSync(_dist, fs.readFileSync(_src));
} else if(stat.isDirectory()) {
// 当是目录是,递归复制
copyDir(_src, _dist, callback)
}
}
})
})
} catch (err) {
callback(err)
}
}
}
}
// 1. 获取当前命令所在目录,即本地打开控制台时的目标路径
const dirPath = process.cwd();
// 2. 扫描当前目录, 判断是否有同名文件夹存在
// glob.sync("*")会获取当前命令所在路径下的子目录数组,也可以用fs.readdir(dirPath)替代
const fileList = glob.sync('*')
// 这里的answer.name就是我们在执行脚手架选项时输入的内容
if (fileList.some((element) => element === answers.name)) {
console.log(logSymbols.error, `当前目录下存在同名文件夹\n`)
console.log(logSymbols.error, `创建失败\n`)
} else {
// 3. 如果没有,创建文件夹
try {
fs.mkdirSync(`${dirPath}${answers.name}`)
console.log(logSymbols.success, `目录创建成功\n`)
// 开始下载模板
const oraIcon = ora().start(`正在下载模板\n`);
// 4. 创建好文件夹后,复制本地模板到创建的目标文件夹
// __dirname:返回运行文件所在的目录 E:\dbt\cli-tool\bin,所以为了方便也可以直接把入口文件放在根路径,这样就少了一层/bin
const dirName = __dirname.split('')
// 目的是为了删除目录中的\bin部分
dirName.splice(-4, 4)
const resultDirName = dirName.join('')
// 5. 复制目标文件夹内容
copyDir(path.join(resultDirName, 'packages'), path.join(dirPath, answers.name), (err) => {
if (err) {
oraIcon.fail(`模板下载失败\n`);
}
})
setTimeout(() => {
oraIcon.succeed("模板下载成功");
console.log(chalk.green(`
======================================================\n
进入项目根目录: cd ./${answers.name}\n
安装项目依赖: yarn install\n
启动项目: yarn run serve 或 npm run serve\n
======================================================
`))}, 2000)
} catch (err) {
console.log(logSymbols.error, chalk.green("目录创建失败"))
}
}
最后: 其实我的这个方法比较简单粗暴,在没有那么复杂的需求前提下,实现成本更低一些,但是势必性能以及脚手架体积会随着项目模板不断地添加变得更大,而且没有办法优化,这一点比较致命,因为实现的本质就是文件夹的整体复制,所以比较适合小型团体,如果是对于十几种项目类型甚至更多的话,就不太适合了,还是得老老实实的按照第一种通用的方式来进行定制化。