話很少說先上圖,簡要說明一下幹了些什麼事。圖可能太模糊,能夠點svg看看javascript
最近公司開展了小程序的業務,派我去負責這一塊的業務,其中須要處理的一個問題是接入咱們web開發的傳統架構--模塊化開發。
咱們來詳細說一下模塊化開發具體是怎麼樣的。
咱們的git工做流採用的是git flow。一個項目會拆分紅幾個模塊,而後一人負責一個模塊(對應git flow的一個feature)獨立開發。模塊開發並與後端聯通後再合併至develop進行集成測試,後續通過一系列測試再發布版本。
目錄結構大致如圖所示,一個模塊包含了他本身的pages / components / assets / model / mixins / apis / routes / scss等等。css
這種開發模式的好處不言而喻,每一個人均可以並行開發,大大提高開發速度。此次就是要移植這種開發模式到小程序中。html
背景說完了,那麼來明確一下咱們的目標。
我採用的是wepy框架,類vue語法的開發,開發體驗很是棒。在vue中,一個組件就是單文件,包含了js、html、css。wepy採用vue的語法,但由與vue稍稍有點區別,wepy的組件分爲三種--wepy.app類,wepy.page類,wepy.component類。
對應到咱們的目錄結構中,每一個模塊實際上就是一系列的page組件。要組合這一系列的模塊,那麼很簡單,咱們要作的就是把這一系列page的路由掃描成一個路由表,而後插入到小程序的入口--app.json中。對應wepy框架那便是app.wpy中的pages字段。前端
第一步!先獲得全部pages的路由並綜合成一個路由表!
個人方案是,在每一個模塊中新建一份routes文件,至關於註冊每一個須要插入到入口的page的路由,不須要接入業務的page就不用註冊啦。是否是很熟悉呢,對的,就是參考vue-router的註冊語法。vue
//routes.js module.exports = [ { name: 'home-detail',//TODO: name先佔位,後續再嘗試經過讀name跳轉某頁 page: 'detail',//須要接入入口的page的文件名。例如這裏是index.wpy。相對於src/的路徑就是`modules/${moduleName}/pages/index`。 }, { name: 'home-index', page: 'index', meta: { weight: 100//這裏加了一個小功能,由於小程序指定pages數組的第一項爲首頁,後續我會經過這個權重字段來給pages路由排序。權重越高位置越前。 } } ]
而掃描各個模塊併合並路由表的腳本很是簡單,讀寫文件就ok了。java
const fs = require('fs') const path = require('path') const routeDest = path.join(__dirname, '../src/config/routes.js') const modulesPath = path.join(__dirname, '../src/modules') let routes = [] fs.readdirSync(modulesPath).forEach(module => { if(module.indexOf('.DS_Store') > -1) return const route = require(`${modulesPath}/${module}/route`) route.forEach(item => { item.page = `modules/${module}/pages/${item.page.match(/\/?(.*)/)[1]}` }) routes = routes.concat(route) }) fs.writeFileSync(routeDest,`module.exports = ${JSON.stringify(routes)}`, e => { console.log(e) })
路由排序策略node
const strategies = { sortByWeight(routes) { routes.sort((a, b) => { a.meta = a.meta || {} b.meta = b.meta || {} const weightA = a.meta.weight || 0 const weightB = b.meta.weight || 0 return weightB - weightA }) return routes } }
最後得出路由表webpack
const Strategies = require('../src/lib/routes-model') const routes = Strategies.sortByWeight(require('../src/config/routes')) const pages = routes.map(item => item.page) console.log(pages)//['modules/home/pages/index', 'modules/home/pages/detail']
So far so good...問題來了,如何替換入口文件中的路由數組。我以下作了幾步嘗試。git
我第一感受就是,這不很簡單嗎?在wepy編譯以前,先跑腳本得出路由表,再import這份路由表就得了。程序員
import routes from './routes' export default class extends wepy.app { config = { pages: routes,//['modules/home/pages/index'] window: { backgroundTextStyle: 'light', navigationBarBackgroundColor: '#fff', navigationBarTitleText: '你們好我是渣渣輝', navigationBarTextStyle: 'black' } } //... }
然而這樣小程序確定會炸啦,pages字段的值必須是靜態的,在小程序運行以前就配置好,動態引入是不行的!不信的話諸君能夠試試。那麼就是說,劃重點---咱們必須在wepy編譯以前再預編譯一次---事先替換掉pages字段的值!
既然要事先替換,那就是要精準定位pages字段的值,而後再替換掉。難點在於若是精準定位pages字段的值呢?
最撈然而最快的方法:正則匹配。
事先定好編碼規範,在pages字段的值的先後添加/* __ROUTES__ */
的註釋
腳本以下:
const fs = require('fs') const path = require('path') import routes from './routes' function replace(source, arr) { const matchResult = source.match(/\/\* __ROUTE__ \*\/([\s\S]*)\/\* __ROUTE__ \*\//) if(!matchResult) { throw new Error('必須包含/* __ROUTE__ */標記註釋') } const str = arr.reduce((pre, next, index, curArr) => { return pre += `'${curArr[index]}', ` }, '') return source.replace(matchResult[1], str) } const entryFile = path.join(__dirname, '../src/app.wpy') let entry = fs.readFileSync(entryFile, {encoding: 'UTF-8'}) entry = replace(entry, routes) fs.writeFileSync(entryFile, entry)
app.wpy的變化以下:
//before export default class extends wepy.app { config = { pages: [ /* __ROUTE__ */ /* __ROUTE__ */ ], window: { backgroundTextStyle: 'light', navigationBarBackgroundColor: '#fff', navigationBarTitleText: '你們好我是渣渣輝', navigationBarTextStyle: 'black' } } //... } //after export default class extends wepy.app { config = { pages: [ /* __ROUTE__ */'modules/home/pages/index', /* __ROUTE__ */ ], window: { backgroundTextStyle: 'light', navigationBarBackgroundColor: '#fff', navigationBarTitleText: '你們好我是渣渣輝', navigationBarTextStyle: 'black' } } //... }
行吧,也總算跑通了。由於項目很趕,因此先用這個方案開發了一個半星期。開發完以後總以爲這種方案太難受,因而密謀着換另外一種各精準的自動的方案。。。
想必你們確定很熟悉這種模式
let host = 'http://www.tanwanlanyue.com/' if(process.env.NODE_ENV === 'production'){ host = 'http://www.zhazhahui.com/' }
經過這種只在編譯過程當中存在的全局常量,咱們能夠作不少值的匹配。
由於wepy已經預編譯了一層,在框架內的業務代碼是讀取不了process.env.NODE_ENV的值。我就想着要不作一個相似於webpack的DefinePlugin的babel插件吧。具體的思路是babel編譯過程當中訪問ast時匹配須要替換的標識符或者表達式,而後替換掉相應的值。例如:
In
export default class extends wepy.app { config = { pages: __ROUTE__, window: { backgroundTextStyle: 'light', navigationBarBackgroundColor: '#fff', navigationBarTitleText: '你們好我是渣渣輝', navigationBarTextStyle: 'black' } } //... }
Out
export default class extends wepy.app { config = { pages: [ 'modules/home/pages/index', ], window: { backgroundTextStyle: 'light', navigationBarBackgroundColor: '#fff', navigationBarTitleText: '你們好我是渣渣輝', navigationBarTextStyle: 'black' } } //... }
在這裏先要給你們推薦幾份學習資料:
首先是babel官網推薦的這份迷你編譯器的代碼,讀完以後基本能理解編譯器作的三件事:解析,轉換,生成的過程了。
其次是編寫Babel插件入門手冊。基本涵蓋了編寫插件的方方面面,不過因爲babel幾個工具文檔的缺失,在寫插件的時候須要去翻查代碼中的註釋閱讀api用法。
而後是大殺器AST轉換器--astexplorer.net。咱們來看一下,babel的解析器--babylon的文檔,涵蓋的節點類型這麼多,腦繪一張AST樹不現實。我在編寫腳本的時候會先把代碼放在轉換器內生成AST樹,再一步一步走。
編寫babel插件以前先要理解抽象語法樹這個概念。編譯器作的事能夠總結爲:解析,轉換,生成。具體的概念解釋去看入門手冊可能會更好。這裏講講我本身的一些理解。
解析包括詞法分析與語法分析。
解析過程吧。其實按個人理解(不知道這樣合適不合適= =)抽象語法樹跟DOM樹其實很相似。詞法分析有點像是把html解析成一個一個的dom節點的過程,語法分析則有點像是將dom節點描述成dom樹。
轉換過程是編譯器最複雜邏輯最集中的地方。首先要理解「樹形遍歷」與「訪問者模式」兩個概念。
「樹形遍歷」如手冊中所舉例子:
假設有這麼一段代碼:
function square(n) { return n * n; }
那麼有以下的樹形結構:
- FunctionDeclaration - Identifier (id) - Identifier (params[0]) - BlockStatement (body) - ReturnStatement (body) - BinaryExpression (argument) - Identifier (left) - Identifier (right)
進入FunctionDeclaration
Identifier (id)
Identifier (id)
Identifier (params[0])
Identifier (params[0])
進入BlockStatement (body)
進入 ReturnStatement (body)
進入 BinaryExpression (argument)
Identifier (left)
Identifier (left)
Identifier (right)
Identifier (right)
BinaryExpression (argument)
ReturnStatement (body)
BlockStatement (body)
「訪問者模式」則能夠理解爲,進入一個節點時被調用的方法。例若有以下的訪問者:
const idVisitor = { Identifier() {//在進行樹形遍歷的過程當中,節點爲標識符時,訪問者就會被調用 console.log("visit an Identifier") } }
結合樹形遍從來看,就是說每一個訪問者有進入、退出兩次機會來訪問一個節點。
而咱們這個替換常量的插件的關鍵之處就是在於,訪問節點時,經過識別節點爲咱們的目標,而後替換他的值!
話很少說,直接上代碼。這裏要用到的一個工具是babel-types
,用來檢查節點。
難度其實並不大,主要工做在於熟悉如何匹配目標節點。如匹配memberExpression時使用matchesPattern方法,匹配標識符則直接檢查節點的name等等套路。最終成品及用法能夠見個人github
const memberExpressionMatcher = (path, key) => path.matchesPattern(key)//複雜表達式的匹配條件 const identifierMatcher = (path, key) => path.node.name === key//標識符的匹配條件 const replacer = (path, value, valueToNode) => {//替換操做的工具函數 path.replaceWith(valueToNode(value)) if(path.parentPath.isBinaryExpression()){//轉換父節點的二元表達式,如:var isProp = __ENV__ === 'production' ===> var isProp = true const result = path.parentPath.evaluate() if(result.confident){ path.parentPath.replaceWith(valueToNode(result.value)) } } } export default function ({ types: t }){//這裏須要用上babel-types這個工具 return { visitor: { MemberExpression(path, { opts: params }){//匹配複雜表達式 Object.keys(params).forEach(key => {//遍歷Options if(memberExpressionMatcher(path, key)){ replacer(path, params[key], t.valueToNode) } }) }, Identifier(path, { opts: params }){//匹配標識符 Object.keys(params).forEach(key => {//遍歷Options if(identifierMatcher(path, key)){ replacer(path, params[key], t.valueToNode) } }) }, } } }
固然啦,這塊插件不能夠寫在wepy.config.js中配置。由於必須在wepy編譯以前執行咱們的編譯腳本,替換pages字段。因此的方案是在跑wepy build --watch
以前跑咱們的編譯腳本,具體操做是引入babel-core
來轉換代碼
const babel = require('babel-core') //...省略獲取app.wpy過程,待會會談到。 //...省略編寫visitor過程,語法跟編寫插件略有一點點不一樣。 const result = babel.transform(code, { parserOpts: {//babel的解析器,babylon的配置。記得加入classProperties,不然會沒法解析app.wpy的類語法 sourceType: 'module', plugins: ['classProperties'] }, plugins: [ [{ visitor: myVistor//使用咱們寫的訪問者 }, { __ROUTES__: pages//替換成咱們的pages數組 }], ], })
固然最終咱們是轉換成功啦,這個插件也用上了生產環境。可是後來沒有采用這方案替換pages字段。暫時只替換了__ENV__: process.env.NODE_ENV
與__VERSION__: version
兩個常量。
爲何呢?
由於每次編譯以後標識符__ROUTES__
都會被轉換成咱們的路由表,那麼下次我想替換的時候難道要手動刪掉而後再加上__ROUTES__
嗎?我固然不會幹跟咱們自動化工程化的思想八字不合的事情啦。
不過寫完這個插件以後收穫仍是挺大的,基本瞭解該如何經過編譯器尋找並替換咱們的目標節點了。
xmldom
這個庫來解析,獲取script標籤內的代碼。wepy.app
的類,再找到config
字段,最後匹配key爲pages
的對象的值。最後替換目標節點最終腳本:
/** * @author zhazheng * @description 在wepy編譯前預編譯。獲取app.wpy內的pages字段,並替換成已生成的路由表。 */ const babel = require('babel-core') const t = require('babel-types') //1.引入路由 const Strategies = require('../src/lib/routes-model') const routes = Strategies.sortByWeight(require('../src/config/routes')) const pages = routes.map(item => item.page) //2.解析script標籤內的js,獲取code const xmldom = require('xmldom') const fs = require('fs') const path = require('path') const appFile = path.join(__dirname, '../src/app.wpy') const fileContent = fs.readFileSync(appFile, { encoding: 'UTF-8' }) let xml = new xmldom.DOMParser().parseFromString(fileContent) function getCodeFromScript(xml){ let code = '' Array.prototype.slice.call(xml.childNodes || []).forEach(child => { if(child.nodeName === 'script'){ Array.prototype.slice.call(child.childNodes || []).forEach(c => { code += c.toString() }) } }) return code } const code = getCodeFromScript(xml) // 3.在遍歷ast樹的過程當中,嵌套三層visitor去尋找節點 //3.1.找class,父類爲wepy.app const appClassVisitor = { Class: { enter(path, state) { const classDeclaration = path.get('superClass') if(classDeclaration.matchesPattern('wepy.app')){ path.traverse(configVisitor, state) } } } } //3.2.找config const configVisitor = { ObjectExpression: { enter(path, state){ const expr = path.parentPath.node if(expr.key && expr.key.name === 'config'){ path.traverse(pagesVisitor, state) } } } } //3.3.找pages,並替換 const pagesVisitor = { ObjectProperty: { enter(path, { opts }){ const isPages = path.node.key.name === 'pages' if(isPages){ path.node.value = t.valueToNode(opts.value) } } } } // 4.轉換並生成code const result = babel.transform(code, { parserOpts: { sourceType: 'module', plugins: ['classProperties'] }, plugins: [ [{ visitor: appClassVisitor }, { value: pages }], ], }) // 5.替換源代碼 fs.writeFileSync(appFile, fileContent.replace(code, result.code))
只須要在執行wepy build --watch
以前先執行這份腳本,就可自動替換路由表,自動化操做。監聽文件變更,增長模塊時自動從新跑腳本,更新路由表,開發體驗一流~
把代碼往更自動化更工程化的方向寫,這樣的過程收穫仍是挺大的。可是確實這份腳本仍有不足之處,起碼匹配節點這部分的代碼是不大嚴謹的。
另外插播一份廣告
我司風變科技正招聘前端開發:
我!們!都!想!要!
咱們開發團隊不只代碼寫的好,並且男程序員還擁有着100%的脫單率!!快來加入咱們吧!
郵箱:nicolas_refn@foxmail.com