用大型開發框架開發小程式那點事兒
最近在寫支付寶小程式,支付寶小程式相比較微信小程式,更加缺少一些框架/工具/以及生態環境,藉著這個時機我們一起來探討一個問題
- 小程式最原始的開發模式有什麼弊端?
- 為什麼我們很需要一些大型框架
- 怎麼在小程式中科學的運用大型開發框架的設計思想?
- 如何在缺乏現成框架的情況下,學習思想,初步自己靈活運用起來
- 如何在已有現成框架的情況下,深度解讀框架設計思想
這其實是一篇循序漸進的小程式實踐記錄。對於支付寶小程式:從一開始缺乏整體框架,深感不便。到決定自己融合框架思想自行實現。再到公司內有更優更全面的整體框架後追求的最佳實踐。
目錄:
- 原始小程式開發中面臨的問題
- 構建一個 store 初步實現資料倉庫
- LunaX 的小程式上層開發元件庫
- Lux 單 store 資料倉庫實踐
- LunaX 的其他工具
小程式開發中面臨的問題
原始的小程式開發模式下,天然具備了頁面的 data 資料與 xml 渲染 mvvm 能力,同時也維護好了整個 app 與頁面 page 的生命週期,在實際開發過程中已經比沒有主流框架支援下的前端頁面開發要便捷的多。但相比於前端廣泛使用的 Vue 開發框架,以及螞蟻內部對 Vue 進一步封裝出來的 Kylin 框架來說,小程式的原始開發模式還是非常原始,存在著非常多的弊端與開發效率問題,逐一舉例:
- 全域性狀態管理
- 跨頁面跨元件通訊
- computed 計算能力
- 資料 Mock 能力
- 研發部署工作流問題
全域性狀態管理問題
在原始的小程式開發模式下,全域性的狀態只能掛在 app.js 內,可以考慮給 app 物件加一個 globalData 的屬性,用來存放和管理全域性變數,並且可以在任意程式碼通過 app 進行訪問。
App({ globalData: { userName:'hehe' }, onLaunch(options) {}, }) // 在頁面訪問全域性狀態 const app = getApp(); let userName = app.globalData.userName
但是小程式的開發中其實是有一種 mvvm 的響應式設計思維融入其中的,頁面上的資料可以做到 setData 的時候響應式去改變介面中的渲染內容,但僅限 page 頁面內的 data 資料,我能不能讓 globalData 也做到這樣的響應式。能讓我 app 的每個頁面,每個元件,但凡需要展示 UserName 的情況下,只需要再 axml 中使用全域性 globalData.userName ,就能做到任何時候有任何人操作修改了 globalData.userName ,其他的頁面(包括已經展示出來的頁面),都能響應式的變更渲染內容?彙總一下我們面臨的痛點
- 希望在頁面/元件的 axml 中,能夠可以直接使用全域性 globalData 資料進行渲染
- 希望 globalData 在發生變化的時候,能夠響應式的通知所有用到的頁面/元件,更新對應渲染元素
跨頁面跨元件通訊
說完了所有元件對全域性狀態的痛點,我們再聊聊頁面/元件間的通訊,小程式原始開發框架中最頭疼的莫過於跨頁面跨元件進行通訊,幾乎是完全隔離的,有限的通訊手段也非常的不易用,這裡舉一些例子
- 跨頁面資料傳遞問題
page A 向 page B 傳遞資料有且只有一個方法,將資料拼接成 url 的 query 然後通過 navigateTo 傳遞給下一個頁面,在 page B 的 onLoad 方法中讀取 options 入參。
痛點1: 還有沒有別的辦法?有,很山寨的讓 page A 在 app 的 globalData 上掛一個全域性變數,在 page B 的 onLoad 時機讀取這個全域性變數,這種方法實在太low了,全域性變數太多非常的不易維護,並且 app 物件所有人都可以操作,也會存在風險。
痛點2: 如果我要傳遞大量資料,巢狀型資料怎麼辦?比如我要傳遞的是一個 object 物件,裡面不僅有很多 key value 還有一個 key 的 value 是一個數組,數組裡面依然是各種物件,這種情況下怎麼傳遞?各種 key value 還算可以通過拼 url 的方式,那不知道長度的 Array 陣列如何拼接 url ?整個 object 物件,用 JSON.stringify 變成字串,然後經過 urlencode 後拼接進入 url ? 太麻煩了。
- 深層元件巢狀資料傳遞問題
page 頁面內含有 component A ,這個元件包含引用了 component B ,B 又包含引用了 Component C,這種 page -> component A -> component B -> component C 的介面巢狀層級,如果 component C 希望訪問 page 才有的資料該怎麼做?在原始的小程式開發方案中,只能通過 component 的 props 一層層透傳下去,同一個資料在3個元件中都得寫一份並且傳遞給下一個元件,這個過程耦合了4個頁面/元件,而只有 C 才會使用到。
就好像對於全域性狀態管理的訴求一樣,我們希望在元件 C 中有更方便更解耦的方式來訪問跨元件乃至跨頁面的資料,並且能夠符合小程式 mvvm 的響應式設計思想,一旦訪問的資料發生變化,元件 C 也能自動觸發元素渲染變更。
痛點1: 希望能夠在元件 C 中直接訪問其他元件/其他頁面的 data 資料
痛點2: 希望能夠將元件 C 的 axml 中的其他元件/其他頁面 data 的渲染元素,能夠響應式的自動根據原資料變化觸發渲染更新
- 跨頁面(主要是跨頁面,跨元件理論也需要支援) 函式呼叫
在老的前端多頁應用開發模式下,2個頁面之間是幾乎不存在相互呼叫的問題的,如果 page B 頁面執行了某些操作需要在 page B 頁面關閉後跳轉重新整理 page A 頁面。一般都會把資料提交給伺服器,然後在 page A 頁面載入的時候,再從伺服器拉取這些資料,而這些資料有可能不見得需要落庫存db,有可能只不過是前端中轉的一些資料而已,通過伺服器就太浪費了。於是前端有 shared worker 可以實現這種頁面之間通訊。也可以使用單頁應用 SPA 的開發模式,用成熟的 Vue 等框架進行元件間呼叫和通訊。
但是到了原始的小程式開發模式裡,所有 page 之間想要進行呼叫通訊就變得很難。雖然小程式本質上所有頁面是執行在同一個 JSContext 的 JS 虛擬機器上下文中,本質上完全應該可以進行相互通訊,但小程式的框架層面並沒有開發對應的 page 之間的通訊 api,必須自己想辦法。
痛點1: 仿照前端網頁開發的方案,把這些資料提交給伺服器中轉儲存?在新頁面展現的時候從伺服器拉取?說實話這樣可以,但這些沒必要的網路通訊無形中也在浪費著使用者的流量與伺服器的壓力
痛點2: 利用 globalData 全域性暫存臨時物件,在 navigateTo 跳轉到下一個頁面之前,把當前頁面的 this 物件掛在全域性,當作臨時物件暫存,在新頁面 onLoad 的時候從全域性變數中補貨這個臨時物件,自己持有,需要的時候直接呼叫暫存頁面 page 的方法。這種臨時變數的方案沒啥可說的,能不用就別用了。
computed 計算能力
習慣了前端頁面使用 vue 開發的同學應該都會對 vue 的 computed 與 kylin 的 getter 有所瞭解,他能夠很方便的對資料進行加工再返回頁面進行渲染。而在小程式的原始開發模式下,是缺乏這種能力的。
我們終端團隊之前沒參與過 kylin 開發的同學可能不太瞭解,那麼舉幾個最簡單的例子:多人賬本記錄著使用者之間的交易行為,大量的地方都在展示著金錢,而金錢的展示需要進行一定的格式化,比如無論是否整數還是小數都得轉化為保留2位的格式化比如 998.00
然後再進行展示。但是服務端下發的資料都是 number 型別,page 中儲存的也應該是 number 型別方便後續的計算。
在小程式的裡沒有提供相關的計算能力於是只能這麼寫,再網路返回的資料回掉中同時 set 2個數據,這樣就要求任何時候操作 money 的時候,都要同步維護 moneyForShow 的值,如果忘記了,那麼頁面就不會正常展示。
//在網路請求中呼叫 this.setData{ money: result.money; moneyForShow: utils.money2show(result.money) } //在axml中使用 <text>{{moneyForShow}}</text>
還是希望能有類似 Vuex 中 computed 的能力,在 page 的 data 中只維護一個值 money ,而定義一個 moneyForShow 的 getter 函式,在 axml 中直接寫 moneyForShow 這個 getter,就能正常的渲染,並且還能保證響應式的資料同步,每當 money 發生變化,通過 moneyForShow 這個 getter 渲染的元素也能自動重新整理。
資料 Mock 能力
小程式框架提供了 HttpRequest/Rpc/Mtop 等網路通訊的能力,但 Rpc/Mtop 這兩種網路請求能力是必須依託在支付寶錢包客戶端內才能生效的 jsbridge 能力(大家申請的內部小程式都是 web 小程式,某種程度上講就是 nebula 容器核心,所有 jsbridge 理論都能直接使用)。但是小程式官方提供的 IDE 開發環境並不是錢包環境,呼叫 Rpc/Mtop 的請求的時候會直接失敗。換句話說在沒有 mock 能力的支援下,我們平日裡開發的小程式根本不可能在官方 IDE 環境中正常開發除錯。官方提供的另外一種命令列 appx + hpm 模擬器的開發模式可以一定程度的解決這個問題。
就好比在開發 kylin 離線 h5 應用的時候,在 chrome 瀏覽器裡也是無法發起 rpc 的,只能通過 hpm 模擬器在支付寶 app 中執行,但 kylin 框架是提供了完善的 mock 方案了
痛點1: 在缺乏架構層面的 mock 解決方案的情況下,想要進行 mock 開發(或者希望在官方 IDE 中進行除錯),每個業務只能自行把 json 資料硬編碼到臨時測試程式碼裡,然後侵入業務邏輯的進行修改返回,這種侵入業務程式碼的 mock 方式並不優雅。
痛點2: 不僅僅網路請求需要 mock ,有一些 jsapi ,甚至小程式的 api 也需要 mock ,舉個例子,getAuthUserInfo 這個 api 是用來獲取使用者授權後的使用者資訊的,但因為 appx + hpm 模擬器的開發模式下,使用者授權環節環境差異,這個 api 一定會返回失敗,所以在這個環境下,這個小程式 api 也許要 mock 能力
研發部署工作流問題
小程式官方推薦的 IDE 研發工作流是一套獨立在前端 basement 平臺之外的工作流。有著自己的正式環境+釋出平臺,開發環境+釋出平臺。更詳細的工作流可以參見 ofollow,noindex">小程式環境部署
- 開發期
- 用官方 IDE 連開發環境進行開發
- 用 IDE 模擬器模擬
- 用 IDE 打包上傳生成二維碼 + 真機掃碼進行除錯
- 不用官方 IDE 用其他編輯器進行開發
- 用 appx run web ios + hpm 模擬器進行模擬
- 用 appx run qrcode 生成二維碼 + 真機掃碼進行除錯
- 用官方 IDE 連開發環境進行開發
- 測試期
- 用官方 IDE 連開發環境進行打包
- 打穩定包上傳開發環境釋出平臺
- 用開發環境釋出平臺生成穩定二維碼
- 提供二維碼給測試
- 釋出期
- 用官方 IDE 連正式環境進行打包
- 用穩定包上傳正式環境釋出平臺
- 在釋出平臺進行預發驗收
- 在釋出平臺進行提交稽核
痛點: 這裡面有一個最關鍵的問題是,官方 IDE 的工作流都是基於打包人員原生代碼的!並不是通過編譯打包平臺直接撈取倉庫主幹裡那些經過 codereview 後的程式碼。一旦打包人員進行打包上傳的時候,使用的不是倉庫中的最新的正確程式碼,或者打包人員本地除錯的時候有略微改動,忘記了就直接打了穩定包進行釋出,這種情況將無法保證釋出程式碼的質量!
構建一個 store 初步實現資料倉庫
多人賬本是會員終端團隊中最早進行小程式開發的產品,在初期調研準備的時候,參考了微信小程式的一些實戰經驗。尤其是關於頁面元件間通訊/關於全域性狀態管理這塊,都有不少成熟的解決方案,比如使用很廣泛的基於微信小程式的上層框架 wepy 。但是在支付寶小程式中缺乏這種整體的框架級解決方案,所以我們需要自己來實現一個功能相對簡單,能暫時滿足基礎通訊需求的“山寨方案”。同時因為時間問題這個山寨方案支援能力也非常有限,也並不能很好的滿足上面的所有痛點,只是解決了最關鍵的兩個問題
EventBus來實現跨頁面跨元件通訊
在原始的小程式開發過程中,對跨元件跨頁面進行通訊有著嚴格的限制。因為整個小程式的任何頁面任何 js 程式碼都是執行在同一個 JSContext JS上下文中,也就是小程式的 Service Worker 環境中,所以本質上他們是完全可以進行通訊只不過是受小程式約束所致。
如果我們自己實現一個全域性的 eventBus 並掛在 app 物件上,讓各個需要發起通訊的地方呼叫 app.event.emit() 發出通知,讓需要接收通訊的地方呼叫 app.event.on() 監聽通知,就能實現初步的跨小程式自身框架的通訊能力
//簡單思路 class Observer { constructor() { //初始化 callback map 字典 } on(eventName, callback) { //將傳入 callback 新增到 eventBus 物件的 key 為 eventName 的陣列中 //新增對 eventName 的監聽 } emit(eventName, param) { //遍歷 eventBus 物件的 key 為 eventName 的陣列 //依次呼叫陣列中存放的 callback 傳入引數 param //發出 eventName 的訊息 } clear(eventName) { //清理掉 eventBus 物件的 key 為 eventName 的陣列中所有值 //移除對 eventName 所有監聽 } off(eventName, callback) { //清理掉 eventBus 物件的 key 為 eventName 的陣列中的 callback這個值 //移除對 eventName 的具體某個監聽 } } export default Observer;
但這種模式存在著一定的弊端,因為 eventBus 的通知模式是一種一對多的呼叫模式,並不適合設計出能支援返回值的 eventBus ,所以如果需要跨頁面跨元件通訊,獲取一定的返回資料,則需要通過2條訊息,一去,一回來實現。eventBus 雖然具備一定的弊端,但卻是自己實現響應式 mvvm 的核心。如果想要構建出超過小程式頁面 page 自身的 data & axml 的 mvvm 能力,那麼至少需要在構建起這麼一套 eventBus。
但受限於業務的時間非常緊迫,在確定能滿足了一期多人賬本的需求的情況下,並沒有深入對 eventBus 進行進一步優化與擴充套件。
後續因為了解到 LunaX 的即將釋出,是一套整體的小程式框架解決方案,以後也不打算持續優化,準備將多人賬本專案著手遷移到 LunaX 上(下文會介紹)
全域性狀態的管理與控制
在多人賬本的需求中,確實存在需要全域性管理一些通用資料,並且被全域性各處頁面 axml 使用通用資料的情況,比如 UserName / UserAvatar / WindowHeight / DeviceInfo 等。又因為這些資料大多來自非同步的 JSApi 所以存在 app 初始化後資料並未準備好,非同步請求回來後必需響應式的同步重新整理所有可能出現並渲染出來的元素。
所以我們的思路就是將 app 下面的 globaData 設計為一個數據倉庫,進行統一的維護和管理,想要操作這個倉庫裡的資料必須通過指定的方法 app.store.commit(key,payload) 來執行,不能通過別的方式。當執行 commit 的時候會通過 eventBus 發出一個 key 變更的通知,來通知各個頁面進行資料變更。同時需要 Hook 每個 Page 的生命週期,在 Page OnLoad 的時候,自動的幫助頁面開發者新增上 eventBus 的 key 變更的監聽,每當 commit 全域性發出了通知,監聽就會自動生效,將新的 globaData,執行 setData 寫入當前 page,從而觸發 axml 的頁面渲染重新整理。
- app.store 提供 commit 能力,進行資料倉庫的統一提交管理
- 每當觸發 commit 全域性傳送對應 key 資料變更的通知
- app.store 提供 hook 頁面的能力,在 OnLoad 時機進行自動化處理
- 將需要的全域性倉庫裡面的資料的 key 通過 setData 寫入當前 page
- 對需要的 key 監聽其資料倉庫變化通知
- 當任意地方觸發 commit 發出了對應 key 資料變更的通知從而觸發監聽
- 將通知帶來 key 與 新value 通過 setData 寫入當前 page
- 觸發頁面的響應式渲染更新
// 簡單思路 class Store extends Observer { constructor() { super(); this.app = null; } // hook app 的建立,將store自己,自動掛載在 app 物件上,便於隨時隨地呼叫 createApp(options) { const { onLaunch } = options; const store = this; options.onLaunch = function (...params) { store.app = this; if (typeof onLaunch === 'function') { onLaunch.apply(this, params); } } return options; } // 當呼叫 commit 的時候,更新 globalData 的值,同時 emit 發出通知 commit(action, payload) { this.app.globalData[action] = payload; this.emit(action, payload); } // hook page 的生命週期,將使用者需要的 globalData key 設定到 page 的 data 之中 // 同時設定監聽,監聽來自 commit 的 key 變化通知,更新 page 的 data createPage(options) { const { globalData = [], watch = {}, onLoad, onUnload } = options; const store = this; const globalDataWatcher = {}; const watcher = {}; // 劫持onLoad 繫結監聽 options.onLoad = function (...params) { store[bindWatcher](globalData, watch, globalDataWatcher, watcher, this); if (typeof onLoad === 'function') { onLoad.apply(this, params); } } // 劫持onUnload 解綁監聽 options.onUnload = function () { store[unbindWatcher](watcher, globalDataWatcher); if (typeof onUnload === 'function') { onUnload.apply(this); } } delete options.globalData; delete options.watch; return options; } // hook component 的生命週期,功能作用類似 page createComponent(options) { // 具體實現參考 page } // 繫結監聽的具體操作 [bindWatcher](globalData, watch, globalDataWatcher, watcher, instance) { const instanceData = {}; let that = this; globalData.forEach((prop)=>{ instanceData[prop] = that.app.globalData[prop]; globalDataWatcher[prop] = payload => { instance.setData({ [prop]: payload }) } that.on(prop, globalDataWatcher[prop]); }) for (let prop in watch) { watcher[prop] = payload => { watch[prop].call(instance, payload); } this.on(prop, watcher[prop]) } instance.setData(instanceData); } // 解綁監聽的具體操作 [unbindWatcher](watcher, globalDataWatcher) { // 頁面解除安裝前 解綁對應的回撥 釋放記憶體 for (let prop in watcher) { this.off(prop, watcher[prop]); } for (let prop in globalDataWatcher) { this.off(prop, globalDataWatcher[prop]) } } } const store = new Store(); export default store;
這種思路其實還是存在弊端的,因為只解決了所有頁面/元件,對於 global 資料倉庫裡的依賴,以及響應式渲染。如果想進一步解決,page A 對 page B 的 data 響應式資料依賴,乃至 component C 對 page A 的 data 響應式資料依賴,則需要進一步加強資料倉庫 store 的管理範圍,不僅僅維護 globalData 的資料, 還要將每個頁面或者每個元件,都抽象出一個 store 子倉庫,統一被 global store 進行管理,這樣 app 的 store ,page 的 store ,component 的 store,相互之間通過父子關係構成了一個以 global store 為根的樹型解構,從而實現所有頁面與所有元件間的資料管理。而每個子倉庫的資料欄位為了避免重名不好識別,通過 pagename-keyname 或者 pagename-componentname-keyname 來當作 keypath 進行區分管理。
- 不只構建一個 global store,為每個 page 每個 component 分別構建一個store
- 以 global store 為根,以介面巢狀關係為層級,將每個 store 關聯起來,形成一個 store 樹
- 整個樹統一進行管理,統一進行 commit 提交後資料更新以及 event 傳送
- 以 keypath 作為不同 store 節點下的資料欄位區分
- 響應式的實現跨頁面跨元件的資料更新以及介面變更渲染
但受限於業務的時間非常緊迫,在確定能滿足了一期多人賬本的需求的情況下,並沒有深入對 store 進行進一步優化與擴充套件。
後續因為了解到 LunaX 的即將釋出,是一套整體的小程式框架解決方案,他內部的 Lux 完全的吸收了前端 Vuex 的能力已經非常出色,所以後續就不打算進一步完善我們自研的 store ,準備將多人賬本專案著手遷移到 LunaX + Lux 上(下文會介紹)
LunaX 的小程式上層開發元件庫
LunaX 是一套從支付寶 h5 Hybrid 中心的提供的前端頁面元件庫 Luna 演進出的面向小程式的組建庫。裡面有著豐富的工具以及腳手架 cli 支援
- Lux 資料倉庫管理外掛 – 小程式最佳實踐的核心
LunaX 內建了一套資料倉庫管理元件 Lux ,相比較我們自己初步實現的簡單 store 資料管理 , Lux
有著更全面的能力支援,包括 state ,getter,mutation ,action 等倉庫能力,以及 commit ,dispatch 的倉庫操作,可以完全實現像 Vuex 那樣以前端成熟的開發模式來進行小程式開發。
LunaX 也提供了強大的 Mock 元件,支援無論是 rpc 還是 http 還是 jsapi 的無侵入 mock 能力。支援白名單、黑名單過濾,支援網路延遲模擬等多種功能
元件庫中封裝了豐富的常用工具能力。包括帶快取,防重複提交,有統一互動的 rpc。封裝了 Tracert,用於 SPM 埋點。封裝了 clue,用於日誌上報,監控報警。獲取鳳蝶區塊資料。對 storage 增加了時間戳,解決相容問題。等等實用功能
通過 luna-appx 的腳手架建立專案,配置好了統一的 .editorconfig, .eslintc, .gitignore,以及 ts 校驗等配置。並且支援完全接入 basement
Lux 單 store 資料倉庫實踐
在前端開發種 Vue 與資料倉庫 Vuex 是一種被廣泛運用的開發框架。而 Lux 的設計初衷就是設計出一套核心 Api 與 Vuex 完全保持一致,但又可以脫離 Vue 單獨在任意環境(自然包括小程式環境)使用的資料倉庫 store。Lux 這種思路吸收了很多 Redux 的設計思想,就像 Redux 也可以在非 React 環境下使用一樣,並且也支援中介軟體與外掛機制。
由於 Lux 的核心 Api 與 Vuex 完全保持一致,在使用上幾乎可以還原 Vuex 的開發模式,所以如果之前接觸 Vuex 不多,可以先看一下官方文件: Vuex 官方文件 來了解基本概念,再參考 Lux 官方文件 來了解如何使用。
基本上資料倉庫,主要就是 state 狀態的概念,用來儲存一切資料,而為了操作資料,衍生出了 getter , mutation , action 幾種操作 , getter 用來對 state 的資料進行加工計算 , mutation 用 commit 觸發來同步提交 state 變更, action 用 dispatch 觸發來非同步執行操作。
同時為了能將資料倉庫的 state 與小程式的 axml 渲染進行 mvvm 關聯,實現響應式資料重新整理,Lux 提供了 connect 和 connect4c 2個方法用來 hook 小程式頁面/元件的生命週期實現繫結關係,並且在 connect 的時候,可以通過傳入 mapConfig 來靈活的自定義關聯控制。
Lux 的基本使用模式
一個小程式頁面建立好就會包含四個基本檔案,.acss .axml .js .json。如上圖,為了使用 Lux 專門給頁面建立一個數據倉庫目錄 store 來重點維護所有資料相關的邏輯程式碼,並把業務邏輯按著 Vuex 的設計思想抽象成四大塊。
// store/index.js 檔案 export default { state: { XX:xx }, getters: { }, mutaions: { setXXData(commit,payload){ //同步 提交某個 state 更新 } }, actions: { requestXXRpc({commit},payload){ //非同步 執行某些操作,在返回後再次呼叫 commit 提交資料更新 xxRpcPromise.then((result)=>{ commit('setXXData',result) }) } }, };
資料倉庫已經建立好了,想要在 page 中進行使用就需要將 store 與 page 進行 connect 並且掛載到 page 物件上,能方便 page 物件操作倉庫。
通過 mapStatesToData 與 mapGettersToData 兩個配置資訊,進行可選可控的繫結操作,這樣就丟棄了 page 自己的 data ,而是通過資料倉庫來關聯 axml 資料渲染。
並且在 page.js 中一般情況下只處理 UI 響應等程式碼,一旦涉及到資料資訊狀態等內容的修改或者變更,同步的變更用 commit 提交給倉庫處理,非同步的任務用 dispatch 提交給倉庫處理
其實繫結操作就是把資料倉庫的 getter 和 states 自動新增到了 page 的 data 中,並且還可以響應式同步更新,只不過對使用者無感知
// 修改 page.js 進行繫結 import { connect } from '@alipay/lux' import store from './store'; const options = { onLoad(options) { //觸發mutation this.$commit('mutation name',data) }, onShow(options) { //觸發action this.$dispatch('mutation name',data) }, }; const mapStatesToData = { //state map 程式碼 todo //但凡經過 map 過的 state 都可以直接在 axml 中使用,並且響應式同步重新整理變化 xxData: state => state.xxData, }; const mapGettersToData = { //getter map 程式碼 todo //但凡經過 map 過的 getter 都可以直接在 axml 中使用,並且響應式同步重新整理變化 xxGetter: 'xxGetter', }; const storeConfig = { mapGetters: mapGettersToData, mapState: mapStatesToData, }; //將 store 與他的配置 storeConfig 和 page 進行繫結 Page(connect(store, { mapState: mapStateToData, mapActions: mapActionsToProps })(options));
進行一個初始化
Lux 單 store 與多 store 的選擇
其實在 Lux 官方文件 中已經有介紹這兩種模式的使用差異了,這裡再多廢話一下
- 多 store:一個 page 一個 store. 隔離性好, 頁面間不會相互干擾
- 單 store:整個小程式 App 只有一個 store. 互動性強, 頁面間共用同一個 store 每一個 page 一個 module(子 store ), module 間可相互 dispatch/commit
這時候最關鍵就需要回想一下我們之前的那些個痛點了
- store 相關的痛點
- 全域性狀態管理:既然是全域性,多store 肯定不滿足,還是單 store 最合適
- 跨頁面跨元件通訊:既然是元件頁面之間互動,更需要單 store 來支援
- computed 計算能力:哪種模式都通過 getter 支援了計算能力
- store 無關的痛點
- 資料 Mock 能力:LunaX 有 Mock 元件
- 研發部署工作流問題:LunaX 有對接 basement
Lux 官方文件中對於單 store 和多 store 模式給的樣例程式碼與使用說明都相對比較簡單,整個多人賬本算是一個比較複雜的一個專案,在實踐中趟了很多坑,也找 LunaX 團隊的同學交流,探討過。在這詳細的把整個專案的單 store 實踐整理一下,也補上很多細節上容易產生的坑和問題。
Lux 的多人賬本單 store 實踐
單 store 最核心的就是把每一個 page 的子 store 當作一個 module 掛載到 app 的根 store 之下,而把有需要的 component 的子 store 當作一個 module 掛載到 page 的子 store下。最終所有的子 store 型成一個像樹一樣的整體,也就是掛載在 app 之下的根 store
component 如果沒那麼複雜可以考慮自己不實現子store,但可以 connect 連結到整個 store 下,從而能實現雖然自己沒 store ,但可以自由的 map 別的 page,app 的 state 與 getter
構建 App 根 store
Lux 的單 store 使用方式是,單獨對整個 App 構建一個 store 物件,然後用這個 store 物件通過 Provider 方法將整個 store 掛載在 App 物件上。
之後如果想給 page 或者 component 進行 connect 子store 操作,執行 connect 使用的方法和多 store 略有不同。要求使用者必須在 connect 的時候不要輸入子 store 物件,而是直接輸入 mapConfig,在 connect 的時候真正繫結到 page 物件上的實際上都是這個根 store。
每個 page 的子 store,不需要在頁面 connect 的時候掛在 page 上,而是應該作為根 store 初始化的時候的一部分,一起放在根 store 裡進行建立。
import * as Lux from '@alipay/lux'; //匯入各個頁面的子store import home from './pages/home/store'; import billBookDetail from './pages/billBookDetail/store'; import createBillBook from './pages/createBillBook/store'; //更多其他 store //建立根 store 物件 export default new Lux.Store({ state: { xx:xx }, getters: { }, mutations: { }, actions: { }, //將匯入的子store 設定到根store下,成為一體 modules: { home, createBillBook, billBookDetail, //... 更多 } }, { produce,//不可變配置,後續聊 plugins: []//Lux 外掛模組,後續聊 });
可以看到根 store 自己就是一個倉庫,可以有 state 以及 getter,mutation,action。在此處可以當作全域性變數的倉庫,管理全域性資料,也可以開放一些全域性介面(action)供任意地方呼叫
每一個頁面自己的子 store ,可以放到根 store 的 modules 欄位裡,形成了樹狀結構的第一層葉子節點。
如果業務需要有些複雜的 component 也需要有自己的子 store,那麼應該放到他的上層 page 頁面子 store 物件的 module 裡,形成樹狀解構的第二層葉子節點。以此類推巢狀型的 component 操作。
最後將根 store 繫結到 App 上,操作非常簡單, App(Provider(store)(appOptions));
一行程式碼即可
import { luna } from './common/jsapi'; import { Provider } from '@alipay/lux'; import store from './app.store'; const appOptions = { onLaunch() { checkVersion(); this.$dispatch('getUserInfo'); this.$dispatch('getDeviceInfo'); }, }; //呼叫 Provider 進行繫結 App(Provider(store)(appOptions));
page 與單 store 的掛載與繫結
將子 store 繫結到 page 的操作,和多 store 繫結姿勢略有差異
import { connect } from '@alipay/lux'; const options = { onLoad(options) {}, onShow(options) {}, }; const mapStatesToData = {}; const mapGettersToData = {}; const storeConfig = { mapGetters: mapGettersToData, mapState: mapStatesToData, }; // 注意此處,已經不需要應用 store 並且傳給 connect 了 // 只需要傳 mapConfig Page(connect(storeConfig)(options));
歸根結底是上面提到的,單 store 模式下,掛載每個 page 下面的 store 依然是根 store,每個page 通過根上面,用 page 的名字就能找到自己對應的 store,用別人 page 的名字也能找到別人對應的 store ,從而能進一步實現多個 page 之間的倉庫操作,無論是讀取資料,還是寫入資料,甚至是派發方法(dispatch action)
單 store 下的資料對映
connect 預設將 store 的所有狀態 map 到了 data 上並監聽所有狀態變化 預設將所有的 actions map 到了 option, 通過 this.$xxxAction 呼叫
在 Lux 的文件中介紹著上面這句話,意思是如果完全寫 mapState,Connect 也會預設將 store 中所有 state 全都 map 到 page 的 data 上。整個 store 也會照常工作。但是!在單 store 模式下,這句話需要重新解讀。
單 store 是一個樹型的結構,所以不同節點 store 的 state 需要從 rootState 通過節點名 pageName來查詢,比如 rootState.pageName.xxData。因為所有的 page 繫結的都是根節點,所以 mapStates 的入參 state 代表的是根節點,當前 page 的名字叫 billBookDetail,通過根節點 state.billBookDetail 來對映當前子 store 資料。如果你希望當前頁面可以在 axml 裡直接使用其他 store 的 state 資料。也可以通過這個根 state 找到其他 store ,直接 map 後使用
const mapStatesToData = { //當前頁面自己的資料 showGuide: state => state.billBookDetail.showGuide, isTotalNumOutIn: state => state.billBookDetail.isTotalNumOutIn, editName: state => state.billBookDetail.editName, memberList: state => state.billBookDetail.memberList, //其他頁面的資料,直接在當前頁面中使用 curUserInfo: state => state.home.curUserInfo, //根 store 的 state資料也可以使用 deviceInfo: state => state.deviceInfo, }; const mapGettersToData = {} const storeConfig = { mapGetters: mapGettersToData, mapState: mapStatesToData };
state 的對映單/多 store 有所差異,getter 的對映一樣存在差異。單 store 下為了區分每個子節點 store 各自的 getter 方法,需要用 keyPath 來識別 getter 方法,keyPath 的格式是 pageName/getterName,所以在進行 map 對映的時候,應該用這種 keyPath 來對映。同樣你也可以把其他 store 的getter 對映到當前 page 上
const mapStatesToData = {} const mapGettersToData = { //當前 page 是 billBookDetail,keyPath 是 billBookDetail/getterName bookDetailForShow: 'billBookDetail/bookDetailForShow', memberInfoForShow: 'billBookDetail/memberForShow', transListForShow: 'billBookDetail/transListForShow', dataStatus: 'billBookDetail/dataStatus', canWrite: 'billBookDetail/canWrite', //打算使用 home 頁面的 getter curUserForShow: 'home/curUserForShow', //打算使用根 store 的 getter maxScreenHeight: 'maxScreenHeight', }; const storeConfig = { mapGetters: mapGettersToData, mapState: mapStatesToData };
在 store 中跨頁面跨元件通訊
從上面的程式碼樣例中可以看到,我們已經能做到初步的跨頁面元件間通訊了,就是當前頁面/元件,可以任意使用其他 store 的 state、getter,來進行自己的渲染,並且享受響應式的頁面重新整理。但這還不夠,我們還希望進一步進行更多的跨頁面跨元件通訊
由於 store.js 的檔案和 page.js 的檔案,js上下文不太一致。所以在 store.js 的檔案中訪問和操作整個單 store 的方式也和 page.js 不一樣
- state
就是存放當前子 store 資料的地方,並沒有什麼邏輯程式碼,不涉及跨元件通訊
- mutation
定義為同步修改當前倉庫 state 的方法,因此所有都靠傳入引數控制,只修改自己,因此不涉及跨元件通訊
- getter
是用來計算一些資料,提供給外部進行讀取,是一種讀操作。在 getter 中是有跨頁面跨元件讀資料的需求的。 pageA 頁面的一個 getter 值,可以不僅由 pageA 頁面的 state 資料來運算,還可以由其他任意子 store 節點的 state 和 getter 來進行資料運算
// Lux 原始碼中對 getter 函式的宣告描述 export type Getter<S, R> = (state: S, getters: any, rootState: R, rootGetters: any) => any;
通過對 Lux 的 .ts 宣告檔案進行觀察發現,getter 函式,引數其實包括4個物件,我們在簡單 demo 中的使用上,很習慣 getter 只寫一個入參,省略了其他三個。就是這三個可以做到在 getter 中訪問並讀取到其他子 store 的資料。所以一個最完整的 getter 寫法應該是
getters: { minRpxHeight(state, getters, rootState, rootGetters) { if (rootState.deviceInfo) { let rate = 750 / rootState.deviceInfo.windowWidth; let rpxHeight = rootState.deviceInfo.windowHeight * rate; let statusBarHeight = rootState.deviceInfo.statusBarHeight * rate; let barHeight = rootState.deviceInfo.titleBarHeight * rate; let headZone = 28 + 44 + 26 + 100 + 100;// 頂部css計算 let result = rpxHeight - headZone - statusBarHeight - barHeight; return result; } else { return 0; } }, }
- action
是用來描述一個倉庫可以被外界呼叫的操作行為,既可以進行讀操作,也可以進行寫操作。在 action 中也有跨頁面跨元件通訊的需求,並且這個需求更大一些,既需要支援對外部倉庫的訪問讀取操作,又需要支援提交執行的操作。
export interface ActionContext<S, R> { dispatch: Dispatch; commit: Commit; state: S; getters: any; rootState: R; rootGetters: any; } type ActionHandler<S, R> = (injectee: ActionContext<S, R>, payload: any) => any; interface ActionObject<S, R> { root?: boolean; handler: ActionHandler<S, R>; } export type Action<S, R> = ActionHandler<S, R> | ActionObject<S, R>;
通過對 Lux 程式碼中 .ts 檔案的宣告可以看出來,action 接受2個入參,第一個入參是一個物件 ActionContext ,第二個入參是實際傳入的引數 payload。而 ActionContext 的定義又包括了6個物件,前兩個用來執行提交和呼叫等操作,後四個用來進行訪問讀取操作。讀取操作:state,getters,rootState,rootGetters 和 getter 中的使用一模一樣就不做贅述了
重點說一下前兩個 commit 和 dispatch。這兩個2個方法的常規使用方法都是第一個引數為名字,第二個引數為傳參。這裡只用 commit 舉例,dispatch 類似
在單 store 模式下,action 上下文中使用 commit 無需指定 keyPath,commit(‘memberList’,listData),即可直接提交給自己的 mutation,但如果此時希望跨倉庫進行提交,可以加入第三個引數 {root:true}。然後使用 keyPath 當作名字進行 commit。因此一個完整的 action 寫法應該是這樣。
actions: { async refreshData({commit,dispatch,state,getters,rootState,rootGetters},payload) { // 獲取某個網路請求 rpc promise let req = reqHomePage(); req.then((result) => { //資料提交給當前頁面的 updateData mutation commit('updateData', result); //資料提交給另一個頁面的 updateUser mutation commit('user/updateUser',result.user,{root:true}); //觸發當前頁面的 saveLocalStorage 另一個 action ,進行網路資料快取 dispatch('saveLocalStorage',result); //觸發另一個頁面的 saveUserCache 另一個 action ,進行使用者資料快取 dispatch('user/cacheUser',result.user,{root:true}); }).catch(() => { commit('updateDataError'); }); }, }
在 page 中跨頁面跨元件通訊
上文說道,因為 page 與 store 的 js 上下文是不太一樣的,所以在 page 中跨倉庫通訊也是略有不同。因為 page 的上下文 this 上掛載的整個單 store 的根,所以在 page 中無論是否是與自己頁面的子 store 互動,還是與其他頁面的子 store 互動,都必須通過名字訪問 or 通過 keyPath 提交執行。
- 通過 this.$store 訪問整個單 store。在 page.js 中不止可以訪問各個倉庫的 state ,也可以訪問 getter。都是通過各個倉庫的名字來查詢並訪問
onLoad(options) { //訪問home子倉庫下的userName state this.$store.state.home.userName; //訪問book自倉庫下的billListForShow getter this.$store.getters.book.billListForShow },
- 通過 this.$commit 與 this.$dispatch ,用 keyPath 直接提交給任意子倉庫 mutation or action。需要補充的是,在 page.js 中是強制 {root:false} 的,所以即便提交給自己頁面的倉庫也不能簡寫省略 keyPath。(這和在store裡不同)
onShow() { //提交給 home 子倉庫下的 userInfo mutation this.$commit('home/userInfo',userinfo); //提交給 billBookDetail 子倉庫下的 refreshBookDetail action this.$dispatch('billBookDetail/refreshBookDetail'); },
Lux 的輔助外掛
Lux 還提供了3個輔助外掛,和一個 immutable js 的配置
- logger 外掛:用來在任意 state 發生變化的時候列印除錯資訊,包含了 preState 和 nextState
-
batch 外掛:可以做到連續多次 commit 提交,可以合併成一次 commit 提交,來實現更好的效能
-
watcher 外掛:可以做到更好的監聽 state 變化,然後觸發監聽回掉,進行更細粒度的區域性監聽區域性更新
為了更好的管理 store 資料倉庫,只允許通過 mutation 更改 state 不允許其他任何程式碼方式直接操作 state ,也為了更高效的計算 state 變化的 diff,Lux 支援可以自定義不可變資料的方案。可以考慮使用 immer.js(腳手架預設整合) 也可以考慮自己實現 deepClone 來做到不可變資料
// 將外掛配置到根 app.store.js 上 import * as Lux from '@alipay/lux'; import createLogger from '@alipay/lux/plugins/logger'; import createBatched from '@alipay/lux/plugins/batched'; import createWatch from '@alipay/lux/plugins/watch'; //配置預設整合的 immer 不可變資料 import { produce } from 'immer'; import debounce from 'lodash.debounce'; //構建 logger 外掛 const logger = createLogger({ predicate: m => m.type !== 'add/updateInputValue' }); //構建 batch 外掛 const batched = createBatched(debounce(notify => notify(), 10)); //構建 watcher 外掛 const watcher = createWatch(); export default new Lux.Store({ state: {}, getters: {}, mutations: {}, actions: {}, modules: { home, createBillBook, ... } }, { produce, // 註冊使用 immer plugins: [ logger,// 註冊使用外掛 batched,//也可以不用 watcher, ] });
程式碼檔案規範
多人賬本專案,在全面實踐 LunaX 的過程中對於程式碼檔案的整理逐漸形成了一定的規範約束。可以參考一下借鑑一下
- components 共用元件總目錄:
- 公用元件不實現自己的 store ,遵照原生小程式開發模式。因為引入 store 會導致要把 元件 store 引入到每一處用到子 store 節點下,極大程度的讓元件在使用上變得複雜
- 公共元件可以視需求而定,進行 mapConfig 來將根 store 綁在 component 上下文
- pages 頁面邏輯總目錄:
- pageA 頁面目錄: 一個頁面的所有檔案
- component 頁面元件:這裡用於存放該頁面專用的頁面元件,如果元件邏輯複雜,可以有自己的子 store,檔案結構同 page store
- store 倉庫目錄:用於存放,子 store 主邏輯,與其他資料處理邏輯
- index.js 檔案:子 store 主邏輯
- rpc.js 檔案:用於生成 rpc promise 的邏輯,如果頁面介面簡單甚至沒有,可以省略,寫在 index.js 中
- storage.js 檔案:用於生成本地儲存 promise 的邏輯,可以省略
- pageA.js 檔案: 進行 store 的 mapConfig
- pageA.axml pageA.acss pageA.json 檔案:常規小程式 page檔案
- pageB 頁面目錄
- pageA 頁面目錄: 一個頁面的所有檔案
- app.js 小程式主入口檔案:繫結單 store 到 app
- app.store.js 根store檔案:單 store 的構建以及外掛配置
LunaX 的其他工具
前面就已經提到過 LunaX 不僅由 Lux 資料倉庫這個元件,還有這其他豐富元件
Mock 元件
- mock 在本地維護,切換非常方便靈活
- mock 支援種類豐富
- 支付寶 or 淘寶的 rpc,mtop
- 公開或內部的 http 介面,
- 同步或非同步的 jsapi
- 鳳蝶的區塊資料
- IDE,模擬器,真機都可以使用
- 跟原 luna-mock 使用方法基本一致
- 非常詳細的 log 記錄
需要說明的是,原文件中提到在模擬器中進行 mock 存在一定瑕疵,需要侵入一些程式碼才可以,現在已經不需要了,直接使用即可。使用 LunaX 構建的小程式專案,整個 mock 工具都已經配置好了
- Rpc 的 mock 最常用,直接通過 module.exports 匯出一個硬編碼物件即可
- 同步 JSApi 使用和 Rpc 一致,module.exports 匯出一個硬編碼物件
- 非同步 JSApi 使用起來略有差異,需要匯出一個方法,把硬編碼物件放到 return 結果中去
const defaultInfo = {'nickName': '我', 'avatar': '../../assets/images/head.png'}; module.exports = function (opts) { return { success: defaultInfo, }; };
小程式埋點
// 埋單個數據 luna.log.info('SOME_INFO', infoData) // 埋多個數據 luna.log.info('SOME_INFO', [infoData1, infoData2]) // 埋 spm 點位 const tracert = new luna.Tracert({ spmAPos: 'a230', // spma位,必填 spmBPos: 'b7449', // spmb位,必填 bizType: 'AntDevTools', // 業務型別,必填 logLevel: 2, // 預設是2 chInfo: luna.config.app // 渠道 }); tracert.logPv();// 記錄 pv tracert.click('c17943.d32288');// 點選埋點 tracert.expo('c17943.d32327');// 曝光埋點
鳳蝶區塊
鳳蝶區塊用於一些運營活動的配置,小程式也可以通過鳳蝶的 path 讀取鳳蝶區塊,從而做到不發版改換配置效果。 getH5data(‘path’) 方法返回的是一個 promise,在 promise 執行完畢後,鳳蝶區塊資料通過 json 返回
網路請求 Rpc
LunaX 封裝了 rpc 方法,統一處理了 Rpc 的快取,轉菊花方案,預設錯誤失敗處理方案,防重複提交,限流展示等處理。