前言:最近接手了一个项目,工期赶、需求多。但是其实很多需求,页面的逻辑结构都是非常类似的。只需cv一下,改动一下即可,但是不想做cv战士的我就想,为什么不写一个简易的脚手架工具呢?这样以后再碰到类似的需求时,只需在控制台敲几行命令,便能快速生成对应的页面。这样余下的时间,用来学习(摸鱼),岂不美哉?
于是,我就撸起袖子开干了,这里放上最终的npm包地址 yyds-cli(传到npm上的好处是不仅可以随时随地下载下来使用,而且还方便推给同事小姐姐乛◡乛)。
最终实现的脚手架功能有如下几个:
- 查看版本信息
- 查看升级信息
- 快速生成模板页面
- 模板路径配置
- 查看配置
下面就跟着我,一起来看看怎么实现这个脚手架工具吧。
准备工作
首先进行准备工作,新建目录,然后初始化项目。
mkdir yyds-cli && cd yyds-cli && npm init -y
复制代码
然后下载需要使用的npm包
- fs-extra 增强版的node
fs
模块 - chalk 打印日志着色器
- commander 核心包,node命令行注册工具
- figlet 控制台界面提示语
- log-symbols 日志符号
- update-notifier 提示升级工具
- minimist 解析参数选项
- inquirer 命令行交互工具
- ejs 渲染模板引擎
npm i --save fs-extra chalk commander figlet log-symbols@4.1.0 update-notifier minimist inquirer ejs
复制代码
注意:因为最后包会发布到npm环境上,所以我们要把这些依赖安装在生产环境下。还有
log-symbols
我们需要带上版本号,因为最新版本的包不能在node环境下运行。
准备工作都做完以后,我们开始写第一个命令。
注册第一个命令 yyds -v
首先在package.json里面添加新的字段bin
。
"bin": {
"yyds": "./bin/index.js"
},
复制代码
接着执行npm link
命令,将工作目录链接到全局,这样不管在哪个目录下,你都可以直接运行yyds
命令了。
然后在当前目录新建一个bin
目录,在里面新建一个index.js
文件,里面存放所有我们要注册的指令。查看版本信息的代码如下:
#! /usr/bin/env node
const program = require('commander')
program
.version(require('../package.json').version, '-v, --version') // 读取package.json里面的version
.usage('<command> [options]')
program.parse(process.argv) // 解析控制台敲入的命令
复制代码
注意第一行
#! /usr/bin/env node
是必须的,否则node解析不到对应的指令
这样,我们就完成了第一个命令,大家可以在控制台,敲入yyds -v
查看一下效果。发现跳出如下的信息,很鸡冻有木有?
好了,平复一下心情,让我们接着实现第二个小功能:提示升级。
提示升级
用过其他脚手架或者安装一些依赖的时候,都遇到过提示我们去升级版本的提示信息,那么他们是如何实现的呢?其实也不难,借助update-notifier
这个包,我们也能轻松实现提示升级的功能,下面就来来看看具体实现吧。
为了方便维护,我们在工作目录下,再新建一个lib
文件夹,并在此目录下新建update.js
文件,编写提示升级的相关代码。
const updateNotifier = require('update-notifier');
const chalk = require('chalk')
const pkg = require('../package.json');
const notifier = updateNotifier({
pkg,
updateCheckInterval: 1000 // 检查更新的频率,官方默认是一天,这里设置成一秒
});
async function update() {
if (notifier.update) {
console.log(`Update available: ${chalk.cyan(notifier.update.latest)}`);
notifier.notify()
} else {
console.log('No new version available')
}
}
module.exports = update
复制代码
写完update
的逻辑以后,别忘了去bin/index.js
里面去注册一下。
program
.command('upgrade')
.description('Check the yyds-cli version')
.action(() => {
require(`../lib/update`)()
})
复制代码
为了测试,我们临时去package.json
文件,将name
改成vue-cli
,然后我们去控制台敲下yyds upgrade
,可以看到,他会提示我们去升级新的版本。
将name
改回原来的以后,再敲击yyds upgrade
,可以看到控制台打印出来No new version available
。
好了,上面两个指令算是开胃小菜,下面我们要完成主要功能,也就是我想做的快速生成页面。
快速生成页面
实现目标:
yyds g <路径>
可以快速生成页面- 如果指定路径下有重名的文件,需要提示是否覆盖,支持用户手动选择覆盖
or
不覆盖 - 带上
--force
参数可以强行覆盖文件 - 通过
-t
参数可以指定模板(为了支持多个模板) - 传参校验
“宏图”已经给出了,下面看看具体实现吧
目标一:快速生成页面
在此之前,分别新建两个文件夹src
和template
,src
用来存放生成文件的目录,template
目录用来存放好对应的页面模板文件:table.vue.ejs
、table.service.js.ejs
、report.vue.ejs
、report.model.js.ejs
(相关的模板代码放在我的github仓库中)。接着开始编写相关的代码,如下所示:
const chalk = require('chalk')
const fs = require('fs-extra')
const ejs = require('ejs')
const symbols = require('log-symbols')
const path = require('path')
// 支持的模板信息
const map = {
table: {
'vue': {
temp: 'table.vue.ejs', // 模板
ext: 'vue', // 后缀
},
'js': {
temp: 'table.service.js.ejs',
ext: 'service.js',
}
},
report: {
'vue': {
temp: 'report.vue.ejs',
ext: 'vue',
},
'js': {
temp: 'report.model.js.ejs',
ext: 'model.js',
}
}
}
/**
* @params {*} path 路径
* @params {*} options 参数
*/
async function generate(url, options) {
let template, tempName
const { temp = 'table' } = options // 获取要生成的模板,因为现在还没有支持模板选择的功能,所以先给一个默认的值
if (temp in map) { // 判断是否有对应模板
tempName = temp
template = map[temp]
try {
for (const [key, value] of Object.entries(template)) {
const {temp, ext} = value
const baseUrl = 'src'
const filepath = `${baseUrl}/${url}`
const fileName = url.split('/').map(str => str.slice(0, 1).toUpperCase() + str.slice(1).toLowerCase()).join('') // 填入ejs模板中的name信息
// 编译模板生成文件
await compile(filepath, temp, {
name: fileName,
ext,
...options,
})
}
console.log(symbols.success, chalk.cyan('? generate success'))
} catch (err) {
console.log(symbols.error, chalk.red(`Generate failed. ${err}`))
}
} else {
console.log(symbols.error, chalk.red(`Sorry, don't support this template`)) // 没有则进行提示
}
}
/**
* 编译模板生成结果
* @params {*} filepath 生成的文件路径
* @params {*} templatepath 模板路径
* @params {*} options 配置
*/
const compile = async (filepath, templatepath, options) => {
const cwd = process.cwd(); // 当前的工作目录
const targetDir = path.resolve(cwd,`${filepath}.${options.ext}`); // 生成的文件
const tempDir = path.resolve(__dirname, `../template/${templatepath}`); // 模板存放路径
if (fs.existsSync(tempDir)) { // 判断该模板路径是否存在
const content = fs.readFileSync(tempDir).toString()
const result = ejs.compile(content)(options)
fs.writeFileSync(`${targetDir}`, result)
} else {
throw Error("Don't find target template in directory")
}
}
module.exports = generate
复制代码
这里将
.vue
文件和.js
文件分开维护的原因是,我的项目.vue
文件和.js
文件在不同的目录下,将他们分开维护的话,更方便管理。
同样地要去bin/index.js
中去注册指令
// generate page
program
.command('g <name>')
.description('Generate template')
.action((name, options) => {
require('../lib/generate')(name, options)
})
复制代码
接下来,我们在命令行输入yyds g hello
回车,可以看到在src
文件夹下成功生成了两份文件hello.service.js
和hello.vue
,同时控制台给出了生成成功的提示。
虽然功能是完成了,但其实还有很多问题存在。比如:现在只能输入一个具体的文件名,不能带上类似hello/hello
的路径。另外假如对应路径下存在同名的文件,脚手架并没有给出提示,直接进行覆盖了,这肯定不符合我们的要求。
因此,接下来我们需要对yyds g
指令进行优化。
支持输入路径
我们需要编写一个函数,替换原有的fs.writeFileSync
。
/**
*
* @param {*} paths 路径
* @param {*} data 写入的数据
* @param {*} ext 文件后缀
*/
function writeFileEnsure(paths, data, ext) {
const cwd = process.cwd();
const pathArr = paths.split('/')
pathArr.reduce((prev, cur, index) => {
const baseUrl = path.resolve(cwd, `${prev}/${cur}`)
if (index === pathArr.length - 1) {
fs.writeFileSync(path.resolve(cwd, `${baseUrl}.${ext}`), data)
} else {
if (!fs.existsSync(baseUrl)) {
fs.mkdirSync(path.resolve(cwd, baseUrl))
}
}
return prev + '/' + cur
}, '.')
}
复制代码
在compile
函数中将原有的fs.writeFileSync
替换成新写的函数:
// ...
// fs.writeFileSync(`${targetDir}`, result)
await writeFileEnsure(filepath, result, options.ext)
// ...
复制代码
改写完以后,我们尝试是否支持yyds g hello/hello
的形式,可以看到完美实现了功能?!
再接再厉,我们继续解决文件覆盖的问题?。这里为了更好的用户体验,我们需要借助inquirer
,帮助我们在命令行通过方向键、回车等的交互形式,选择对应的命令。这也是我们需要完成的目标二。
目标二:文件覆盖交互功能
首先在generate.js
中引入inquirer
const inquirer = require('inquirer')
复制代码
然后改写compile
函数,在写入文件之前,先判断当前目录下有没有重名文件存在。
// ...
if (fs.existsSync(tempDir)) { // 判断该模板路径是否存在
if (fs.existsSync(targetDir)) {
const { action } = await inquirer.prompt([
{
name: 'action',
type: 'list',
message: `Target directory ${chalk.cyan(targetDir)} already exists. Pick an action:`,
choices: [
{ name: 'Overwrite', value: 'overwrite' },
{ name: 'Cancel', value: false }
]
}
])
if (!action) {
throw Error('Cancel overite')
} else if (action === 'overwrite') {
console.log(`?Removing ${chalk.cyan(targetDir)}`)
await fs.remove(targetDir)
}
}
// ...
}
// ...
复制代码
现在,我们尝试继续在控制台敲下yyds g hello
命令,就会发现出现如下界面
选择Overwrite
就会对原文件进行覆盖,选择cancel
则提示✖ Generate failed. Error: Cancel overwrite
。
当然也有暴躁的小伙伴,觉得每次都要进行选择,很麻烦。因为某些情况下,用户完全知道自己在干吗!那有没有办法,不提示直接进行覆盖呢?有!而且也很简单。只需在注册指令的时候加一行配置,并通过该配置传入的参数在compile
函数中,再加一层判断即可。
目标三:强行覆写文件
添加-f --force
参数
program
.command('g <name>')
.description('Generate template')
.option('-f --force', 'Overwrite target directory if it exists')
.action((name, options) => {
require('../lib/generate')(name, options)
})
复制代码
修改compile
函数
// ...
if (fs.existsSync(targetDir)) {
+ if (!options.force) { // 只有force为false的情况下,才会走入交互提示的逻辑
const { action } = await inquirer.prompt([
// ...
])
// ...
+ }
}
// ...
复制代码
好了,现在只需输入yyds g hello -f
或者yyds g hello --force
就能直接覆盖重名文件。
到这里,我们已经完成了三个目标了,但是细心的小伙伴可能已经发现了,generate.js
的map
对象中还支持另一种模板report
页面的快速生成,但是现在我们的脚手架明显还不支持生成对应的模板页面。莫慌,让我们先喝口茶缓一缓╭(°A° `)~~
好了,现在让我们来一起来完成目标四吧
目标四:切换模板
聪明的小伙伴,肯定也想到了,我们需要在注册的地方再添加一个参数,让我们能够在控制台输入对应的模板名字。
// generate page
program
.command('g <name>')
.description('Generate template')
.option('-t, --temp <name>', 'Auto generate <name> page. Currently support template [table, report]', 'table') // 最后的'table' 代表如果没有传 -t 参数,则使用默认模板 table
.option('-f --force', 'Overwrite target directory if it exists')
.action((name, options) => {
require('../lib/generate')(name, options)
})
复制代码
而在generate.js
函数中,也早有对应的逻辑支持,通过options
我们便能取到对应的模板名字。
// ...
const { temp } = options // 通过该行获取对应的模板信息
if (temp in map) {
tempName = temp
template = map[temp]
// ....
复制代码
最后让我们看看效果,因为最终效果不方便展示,所以就看一看输入yyds g report -t report
,控制台传入的options
打印出来的日志信息是啥吧。
可以看到temp
对应的是report
,所以之后也会拿到report
对应的ejs
模板进行渲染。之后如果我们想要继续添加其他模板,只要维护map
这个对象,以及对应的ejs
模板即可。
目标五:传参校验
现在我们只剩下最后一个目标五需要实现,其实这也很简单,只需要在注册指令的地方加一个判断即可,这里直接贴上代码:
// 需要引入这两个包
const minimist = require('minimist')
const chalk = require('chalk')
// ...
program
.command('g <name>')
.description('Generate template')
.option('-t, --temp <name>', 'Auto generate <name> page. Currently support template [table, report]', 'table') // 最后的table 代表如果没有传 -t 参数,则使用默认模板 table
.option('-f --force', 'Overwrite target directory if it exists')
.action((name, options) => {
if (minimist(process.argv.slice(3))._.length > 1) {
console.log(chalk.yellow('\n Info: You provided more than one argument. The first one will be used as the app\'s name, the rest are ignored.'))
}
require('../lib/generate')(name, options)
})
// ...
复制代码
现在当用户不按规定输入传参时,比如:yyds g hello -t report xxxx
, 将会给出警告Info: You provided more than one argument. The first one will be used as the template's name, the rest are ignored.
配置和查看信息
其实到这里,这个脚手架基本已经能达成我的要求了,但是其实还有个小问题可以优化,因为我需要生成页面的路径是这样的src/xxx/xxx/xxx/xxx/xxx/xxx
,那么每次生成页面,岂不是每次都要带上那一长串恶心?的路径,想到这里,我是崩溃的。
不过我想到了可以将页面的默认路径做成配置的形式,这样在控制台只需输入最终的路径,然后由配置项中的路径
和输入的路径
拼接在一起,就是我们最后需要生成页面的最终路径。并且默认路径可以支持在控制台,通过指令的形式去修改。下面来看看具体实现吧。
首先在lib
下新建config.js
const fse = require('fs-extra');
const path = require('path');
const symbols = require('log-symbols')
const chalk = require('chalk')
const config = {
'tablevue': 'src/renderer/page/manage/desk',
'tablejs': 'src/renderer/page/manage/desk',
'reportvue': 'src/renderer/page/manage/report',
'reportjs': 'src/renderer/models/report',
}
const configPath = path.resolve(__dirname,'../config.json')
// 输出json文件
async function defConfig() {
try {
await fse.outputJson(configPath, config)
} catch (err) {
console.error(err)
process.exit()
}
}
// 获取配置信息
async function getJson () {
try {
const config = await fse.readJson(configPath)
console.log(chalk.cyan('current config:'), config)
} catch (err) {
console.log(chalk.red(err))
}
}
// 设置json
async function setUrl(name, link) {
const exists = await fse.pathExists(configPath)
if (exists){
mirrorAction(name, link)
}else{
await defConfig()
mirrorAction(name, link)
}
}
async function mirrorAction(name, link){
try {
const config = await fse.readJson(configPath)
config[name] = link
await fse.writeJson(configPath, config)
await getJson()
console.log(symbols.success, '? Set the url successful.')
} catch (err) {
console.log(symbols.error, chalk.red(`? Set the url failed. ${err}`))
process.exit()
}
}
module.exports = { setUrl, getJson, config}
复制代码
然后改写一下generate
函数,baseUrl
不写死为src
而是读取json
文件里面的默认路径。
// ...
const { config } = require('./config')
const configPath = path.resolve(__dirname,'../config.json')
// ...
async function generate(url, options) {
let template, tempName
const { temp = 'table' } = options // 获取要生成的模板
if (temp in map) { // 判断是否有对应模板
tempName = temp
template = map[temp]
+ let jsonConfig
+ await fs.readJson(configPath).then((data) => {
+ jsonConfig = data
+ }).catch(() => {
+ fs.writeJson(configPath, config)
+ jsonConfig = config
+ })
try {
for (const [key, value] of Object.entries(template)) {
const {temp, ext} = value
// const baseUrl = 'src'
+ const baseUrl = jsonConfig[`${tempName}${key}`]
const filepath = `${baseUrl}/${url}`
const fileName = url.split('/').map(str => str.slice(0, 1).toUpperCase() + str.slice(1).toLowerCase()).join('')
// 编译模板生成文件
await compile(filepath, temp, {
name: fileName,
ext,
...options,
})
}
console.log(symbols.success, chalk.cyan('? generate success'))
} catch (err) {
console.log(symbols.error, chalk.red(`Generate failed. ${err}`))
}
} else {
console.log(symbols.error, chalk.red(`Sorry, don't support this template`)) // 没有则进行提示
}
}
// ...
复制代码
最后去注册指令yyds m <template> <url>
和yyds config
program
.command('m <template> <url>')
.description("Set the template url.")
.action((template, url) => {
require('../lib/config').setUrl(template, url)
})
program
.command('config')
.description("see the current config.")
.action((template, mirror) => {
require('../lib/config').getJson(template, mirror)
})
复制代码
完成以上步骤以后,我们来看看效果。
首先重新执行yyds g hello
指令,看看路径有没有变化。可以看到,生成的页面将会出现在我们配置的src/renderer/page/manage/desk
目录下,同时会生成一份config.json
文件,里面保存的是我们的默认路径配置。
接着我们执行yyds config
命令,试试能否查看配置信息。可以看到成功地输出了结果,如下所示:
然后我们尝试一下修改配置,修改table
模板生成的vue
文件的目录,yyds m tablevue src/desk
最后验证一下结果,重新执行yyds g hello
,查看生成的路径。
完美!给自己鼓鼓掌。
优化
到这里整个脚手架功能已经完成的差不多了。不过我们还是可以进行一些小小的优化。
帮助信息
program.on('--help', () => {
console.log()
console.log(` Run ${chalk.cyan(`yyds <command> --help`)} for detailed usage of given command.`)
console.log()
})
program.commands.forEach(c => c.on('--help', () => console.log()))
复制代码
当输入yyds -h
以后,最底下会有Run hzzt <command> --help for detailed usage of given command.
提示用户获取如何更详细的指令信息。
来点仪式感
借助figlet
这个包,我们可以搞点花里胡哨的提示。
在generate.js
函数成功生成页面以后,我们展示一下大大的提示语yyds
。
// ...
const { promisify } = require('util')
const figlet = promisify(require('figlet'))
// ...
// ...
console.log(symbols.success, chalk.cyan('? generate success'))
const info = await figlet('yyds', {
font: 'Ghost',
width: 100,
whitespaceBreak: true
})
console.log(chalk.green(info), '\n')
// ...
复制代码
发布到npm官网
到此,这个脚手架功能已经写完了。不过我们还差最后一步:将写好的脚手架发布到npm官网,具体步骤如下:
- 新建
.npmignore
文件
config.josn
node_modules/
src/
复制代码
排除要发布到npm仓库的文件
- 注册登录npm账号
如果是首次发包则执行npm adduser
,输入用户名、密码以及邮箱,完成注册。完成这一步后,别忘了去邮箱验证账号,否则后面发包会报错。
非首次发包则执行npm login
输入用户名、密码以及邮箱,登录账号。
- 借还本地对应的npm镜像为
npm
如果是淘宝的镜像,需要切换成npm。怎么快速查看和切换,这里推荐一个工具nrm。
nrm current // 查看当前镜像源
nrm ls // 可用的镜像
nrm use npm // 切换镜像
复制代码
- 发布
执行npm publish --access public
因为默认发包是私包,所以需要带上--access public
否则会报错。
- 解绑
执行npm unlink
进行解绑,否则下载下来的npm包,无法进行验证,因为关联的是本地的yyds
目录。
如果解绑不成功则执行
npm unlink -g yyds-cli
,我就是npm unlink
解绑不了,最后执行前面的命令,才解绑成功。刚开始是npm unlink
直接报错提示要带上packagename
,但是我带上以后还是无法解绑。后来猜测是切换了Node版本的原因,于是尝试了切换node版本,虽然能直接执行npm unlink
了,但是还是无法解绑成功。最后我带上-g
参数运行npm unlink -g yyds-cli
才解绑成功。原因网上没查到,望知道的大神告知。
- 验证
发包成功以后,全局安装脚手架,然后去工作目录下测试效果。
npm i -g yyds-cli && npm g hello
复制代码
看到页面在项目下成功生成,心里不禁轻蔑一笑(哼,还想阻止我到点下班)。
说点啥
写这个工具一方面确实是为了偷懒,另一方面也是兴趣使然,就和本人是一名兴趣使然的前端程序猿一样。望各位看官看了能留下宝贵的意见,不吝赐教(菜鸟瑟瑟发抖)。
最后本文的源码也放在了我的小破github上,喜欢的也希望给个star
。
巨人的肩膀: