前言
上篇文章只是简单的说了下前端脚手架的代码,和实现思路,最近又增加了部分的功能,这次就把上篇文章加上这次的进行一个统一的描述,最近看其他人的掘金文章,发现只有大白话才是最好的,能让人迅速理解的的语言,那么这次,就用大白话来进行写一个前端脚手架。
开始
什么是前端脚手架
1.是什么
- 脚手架是为了保证各施工过程顺利进行而搭设的工作平台。(百度对脚手架的定义)
- 能够快速帮我生成新项目的目录模板
- 能够提升我的开发效率和开发的舒适性
2.有什么好处
- 是统一各业务线的技术栈、制定规范,这样出现问题可以统一解决,升级和迭代都可以同步的进行
- 提高效率,在统一的基础上,提供更多提高效率的工具,这个效率不只是开发效率,是从开发到上线的全流程的效率,比如一些业务组件的封装,提高上线效率的发布系统,各种utils等
开始撸代码
本文代码在https://github.com/amazingliyuzhao/cli-demo 的test分支
1.项目目录
先看macaw-hellow.js
console.log('hello, cli')
然后我去命令行里面执行一下
node ./bin/macaw-hellow.js
可以看到打印语句
到现在我们已经写出执行命令了。然后我们来增加一点功能,首先我们看到其他的脚手架比如vue-cli 创建文件的时候都是,vue create projectName,基本都是自定义命令然后+项目名
但是我们发现他们前面并没有+node命令,但是仍然会执行语句。这就是因为使用tj大神写的commander.js工具来实现的 关于commander.js的文档可以直接看github的官方文档,而且带有中文版本,这里用到哪一个我就说哪一个,想要深入了解的可以自己看一下。
2. commander.js
获取项目名称
第一步,引入
const program = require('commander')
改造一下我们的hellow文件
program.usage('<project-name>')
.parse(process.argv) // 加入这个能获取到项目名称
// 根据输入,获取项目名称
let projectName = program.rawArgs[2] // 获取项目名称
console.log(projectName)
执行hellow文件
node ./bin/macaw-hello.js testDemo
可以看到打印结果
可以看到,现在已经拿到了我们自定义的项目名字,接下来我们就可以继续搞事情了拿到名字下一步干什么,当然是要根据名字来创建我们的项目。
项目名称容错
但是先考虑下容错,如果没有名字怎么办
help() 相当于执行命令的--help选项,显示help信息,这是commander内置的一个命令选项
if (!projectName) { // project-name 必填 如果没有输入名称执行helphelp
program.help()// 相当于执行命令的--help选项,显示help信息,这是commander内置的一个命令选项
return
}
创建文件夹
创建文件夹的逻辑有很多,我们可以先理顺了再写
创建文件的逻辑
- 如果创建项目名和根目录一致
直接在根目录下生成文件
- 如果创建项目名和根目录不一致
直接在根目录下创建项目目录并生成件
- 如果根目录下已经存在和项目名相同的文件
询问用户是否覆盖
看下处理代码
#!/usr/bin/env node
const program = require('commander')
const path = require('path')
const fs = require('fs')
const glob = require('glob') // npm i glob -D
const download = require('../lib/download') //下载配置
const inquirer = require('inquirer') // 按需引入
const logSymbols = require("log-symbols");
const chalk = require('chalk')
const remove = require('../lib/remove') // 删除文件js
const generator = require('../lib/generator')// 模版插入
const CFonts = require('cfonts');
program.usage('<project-name>')
.parse(process.argv) // 加入这个能获取到项目名称
// 根据输入,获取项目名称
// console.log(program)
let projectName = program.rawArgs[2] // 获取项目名称
if (!projectName) { // project-name 必填 如果没有输入名称执行helphelp
program.help()// 相当于执行命令的--help选项,显示help信息,这是commander内置的一个命令选项
return
}
// 当前目录为空,如果当前目录的名称和project-name一样,则直接在当前目录下创建工程,否则,在当前目录下创建以project-name作为名称的目录作为工程的根目录
// 当前目录不为空,如果目录中不存在与project-name同名的目录,则创建以project-name作为名称的目录作为工程的根目录,否则提示项目已经存在,结束命令执行。
// process.cwd() 是当前执行node命令时候的文件夹地址
//__dirname 是被执行的js 文件的地址 ——文件所在目录
const list = glob.sync('*') // 遍历当前目录,数组类型
let next = undefined;
let rootName = path.basename(process.cwd());
if (list.length) { // 如果当前目录不为空
if (list.some(n => {
const fileName = path.resolve(process.cwd(), n);
const isDir = fs.statSync(fileName).isDirectory();
return projectName === n && isDir // 找到创建文件名和当前目录文件存在一致的文件
})) { // 如果文件已经存在
next = inquirer.prompt([
{
name:"isRemovePro",
message:`项目${projectName}已经存在,是否覆盖文件`,
type: 'confirm',
default: true
}
]).then(answer=>{
if(answer.isRemovePro){
remove(path.resolve(process.cwd(), projectName))
rootName = projectName;
return Promise.resolve(projectName);
}else{
console.log("停止创建")
next = undefined
// return;
}
})
}
} else if (rootName === projectName) { // 如果文件名和根目录文件名一致
rootName = '.';
next = inquirer.prompt([
{
name: 'buildInCurrent',
message: '当前目录为空,且目录名称和项目名称相同,是否直接在当前目录下创建新项目?',
type: 'confirm',
default: true
}
]).then(answer => {
console.log(answer.buildInCurrent)
return Promise.resolve(answer.buildInCurrent ? '.' : projectName)
})
} else {
rootName = projectName;
next = Promise.resolve(projectName) // 返回resole函数,并传递projectName
}
next && go()
function go () {
// 预留,处理子命令
}
上面我们提到了询问用户,那我们怎么在命令行和用户进行交互呢
3. inquirer.js —— 一个用户与命令行交互的工具
通过介绍我们就能知道这个函数库的作用,就是能在命令行和用户进行一个交互,先看下场景
- type:表示提问的类型,包括:input, confirm, list, rawlist, expand, checkbox, password, editor;
- message:问题的描述;
- default:默认值;
- choices:列表选项,在某些type下可用,并且包含一个分隔符(separator);
- validate:对用户的回答进行校验;
- filter:对用户的回答进行过滤处理,返回处理后的值;
- transformer:对用户回答的显示效果进行处理(如:修改回答的字体或背景颜色),但不会影响最终的答案的内容;
- when:根据前面问题的回答,判断当前问题是否需要被回答;
- pageSize:修改某些type类型下的渲染行数;
- prefix:修改message默认前缀;
- suffix:修改message默认后缀。
大体使用框架为
const inquirer = require('inquirer');//引入
const promptList = [];//用户的交互
inquirer.prompt(promptList).then(answers => {
console.log("最后输出")
console.log(answers); // 返回的结果
})
我们一条一条来看
- 首先是type中的input
const promptList = [{
type: 'input',
message: '设置一个用户名:',
name: 'name',
default: "test_user" // 默认值
},{
type: 'input',
message: '请输入手机号:',
name: 'phone',
validate: function(val) {
if(!val.match(/\d{11}/g)) { // 校验位数
return "请输入11位数字";
}
return true
}
}];
效果为
- type中-->confirm
const promptList = [{
type: "confirm",
message: "第一个条件是否成立",
name: "firstChange",
prefix: "第一个条件的前缀"
},{
type: "confirm",
message: "第二个条件是否成立(依托于第一个条件)",
name: "secondChange",
suffix: "第二个条件的后缀",
when: function(answers) { // 当firstChange为true的时候才会提问当前问题
return answers.firstChange
}
}];
效果为
3. type-->listconst promptList = [{
type: 'list',
message: '请选择一种水果:',
name: 'fruit',
choices: [
"🍎 Apple",
"🍐 Pear",
"🍌 Banana"
],
filter: function (val) { // 使用filter将回答变为小写
return val.toLowerCase();
}
}];
效果为
- type-->list
const promptList = [{
type: "expand",
message: "请选择一种水果:",
name: "fruit",
choices: [
{
key: "a",
name: "Apple",
value: "apple"
},
{
key: "O",
name: "Orange",
value: "orange"
},
{
key: "p",
name: "Pear",
value: "pear"
}
]
}];
这个要解释一下,key就是提示,而且key只能为一个简单字母,H默认为help选项,选择H会展示所有的选择项
效果为
5. type中-->checkboxconst promptList = [{
type: "checkbox",
message: "类型:",
name: "color",
choices: [
new inquirer.Separator("--- 品牌 ---"), // 添加分隔符
{
name: "奥迪",
},
{
name: "奔驰",
},
{
name: "红旗",
},
new inquirer.Separator("--- 颜色 ---"), // 添加分隔符
{
name: "blur",
checked: true // 默认选中
},
{
name: "red",
},
{
name: "green"
}
]
}];
- type中-->password 密文输入
const promptList = [{
type: "password", // 密码为密文输入
message: "请输入密码:",
name: "pwd"
}];
- type中-->editor 密文输入
const promptList = [{
type: "editor",
message: "请输入备注:",
name: "editor"
}];
回车开始写备注,esc+:wq 保存并退出编辑
至此inquire常用的几个类型我们就说完了,继续看我们的项目吧
回到之前流程,我们已经走到了创建文件 我们在处理完创建文件的逻辑之后执行next和go函数,
next = Promise.resolve(projectName) // 返回resole函数,并传递projectName
在go函数里面创建我们的目录
function go () {
next.then(projectRoot => { //
if (projectRoot !== '.') {
fs.mkdirSync(projectRoot)//创建目录文件
}
}
}
生成文件
我们先看一个细节,我执行命令是amaz-test + 项目名,这里的amaz就是我的自定义命令,看下我们的 package.json文件的配置
{
"name": "liyuzhaocli-2",
"version": "1.0.0",
"description": "amz脚手架1.0",
"bin": {
"macaw": "./bin/macaw.js",
"amaz": "./bin/macaw-init.js",
"amaz-test": "./bin/macaw-test.js"
},
"main": "./bin/macaw-hellow.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"cli"
],
"author": "amaizngli",
"license": "ISC",
"dependencies": {
"cfonts": "^2.4.5",
"commander": "^4.0.0",
"download-git-repo": "^3.0.2",
"glob": "^7.1.5",
"handlebars": "^4.5.1",
"inquirer": "^7.0.0",
"metalsmith": "^2.3.0",
"ora": "^4.0.2"
}
}
可以看到bin下面就是我们的自定义命令了
如果想模拟发布npm后的效果,可以在本地直接npm link 链接到全局这样就可以在我们的电脑上使用我们的命令了。
但是现在我们只是创建了一个空的目录,还需要往里面填充我们的模版,那么就需要我们的另一个工具了
4. download-git-repo —— 一个下载远程git文件的工具
首先我们在lib目录下新建一个download.js的文件
先放官网地址 www.npmjs.com/package/dow…先看下代码
const download = require('download-git-repo')
const path = require("path")
const ora = require('ora')
module.exports = function (target) {
target = path.join(target || '.', '.download-temp');
return new Promise(function (res, rej) {
// 这里可以根据具体的模板地址设置下载的url,注意,如果是git,url后面的branch不能忽略
// 格式是名字/地址 后面不加 .git 但是带着 #分支
// let url = 'amazingliyuzhao/CliTestGit#test'
let url = 'amazingliyuzhao/cli-template#test'
const spinner = ora(`正在下载项目模板,源地址:${url}`)
spinner.start();
download(url, target, { clone: false }, function (err) { // clone false 设置成false 具体设置看官网设置
if (err) {
spinner.fail()
rej(err)
}
else {
// 下载的模板存放在一个临时路径中,下载完成后,可以向下通知这个临时路径,以便后续处理
spinner.succeed()
res(target)
}
})
})
}
其中的ora是一个代码美化库
注意一个大坑,就是url地址的格式,我们正常的git地址是 github.com/amaz/cli-de…
当用这个工具的时候要使用amaz/cli-demo#test。#后面是对应的分支名字。没有默认,master也必须写
我们可以把提前写好的模版放到git上,然后创建好文件之后,直接从git拉取,当需要更新模版的时候,只需要更新远端的git仓库,每次拉取,都是拉取的最新的代码。
最后再在我们的macaw-init 文件中引入
const download = require('../lib/download') //下载配置
然后改写下go函数
function go () {
next.then(projectRoot => {
if (projectRoot !== '.') {
fs.mkdirSync(projectRoot) //创建文件
}
return download(projectRoot).then(target => {//下载模版
return {
projectRoot,
downloadTemp: target
}
})
})
}
下载的远端模版
好了问题又来了,我们下载完模版,所有信息都是在模版里面写死的,版本信息,项目名称,版本号,都需要我们自己去项目中修改,这看起来并不是我们预期的可定制化
所以我们的期望是通过用户输入自定义我们的信息,提到用户交互我们首先想到上面说的inquire.js工具,但是我们怎么把从用户那里获取到的内容填充到我们的模版呢?
这时候我们又要用到一个新的工具
5. metalsmith —— 一个下载远程git文件的工具
惯例先放官网 metalsmith.io/ 但是说实话,官网不太能看懂。
推荐一个网站 www.kutu66.com//GitHub/art…
在lib下面新建一个generator文件,
安装
npm i handlebars metalsmith -D
// // npm i handlebars metalsmith -D
const rm = require('rimraf').sync //以包的形式包装rm -rf命令,用来删除文件和文件夹的,不管文件夹是否为空,都可删除
const Metalsmith = require('metalsmith') // 插值
const Handlebars = require('handlebars') // 模版
const remove = require("../lib/remove") // 删除
const fs = require("fs")
const path = require("path")
module.exports = function (context) {
let metadata = context.metadata; // 用户自定义信息
let src = context.downloadTemp; // 暂时存放文件目录
let dest = './' + context.projectRoot; //项目的根目录
if (!src) {
return Promise.reject(new Error(`无效的source:${src}`))
}
return new Promise((resolve, reject) => {
const metalsmith = Metalsmith(process.cwd())
.metadata(metadata) // 将用户输入信息放入
.clean(false)
.source(src)
.destination(dest);
metalsmith.use((files, metalsmith, done) => {
const meta = metalsmith.metadata()
Object.keys(files).forEach(fileName => {
if(fileName.split(".").pop() != "png"){
const t = files[fileName].contents.toString()
files[fileName].contents = new Buffer.from(Handlebars.compile(t)(meta),'UTF-8')
}
})
done()
}).build(err => {
remove(src);
err ? reject(err) : resolve(context);
})
})
}
具体使用方法,可以看网站(太久没看,我也快忘了~,之后补充详细)
基本来说,这是一个实现插值的文件,用这个工具的同时还需要一个模版库,这里我选的是Handlebars,具体语法可以自己搜一下,和ejs以及其他的模版js文件一样,只是语法可能不太相同。
思想就是,遍历模版下的所有文件,然后把文件内容转换成字符串,找到模版字符串的内容就进行处理,但是这里也有一个坑,就是这个工具好像不识别图片,所以我在上面代码对图片进行了过滤处理。
然后在macaw-init.js里面引入一下我们的代码
const generator = require('../lib/generator')// 模版插入
改写下我们的go函数
function go () {
next.then(projectRoot => { //
if (projectRoot !== '.') {
fs.mkdirSync(projectRoot)
}
CFonts.say('amazing', {
font: 'block', // define the font face
align: 'left', // define text alignment
colors: ['#f80'], // define all colors
background: 'transparent', // define the background color, you can also use `backgroundColor` here as key
letterSpacing: 1, // define letter spacing
lineHeight: 1, // define the line height
space: true, // define if the output text should have empty lines on top and on the bottom
maxLength: '0', // define how many character can be on one line
});
return download(projectRoot).then(target => {
return {
projectRoot,
downloadTemp: target
}
})
})
.then(context => {
// console.log(context)
return inquirer.prompt([
{
name: 'projectName',
message: '项目的名称',
default: context.name
}, {
name: 'projectVersion',
message: '项目的版本号',
default: '1.0.0'
}, {
name: 'projectDescription',
message: '项目的简介',
default: `A project named ${context.projectRoot}`
},{
name: 'isElement',
message: '是否使用element',
default: "No",
},{
name: 'isEslint',
message: '是否使用isEslint',
default: "No",
}
]).then(answers => { // 可选选项回调函数
let v = answers.isElement.toUpperCase();
answers.isElement = v === "YES" || v === "Y";
let iseslint = answers.isEslint.toUpperCase();
answers.isEslint = iseslint === "YES" || iseslint === "Y";
return {
...context,
metadata: {
...answers
}
}
})
}).then(context => {
console.log("生成文件")
console.log(context)
//删除临时文件夹,将文件移动到目标目录下
return generator(context); // 插值处理
}).then(context => {
// 成功用绿色显示,给出积极的反馈
console.log(logSymbols.success, chalk.green('创建成功:)'))
console.log(chalk.green('cd ' + context.projectRoot + '\nnpm install\nnpm start'))
}).catch(err => {
console.error(err)
// 失败了用红色,增强提示
console.log(err);
console.error(logSymbols.error, chalk.red(`创建失败:${err.message}`))
})
}
可以看到我们用到inquire.js进行的用户交互,然后处理用户回答后,进行插值处理
对了上的彩色字体也是用了一个工具Cfonts还有各种花里胡哨的效果等你探索~
CFonts.say('amazing', {
font: 'block', // define the font face
align: 'left', // define text alignment
colors: ['#f80'], // define all colors
background: 'transparent', // define the background color, you can also use `backgroundColor` here as key
letterSpacing: 1, // define letter spacing
lineHeight: 1, // define the line height
space: true, // define if the output text should have empty lines on top and on the bottom
maxLength: '0', // define how many character can be on one line
});
现在我们已经从远端拉取了模版,并且实现了插值,那模版中我们怎么进行处理呢?
6. 在模版中进行插值
先看一个处理代码
{
"name": "{{projectName}}",
"version": "{{projectVersion}}",
"description": "{{projectDescription}}",
"author": "lyz",
"private": true,
"scripts": {
"dev": "webpack-dev-server --inline --progress --config build/webpack.dev.conf.js",
"start": "npm run dev",
"lint": "eslint --ext .js,.vue src",
"build": "node build/build.js"
},
"dependencies": {
{{#if isElement}}
"element-ui": "^2.13.0",
{{/if}}
"vue": "^2.5.2",
"vue-router": "^3.0.1",
"node-sass": "^4.13.0"
},
"devDependencies": {
"autoprefixer": "^7.1.2",
"babel-core": "^6.22.1",
"babel-eslint": "^8.2.1",
"babel-helper-vue-jsx-merge-props": "^2.0.3",
"babel-loader": "^7.1.1",
"babel-plugin-syntax-jsx": "^6.18.0",
"babel-plugin-transform-runtime": "^6.22.0",
"babel-plugin-transform-vue-jsx": "^3.5.0",
"babel-preset-env": "^1.3.2",
"babel-preset-stage-2": "^6.22.0",
"chalk": "^2.0.1",
"copy-webpack-plugin": "^4.0.1",
"css-loader": "^0.28.0",
"eslint": "^4.15.0",
"eslint-config-standard": "^10.2.1",
"eslint-friendly-formatter": "^3.0.0",
"eslint-loader": "^1.7.1",
"eslint-plugin-import": "^2.7.0",
"eslint-plugin-node": "^5.2.0",
"eslint-plugin-promise": "^3.4.0",
"eslint-plugin-standard": "^3.0.1",
"eslint-plugin-vue": "^4.0.0",
"extract-text-webpack-plugin": "^3.0.0",
"file-loader": "^1.1.4",
"friendly-errors-webpack-plugin": "^1.6.1",
"html-webpack-plugin": "^2.30.1",
"node-notifier": "^5.1.2",
"optimize-css-assets-webpack-plugin": "^3.2.0",
"ora": "^1.2.0",
"portfinder": "^1.0.13",
"postcss-import": "^11.0.0",
"postcss-loader": "^2.0.8",
"postcss-url": "^7.2.1",
"rimraf": "^2.6.0",
"sass-loader": "^7.3.1",
"semver": "^5.3.0",
"shelljs": "^0.7.6",
"uglifyjs-webpack-plugin": "^1.1.1",
"url-loader": "^0.5.8",
"vue-loader": "^13.3.0",
"vue-style-loader": "^3.0.1",
"vue-template-compiler": "^2.5.2",
"webpack": "^3.6.0",
"webpack-bundle-analyzer": "^2.9.0",
"webpack-dev-server": "^2.9.1",
"webpack-merge": "^4.1.0"
},
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
},
"browserslist": [
"> 1%",
"last 2 versions",
"not ie <= 8"
]
}
可以看到我们的模版语法{{}} 以及下面{{#if isElement}}{{/if}}都是handlebus语法,当metalsmith工具遍历文件碰到这些语法后会自动解析。并将用户的输入处理后插入到我们的模版中,这里我选择的是否配置element和是否配置eslint都是通过在packagejson里面选择是否加载对应的依赖包进行配置的
7. 更多的扩展
其实到目前,我们已经能写一出一个微定制化的模版了,但是我们的插值操作都是在代码里面进行的,但是如果设计到文件级别的增删我们要如何配置呢,目前的一个思路就是通过.gitignore文件,将不需要的文件添加或者从里面删除,仍然是使用我们的插值工具完成。这个模块之后再进行补充,
要是涉及到整个框架的改变,比如一个vue一个react,推荐在下载模版的时候加入用户询问,然后在我们的远端维护多个git仓库,根据用户的选择下载不同仓库的模版。
结语
这篇文章从写到写完,可能用了一周多,每次重新打开思路可能会有一些疏漏,阅读时候发现什么错误,希望在评论区指正。