話不多說先上圖,簡要說明一下幹了些什麼事。圖可能太模糊,可以點svg看看
背景
最近公司開展了小程式的業務,派我去負責這一塊的業務,其中需要處理的一個問題是接入我們web開發的傳統架構–模組化開發。
我們來詳細說一下模組化開發具體是怎麼樣的。
我們的git工作流採用的是git flow。一個專案會拆分成幾個模組,然後一人負責一個模組(對應git flow的一個feature)獨立開發。模組開發並與後端聯通後再合併至develop進行整合測試,後續經過一系列測試再發布版本。
目錄結構大體如圖所示,一個模組包含了他自己的pages / components / assets / model / mixins / apis / routes / scss等等。
這種開發模式的好處不言而喻,每個人都可以並行開發,大大提升開發速度。這次就是要移植這種開發模式到小程式中。
目標
背景說完了,那麼來明確一下我們的目標。
我採用的是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的註冊語法。
//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了。
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)
})
複製程式碼
路由排序策略
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
}
}
複製程式碼
最後得出路由表
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…問題來了,如何替換入口檔案中的路由陣列。我如下做了幾步嘗試。
直接引入
我第一感覺就是,這不很簡單嗎?在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__ */([sS]*)/* __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`
}
}
//...
}
複製程式碼
行吧,也總算跑通了。因為專案很趕,所以先用這個方案開發了一個半星期。開發完之後總覺得這種方案太難受,於是密謀著換另一種各精準的自動的方案。。。
babel外掛替換全域性常量
1.思路
想必大家肯定很熟悉這種模式
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`
}
}
//...
}
複製程式碼
2.學習如何編寫babel外掛
在這裡先要給大家推薦幾份學習資料:
首先是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")
}
}
複製程式碼
結合樹形遍歷來看,就是說每個訪問者有進入、退出兩次機會來訪問一個節點。
而我們這個替換常量的外掛的關鍵之處就是在於,訪問節點時,通過識別節點為我們的目標,然後替換他的值!
3.動手寫外掛
話不多說,直接上程式碼。這裡要用到的一個工具是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)
}
})
},
}
}
}
複製程式碼
4.結果
當然啦,這塊外掛不可以寫在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__
嗎?我當然不會幹跟我們自動化工程化的思想八字不合的事情啦。
不過寫完這個外掛之後收穫還是挺大的,基本瞭解該如何通過編譯器尋找並替換我們的目標節點了。
編寫babel指令碼識別pages欄位
1.思路
- 首先獲取到原始碼:app.wpy是類vue單檔案的語法。js都在script標籤內,那麼怎麼獲取這部分程式碼呢?又正則?不好吧,太撈了。通過閱讀wepy-cli的原始碼,使用
xmldom
這個庫來解析,獲取script標籤內的程式碼。 - 編寫訪問者遍歷並替換節點:首先是找到繼承自
wepy.app
的類,再找到config
欄位,最後匹配key為pages
的物件的值。最後替換目標節點 - babel轉換為程式碼後,通過讀寫檔案替換目的碼。大業已成!done!
2.成果
最終指令碼:
/**
* @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))
複製程式碼
3.使用方法
只需要在執行wepy build --watch
之前先執行這份指令碼,就可自動替換路由表,自動化操作。監聽檔案變動,增加模組時自動重新跑指令碼,更新路由表,開發體驗一流~
結語
把程式碼往更自動化更工程化的方向寫,這樣的過程收穫還是挺大的。但是確實這份指令碼仍有不足之處,起碼匹配節點這部分的程式碼是不大嚴謹的。
另外插播一份廣告
我司風變科技正招聘前端開發:
- 應屆、一年經驗,熟悉Vue的前端小鮮肉
- 三年經驗的前端大佬
我!們!都!想!要!
我們開發團隊不僅程式碼寫的好,而且男程式設計師還擁有著100%的脫單率!!快來加入我們吧!
郵箱:nicolas_refn@foxmail.com