咱們團隊的前端項目是基於一套內部的後臺框架進行開發的,這套框架是基於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,藉助這個模塊也能夠很快的實現本身的腳手架工具。
文中有不足的地方,歡迎指正和討論:)