腳手架這個詞估計做前端的都很熟悉。在沒有實現前端工程化的年代,前端程式碼的組織都是純手工維護的。比如我要做一個網站頁面,那麼我需要手動建立一個資料夾來存放程式碼檔案,我把它命名為demo。然後在demo目錄下建立src資料夾,在src資料夾內建立css資料夾、js資料夾、image資料夾、lib資料夾等等…一切都是手工維護。自從node.js出現後,前端開發才慢慢開始告別刀耕火種,越來越多的自動化工具充斥我們的眼球。模板生成、程式碼壓縮、構建打包、自動部署…這些已經成為構建前端工程專案的標配。那麼,一個模板生成的命令列工具的原理是什麼?怎樣開發一個屬於自己的命令列腳手架工具?希望我寫的這篇小文章會給大家帶來一點啟發。
原理
生成模板檔案的方式可以是本地新建空白檔案,然後進行檔案內容讀寫;又或者是把本地已有的模板進行配置資訊填充。然而我們知道,IO讀寫的速度非常慢,效能消耗大。但是一個模板生成器(Generator)如果是基於已有的模板檔案進行配置填充,然後在copy到專案目錄對應的位置,那會比直接讀寫磁碟效率更高。所以一般來說,模板生成器會採用第二種工作原理。
Yeoman-generator
模板生成器的腳手架有很多,前端領域每天都會有很多類似的輪子源源不斷地從開源社群流出。這裡我用來開發自己的generator的工具是Yeoman。Yeoman的Logo是一個戴著紅帽子的大鬍子,它是一個通用的腳手架搭建系統,可以建立任何的型別的app。同時它又是”語言無感知”的,支援建立任何型別開發語言的專案,Web, Java, Python, C# 等等。Yeoman的通用性在於,它本身不做任何決定,所有的操作都是通過Yeoman環境裡面的各種generator實現的。通過自定義generator,我們可以建立任何格式的專案目錄。這是Yeoman的最大魅力之處。另外,Yeoman通過提供promting這個方法實現輸入式命令列互動,可以讓使用者自由填寫配置資訊,互動體驗也非常棒。下面說說怎樣基於Yeoman開發一個簡單的generator:
Simple-dir
simple-dir是我自己搗鼓的一個很簡單的Yeoman-generator,在這裡我拿它來作為講解示例,大家也可以開啟詳細程式碼來看,歡迎star,也歡迎提issue。
第一步,package.json
開發一個Yeoman-generator,我們要做的第一步就是配置package.json。有幾個關鍵的地方,一個是,name的值的格式必須是”generator-“字首 + Yeoman-generators官方源列表上的唯一值(如果你要共享你的generator到官方generator源的話);第二個就是,keywords屬性必須包括”yeoman-generator”這個值;第三,files屬性是命令自定義檔案,app是預設的命令;第四,必須要安裝最新版本的yeoman-generator依賴,可以直接執行:npm install –save yeoman-generator 獲取最新的版本號。詳細的package.json可以看下面這份:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
{ "name": "generator-simple-dir", "version": "0.0.1", "description": "A very simple template generator", "files": [ "generators/app", "generators/comp", "generators/page" ], "author": "橙鄉果汁", "license": "MIT", "keywords": [ "yeoman-generator" ], "repository": { "type": "git", "url": "git@github.com:hugzh/generator-simple-dir.git" }, "bugs": { "url": "https://github.com/hugzh/generator-simple-dir/issues" }, "dependencies": { "glob": "^7.1.0", "mkdirp": "^0.5.1", "yeoman-generator": "^0.24.1" } } |
對應的src目錄格式應該是這樣的:
├───package.json └───generators/ ├───app/ │ └───index.js ├───comp/ │ └───index.js └───page/ └───index.js
你也可以直接把files屬性直接寫成:
1 2 3 4 5 |
"files": [ "app", "comp", "page" ] |
但是這樣的話,你的程式碼根目錄就必須直接包含app,comp和page資料夾。
第二步,擴充generator
這裡我們有三個generator——app,comp和page。以page為例,我們來實現一個generator。
首先,需要繼承Yeoman提供的generator基類:
1 2 |
var generators = require('yeoman-generator'); module.exports = generators.Base.extend(); |
然後我們就可以在基類內部重寫generator的方法了。Yeoman提供了一系列的基類方法:
initializing – 初始化 (檢查當前專案狀態、獲取配置檔案內容等等) prompting – 獲取使用者輸入,實現與使用者的互動 (通過this.prompt()呼叫) configuring – 儲存配置並配置整個專案 (比如建立 .editorconfig 檔案和其他媒介檔案) default – 當定義的方法沒有匹配任何基類方法的時候用到 writing – 根據自定義的規則寫入具體的generator檔案 (routes, controllers, etc) conflicts – 內部衝突處理 install – 安裝npm、bower等依賴的地方 end – 在最後呼叫, 實現cleanup, say good bye等功能。
在示例generator-simple-dir裡,page這個generator的作用是建立頁面,需要生成html/css/js檔案。在generators.Base.extend函式內部,page實現了 initializing、prompting、writing、end這幾個方法。對於prompting這樣的非同步方法,需要在互動結束的時候呼叫this.async()來結束非同步任務。Yeoman實現使用者互動的核心方法是prompting,它是一個非同步的方法,並且返回一個promise。prompting方法通過一個陣列引數,可以實現鏈式的使用者輸入。其中input型別的是使用者輸入自定義內容,confirm型別是作為True/False判斷的prompt,輸入Y/N。官方的示例如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
module.exports = generators.Base.extend({ prompting: function () { return this.prompt([{ type : 'input', name : 'name', message : 'Your project name', default : this.appname // Default to current folder name }, { type : 'confirm', name : 'cool', message : 'Would you like to enable the Cool feature?' }]).then(function (answers) { this.log('app name', answers.name); this.log('cool feature', answers.cool); }.bind(this)); } }) |
如果你想要記住使用者輸入的一個內容,用來做後面輸入的預設值的話,還可以通過增加store:true配置來實現。 在generator-simple-dir裡面,page這個generator包含4個執行步驟:初始化、獲取使用者輸入、根據使用者輸入生產模板檔案、結束返回,實現的程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 |
'use strict'; var generators = require('yeoman-generator'); var glob = require('glob'); module.exports = generators.Base.extend({ // init initializing: function() { this.existedFile = []; this.pageName = ''; // 遍歷./pages var pageFiles = glob.sync(this.destinationPath('./pages/*/')); var reg = /\/(\w+)(\/$)/; pageFiles.forEach(function(v) { if (v && v.lastIndexOf('/') > -1) { this.existedFile.push(reg.exec(v)[1]); } }.bind(this)); }, prompting: function() { var done = this.async(); var promptConf = [{ type: 'input', name: 'pageName', message: '請輸入頁面名稱:', default: 'page_demo', // 校驗page是否已存在 validate: function(input) { if (this.existedFile && this.existedFile.indexOf(input) > -1) { this.log('頁面已存在,請換一個頁面名稱!'); return false; } else { return true; } }.bind(this) }, { type: 'input', name: 'pageTitle', message: '頁面Title描述:', default: 'Title' }, { type: 'confirm', name: 'isNeedStyle', message: '是否需要樣式表?', default: true }, { type: 'confirm', name: 'isPc', message: '是否PC端的頁面?', default: false }]; return this.prompt(promptConf) .then(function(props) { this.pageName = props.pageName; this.pageTitle = props.pageTitle; this.isNeedStyle = props.isNeedStyle; this.isPc = props.isPc; done(); }.bind(this)); }, writing: function() { var tplArr = ['page.html', 'page.js', 'page.css']; var pageConf = { pageName: this.pageName, pageTitle: this.pageTitle, isNeedStyle: this.isNeedStyle, }; if (this.isPc) { tplArr[0] = 'page.pc.html'; } if (!this.isNeedStyle) { tplArr.pop(); } tplArr.forEach(function(value, index) { // (from,to,content) this.fs.copyTpl( this.templatePath(value), this.destinationPath('pages/' + pageConf.pageName + '/' + pageConf.pageName + '.' + value.split( '.').pop()), pageConf ); }.bind(this)); }, end: function() { this.log('新建頁面完成!') } }); |
定製模板
prompting方法是用來獲取使用者輸入,writing方法是根據使用者輸入內容生成模板檔案。之前說到,模板生成器的一般原理是用獲取的配置資訊渲染好模板,再拷貝到專案目錄對應的位置。所以,在writing方法裡面,需要實現模板渲染和拷貝。在Yeoman-generator裡,需要的模板檔案預設放在templates資料夾裡,所有檔案相關的操作通過this.fs物件來實現。this.fs.copyTpl就是我們用來拷貝渲染好的模板檔案的方法,需要輸入三個引數:模板源路徑、需要拷貝到的專案路徑、模板渲染內容物件。模板的渲染是基於ejs模板引擎的語法。根據我們定義的專案結構,page的實現如下:
1 2 3 4 5 6 7 |
this.fs.copyTpl( this.templatePath(value), this.destinationPath('pages/' + pageConf.pageName + '/' + pageConf.pageName + '.' + value.split( '.').pop()), pageConf ); |
更詳細的Yeoman-generator檔案操作文件請點選這裡。
commander
上面我們講解了Yeoman-generator的定製,也展示了一個簡單的generator——“simple-dir”。為了把simple-dir很優雅地跑起來,我們需要搞一個命令列工具。基於Nodejs開發自己的命令列工具是很簡單的事情,因為TJ大神已經為我們貢獻了屌炸天的工具——commander.js。關於commander的使用教程有很多,也比較容易上手,如果你還沒有了解過commander.js,推薦閱讀這兩篇文章:《Commander:node.js命令列介面的完全解決方案》和《Commander寫自己的Nodejs命令》。
有了commander的基礎之後,我們將Yeoman-generator封裝到自定義好的命令中。比如我已經封裝好了自己的命令列工具,它的名字叫做atdir(取自auto director),我們想要實現只需要執行 “atdir page” 就會自動生成需要的 html/css/js。然後我們只需要在atdir裡面定義page.js:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
module.exports = function() { var yeoman = require('yeoman-environment'); var env = yeoman.createEnv(); env.lookup(function() { env.run('simple-dir:page', { 'skip-install': true }, function(err) { if (err) { throw err; } }); }); } |
env.lookup()的作用是遍歷使用者機器上安裝好的generator,接入到Yeoman-environment,比如我們simple-dir的init、page或者comp命令。然後執行env.run()。由於我已經將simple-dir釋出到npm包了,所以可以直接呼叫env.run(‘simple-dir:page’,function(){})。如果你不想將generator釋出到npm,然後又想在本地使用generator的話也可以,直接進入generator的根目錄,執行npm link,simple-dir 指令就會關聯到本地的npm裡面,Yeoman就能找到 “simple-dir:page” 這個指令啦!
小工具——atdir
atdir就是上面說的命令列小工具,想要了解命令列的詳細封裝方法可以戳這裡。由於atdir沒有釋出到npm源,不能直接npm i。如果想要執行起來的話,請先把atdir原始碼clone到本地,進入到atdir根目錄,執行npm link,npm install 之後就可以愉快的執行atdir命令啦~~~ 附上幾張執行介面截圖: $ atdir init
結語
這只是Yeoman-generator的簡單用法,意圖在於學習搭建一個自己的命令列腳手架,其實還有很多可以完善的地方,比如目前的模板目錄是固定的,可以考慮實現更靈活的配置;還可以加上webpack等打包工具的config實現自動構建等等,這個就留到後面再去擴充。大家有什麼想法也可以在github上提issue,歡迎指正!