咱们团队的前端项目是基于一套内部的后台框架进行开发的,这套框架是基于vue和ElementUI进行了一些定制化包装,并加入了一些本身团队设计的模块,能够进一步简化后台页面的开发工做。javascript
这套框架拆分为基础组件模块,用户权限模块,数据图表模块三个模块,后台业务层的开发至少要基于基础组件模块,能够根据具体须要加入用户权限模块或者数据图表模块。尽管vue提供了一些脚手架工具vue-cli,但因为咱们的项目是基于多页面的配置进行开发和打包,与vue-cli生成的项目结构和配置有些不同,因此建立项目的时候,仍然须要人工去修改不少地方,甚至为了方便,直接从以前的项目copy过来而后进行魔改。表面上看问题不大,但其实存在不少问题:html
重复性工做,繁琐并且浪费时间前端
copy过来的模板容易存在无关的代码vue
项目中有不少须要配置的地方,容易忽略一些配置点,进而埋坑java
人工操做永远都有可能犯错,建新项目时,总要花时间去排错node
内部框架也在不停的迭代,人工建项目每每不知道框架最新的版本号是多少,使用旧版本的框架可能会从新引入一些bugpython
针对以上问题,我开发了一个脚手架工具,能够根据交互动态生成项目结构,自动添加依赖和配置,并移除不须要的文件。linux
接下来整理一下个人整个开发经历。git
开始撸代码以前,先捋一捋思路。其实,在实现本身的脚手架以前,我反复整理分析了vue-cli的实现,发现不少有意思的模块,并从中借鉴了它的一些好的思想。github
vue-cli是将项目模板做为资源独立发布在git上,而后在运行的时候将模板下载下来,通过模板引擎渲染,最后生成工程。这样将项目模板与工具分离的目的主要是,项目模板负责项目的结构和依赖配置,脚手架负责项目构建的流程,这两部分并无太大的关联,经过分离,能够确保这两部分独立维护。假如项目的结构、依赖项或者配置有变更,只须要更新项目模板便可。
参照vue-cli的思路,我也将项目模板独立发布到git上,而后经过脚手架工具下载下来,通过与脚手架的交互获取新项目的信息,并将交互的输入做为元信息渲染项目模板,最终获得项目的基础结构。
工程基于nodejs 8.4以及ES6进行开发,目录结构以下
/bin # ------ 命令执行文件
/lib # ------ 工具模块
package.json
复制代码
下面的部分代码须要你先对Promise
有必定的了解才更好的理解。
nodejs内置了对命令行操做的支持,node工程下package.json
中的bin
字段能够定义命令名和关联的执行文件。
{
"name": "macaw-cli",
"version": "1.0.0",
"description": "个人cli",
"bin": {
"macaw": "./bin/macaw.js"
}
}
复制代码
通过这样配置的nodejs项目,在使用-g
选项进行全局安装的时候,会自动在系统的[prefix]/bin
目录下建立相应的符号连接(symlink)关联到执行文件。若是是本地安装,这个符号连接会生成在./node_modules/.bin
目录下。这样作的好处是能够直接在终端中像执行命令同样执行nodejs文件。关于prefix
,能够经过npm config get prefix
获取。
在bin目录下建立一个macaw.js文件,用于处理命令行的逻辑。
touch ./bin/macaw.js
复制代码
接下来就要用到github上一位神级人物——tj——开发的模块commander.js。commander.js能够自动的解析命令和参数,合并多选项,处理短参,等等,功能强大,上手简单。具体的使用方法能够参见项目的README。
在macaw.js
中编写命令行的入口逻辑
#!/usr/bin/env node
const program = require('commander') // npm i commander -D
program.version('1.0.0')
.usage('<command> [项目名称]')
.command('hello', 'hello')
.parse(process.argv)
复制代码
接着,在bin
目录下建立macaw-hello.js
,放一个打印语句
touch ./bin/macaw-hello.js
echo "console.log('hello, commander')" > ./bin/macaw-hello.js
复制代码
这样,经过node命令测试一下
node ./bin/macaw.js hello
复制代码
不出意外,能够在终端上看到一句话:hello, commander。
commander支持git风格的子命令处理,能够根据子命令自动引导到以特定格式命名的命令执行文件,文件名的格式是[command]-[subcommand]
,例如:
咱们须要经过一个命令来新建项目,按照经常使用的一些名词,咱们能够定义一个名为init
的子命令。
对bin/macaw.js
作一些改动。
const program = require('commander')
program.version('1.0.0')
.usage('<command> [项目名称]')
.command('init', '建立新项目')
.parse(process.argv)
复制代码
在bin目录下建立一个init
命令关联的执行文件
touch ./bin/macaw-init.js
复制代码
添加以下代码
#!/usr/bin/env node
const program = require('commander')
program.usage('<project-name>').parse(process.argv)
// 根据输入,获取项目名称
let projectName = program.args[0]
if (!projectName) { // project-name 必填
// 至关于执行命令的--help选项,显示help信息,这是commander内置的一个命令选项
program.help()
return
}
go()
function go () {
// 预留,处理子命令
}
复制代码
注意第一行#!/usr/bin/env node
是干吗的,有个关键词叫Shebang,不了解的能够去搜搜看
project-name
是必填参数,不过,我想对project-name
进行一些自动化的处理。
project-name
同样,则直接在当前目录下建立工程,不然,在当前目录下建立以project-name
做为名称的目录做为工程的根目录project-name
同名的目录,则建立以project-name
做为名称的目录做为工程的根目录,不然提示项目已经存在,结束命令执行。根据以上设定,再对执行文件作一些完善
#!/usr/bin/env node
const program = require('commander')
const path = require('path')
const fs = require('fs')
const glob = require('glob') // npm i glob -D
program.usage('<project-name>')
// 根据输入,获取项目名称
let projectName = program.args[0]
if (!projectName) { // project-name 必填
// 至关于执行命令的--help选项,显示help信息,这是commander内置的一个命令选项
program.help()
return
}
const list = glob.sync('*') // 遍历当前目录
let rootName = path.basename(process.cwd())
if (list.length) { // 若是当前目录不为空
if (list.filter(name => {
const fileName = path.resolve(process.cwd(), path.join('.', name))
const isDir = fs.stat(fileName).isDirectory()
return name.indexOf(projectName) !== -1 && isDir
}).length !== 0) {
console.log(`项目${projectName}已经存在`)
return
}
rootName = projectName
} else if (rootName === projectName) {
rootName = '.'
} else {
rootName = projectName
}
go()
function go () {
// 预留,处理子命令
console.log(path.resolve(process.cwd(), path.join('.', rootName)))
}
复制代码
随意找个路径下建一个空目录,而后在这个目录下执行我们定义的初始化命令
node /[pathto]/macaw-cli/bin/macaw.js init hello-cli
复制代码
正常的话,能够看到终端上打印出项目的路径。
下载模板的工具用到另一个node模块download-git-repo,参照项目的README,对下载工具进行简单的封装。
在lib
目录下建立一个download.js
const download = require('download-git-repo')
module.exports = function (target) {
target = path.join(target || '.', '.download-temp')
return new Promise(resolve, reject) {
// 这里能够根据具体的模板地址设置下载的url,注意,若是是git,url后面的branch不能忽略
download('https://github.com:username/templates-repo.git#master',
target, { clone: true }, (err) => {
if (err) {
reject(err)
} else {
// 下载的模板存放在一个临时路径中,下载完成后,能够向下通知这个临时路径,以便后续处理
resolve(target)
}
})
}
}
复制代码
download-git-repo模块本质上就是一个方法,它遵循node.js的CPS,用回调的方式处理异步结果。若是熟悉node.js的话,应该都知道这样处理存在一个弊端,我把它进行了封装,转换成如今更加流行的Promise的风格处理异步。
再一次对以前的macaw-init.js
进行修改
const download = require('./lib/download')
... // 以前的省略
function go () {
download(rootName)
.then(target => console.log(target))
.catch(err => console.log(err))
}
复制代码
下载完成以后,再将临时下载目录中的项目模板文件转移到项目目录中,一个简单的脚手架算是基本完成了。转移的具体实现方法就不细说了,能够参见node.js的API。你的node.js版本若是在8如下,能够用stream和pipe的方式实现,若是是8或者9,可使用新的API——copyFile()或者copyFileSync()。
but...
这个世界并不是咱们想象的那么简单。咱们可能会但愿项目模板中有些文件或者代码能够动态处理。好比:
对于这类状况,咱们还须要借助其余工具包来完成。
对于命令行交互的功能,能够用inquirer.js来处理。用法其实很简单:
const inquirer = require('inquirer') // npm i inquirer -D
inquirer.prompt([
{
name: 'projectName',
message: '请输入项目名称'
}
]).then(answers => {
console.log(`你输入的项目名称是:${answers.projectName}`)
})
复制代码
prompt()
接受一个问题对象的数据,在用户与终端交互过程当中,将用户的输入存放在一个答案对象中,而后返回一个Promise
,经过then()
获取到这个答案对象。so easy!
接下来继续对macaw-init.js进行完善。
// ...
const inquirer = require('inquirer')
const list = glob.sync('*')
let next = undefined
if (list.length) {
if (list.filter(name => {
const fileName = path.resolve(process.cwd(), path.join('.', name))
const isDir = fs.stat(fileName).isDirectory()
return name.indexOf(projectName) !== -1 && isDir
}).length !== 0) {
console.log(`项目${projectName}已经存在`)
return
}
next = Promise.resolve(projectName)
} else if (rootName === projectName) {
next = inquirer.prompt([
{
name: 'buildInCurrent',
message: '当前目录为空,且目录名称和项目名称相同,是否直接在当前目录下建立新项目?'
type: 'confirm',
default: true
}
]).then(answer => {
return Promise.resolve(answer.buildInCurrent ? '.' : projectName)
})
} else {
next = Promise.resolve(projectName)
}
next && go()
function go () {
next.then(projectRoot => {
if (projectRoot !== '.') {
fs.mkdirSync(projectRoot)
}
return download(projectRoot).then(target => {
return {
projectRoot,
downloadTemp: target
}
})
})
}
复制代码
若是当前目录是空的,而且目录名称和项目名称相同,那么就经过终端交互的方式确认是否直接在当前目录下建立项目,这样会让脚手架更加人性化。
前面提到,新项目的名称、版本号、描述等信息能够直接经过终端交互插入到项目模板中,那么再进一步完善交互流程。
// ...
// 这个模块能够获取node包的最新版本
const latestVersion = require('latest-version') // npm i latest-version -D
// ...
function go () {
next.then(projectRoot => {
if (projectRoot !== '.') {
fs.mkdirSync(projectRoot)
}
return download(projectRoot).then(target => {
return {
name: projectRoot,
root: projectRoot,
downloadTemp: target
}
})
}).then(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.name}`
}
]).then(answers => {
return latestVersion('macaw-ui').then(version => {
answers.supportUiVersion = version
return {
...context,
metadata: {
...answers
}
}
}).catch(err => {
return Promise.reject(err)
})
})
}).then(context => {
console.log(context)
}).catch(err => {
console.error(err)
})
}
复制代码
下载完成后,提示用户输入新项目信息。固然,交互的问题不只限于此,能够根据本身项目的状况,添加更多的交互问题。inquirer.js强大的地方在于,支持不少种交互类型,除了简单的input
,还有confirm
、list
、password
、checkbox
等,具体能够参见项目的README。
而后,怎么把这些输入的内容插入到模板中呢,这时候又用到另一个简单但又不简单的工具包——metalsmith。
引用官网的介绍:
An extremely simple, pluggable static site generator.
它就是一个静态网站生成器,能够用在批量处理模板的场景,相似的工具包还有Wintersmith、Assemble、Hexo。它最大的一个特色就是EVERYTHING IS PLUGIN,因此,metalsmith本质上就是一个胶水框架,经过黏合各类插件来完成生产工做。
模板引擎我选择handlebars。固然,还能够有其余选择,例如ejs、jade、swig。
用handlebars的语法对模板作一些调整,例如修改模板中的package.json
{
"name": "{{projectName}}",
"version": "{{projectVersion}}",
"description": "{{projectDescription}}",
"author": "Forcs Zhang",
"private": true,
"scripts": {
"dev": "node build/dev-server.js",
"start": "node build/dev-server.js",
"build": "node build/build.js",
"unit": "cross-env BABEL_ENV=test karma start test/unit/karma.conf.js --single-run",
"test": "npm run unit",
"lint": "eslint --ext .js,.vue src test/unit/specs"
},
"dependencies": {
"element-ui": "^2.0.7",
"macaw-ui": "{{supportUiVersion}}",
"vue": "^2.5.2",
"vue-router": "^2.3.1"
},
...
}
复制代码
package.json
的name
、version
、description
字段的内容被替换成了handlebar语法的占位符,模板中其余地方也作相似的替换,完成后从新提交模板的更新。
在lib
目录下建立generator.js
,封装metalsmith。
touch ./lib/generator.js
复制代码
// npm i handlebars metalsmith -D
const Metalsmith = require('metalsmith')
const Handlebars = require('handlebars')
const rm = require('rimraf').sync
module.exports = function (metadata = {}, src, dest = '.') {
if (!src) {
return Promise.reject(new Error(`无效的source:${src}`))
}
return new Promise((resolve, reject) => {
Metalsmith(process.cwd())
.metadata(metadata)
.clean(false)
.source(src)
.destination(dest)
.use((files, metalsmith, done) => {
const meta = metalsmith.metadata()
Object.keys(files).forEach(fileName => {
const t = files[fileName].contents.toString()
files[fileName].contents = new Buffer(Handlebars.compile(t)(meta))
})
done()
}).build(err => {
rm(src)
err ? reject(err) : resolve()
})
})
}
复制代码
给macaw-init.js
的go()
添加生成逻辑。
// ...
const generator = require('../lib/generator')
function go () {
next.then(projectRoot => {
// ...
}).then(context => {
// 添加生成的逻辑
return generator(context)
}).then(context => {
console.log('建立成功:)')
}).catch(err => {
console.error(`建立失败:${err.message}`)
})
}
复制代码
至此,一个带交互,可动态给模板插值的脚手架算是基本完成了。
tips:墙裂推荐一下tj的另外一个工具包:consolidate.js,在vue-cli中发现的,感兴趣的话能够去了解一下。
经过一些工具包,让脚手架更加人性化。这里介绍两个在vue-cli中发现的工具包:
这两个工具包用起来不复杂,用好了会让脚手架看起来更加高大上
ora能够用在加载等待的场景中,好比脚手架中下载项目模板的时候可使用,若是给模板插值生成项目的过程也有明显等待的话,也可使用。
如下载为例,对download.js
作一些改良:
npm i ora -D
复制代码
const download = require('download-git-repo')
const ora = require('ora')
module.exports = function (target) {
target = path.join(target || '.', '.download-temp')
return new Promise(resolve, reject) {
const url = 'https://github.com:username/templates-repo.git#master'
const spinner = ora(`正在下载项目模板,源地址:${url}`)
spinner.start()
download(url, target, { clone: true }, (err) => {
if (err) {
spinner.fail() // wrong :(
reject(err)
} else {
spinner.succeed() // ok :)
resolve(target)
}
})
}
}
复制代码
chalk能够给终端文字设置颜色。
// ...
const chalk = require('chalk')
const logSymbols = require('log-symbols')
// ...
function go () {
// ...
next.then(/* ... */)
/* ... */
.then(context => {
// 成功用绿色显示,给出积极的反馈
console.log(logSymbols.success, chalk.green('建立成功:)'))
console.log()
console.log(chalk.green('cd ' + context.root + '\nnpm install\nnpm run dev'))
}).catch(err => {
// 失败了用红色,加强提示
console.error(logSymbols.error, chalk.red(`建立失败:${error.message}`))
})
}
复制代码
有时候,项目模板中并非全部文件都是须要的。为了保证新生成的项目中尽量的不存在脏代码,咱们可能须要根据脚手架的输入项来确认最终生成的项目结构,将没用的文件或者目录移除。好比vue-cli,建立项目时会询问咱们是否须要加入测试模块,若是不须要,最终生成的项目代码中是不包含测试相关的代码的。这个功能如何实现呢?
我参考了git的思路,定义个ignore
文件,将须要被忽略的文件名列在这个ignore
文件里,配上模板语法。脚手架在生成项目的时候,根据输入项先渲染这个ignore
文件,而后根据ignore
文件的内容移除不须要的模板文件,而后再渲染真正会用到的项目模板,最终生成项目。
根据以上思路,我先定义了属于咱们项目本身的ignore
文件,取名为templates.ignore
。
而后在这个ignore
文件中添加须要被忽略的文件名。
{{#unless supportMacawAdmin}}
# 若是不开启admin后台,登陆页面和密码修改页面是不须要的
src/entry/login.js
src/entry/password.js
{{/unless}}
# 最终生成的项目中不须要ignore文字自身
templates.ignore
复制代码
而后在lib/generator.js
中添加对templates.ignore
的处理逻辑
// ...
const minimatch = require('minimatch') // https://github.com/isaacs/minimatch
module.exports = function (metadata = {}, src, dest = '.') {
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)
// 判断下载的项目模板中是否有templates.ignore
const ignoreFile = path.join(src, 'templates.ignore')
if (fs.existsSync(ignoreFile)) {
// 定义一个用于移除模板中被忽略文件的metalsmith插件
metalsmith.use((files, metalsmith, done) => {
const meta = metalsmith.metadata()
// 先对ignore文件进行渲染,而后按行切割ignore文件的内容,拿到被忽略清单
const ignores = Handlebars.compile(fs.readFileSync(ignoreFile).toString())(meta)
.split('\n').filter(item => !!item.length)
Object.keys(files).forEach(fileName => {
// 移除被忽略的文件
ignores.forEach(ignorePattern => {
if (minimatch(fileName, ignorePattern)) {
delete files[fileName]
}
})
})
done()
})
}
metalsmith.use((files, metalsmith, done) => {
const meta = metalsmith.metadata()
Object.keys(files).forEach(fileName => {
const t = files[fileName].contents.toString()
files[fileName].contents = new Buffer(Handlebars.compile(t)(meta))
})
done()
}).build(err => {
rm(src)
err ? reject(err) : resolve()
})
})
}
复制代码
基于插件思想的metalsmith很好扩展,实现也不复杂,具体过程可参见代码中的注释。
通过对vue-cli的整理,借助了不少node模块,整个脚手架的实现并不复杂。
以上就是我开发脚手架的主要经历,中间还有不少不足的地方,从此再慢慢完善吧。
最后说一下,其实vue-cli能作的事情还有不少,具体的能够看看项目的README和源码。关于脚手架的开发,不必定要彻底造个轮子,能够看看另一个很强大的模块YEOMAN,借助这个模块也能够很快的实现本身的脚手架工具。
文中有不足的地方,欢迎指正和讨论:)