世界上最小卻強大的小程式框架 - 100多行程式碼搞定全域性狀態管理和跨頁通訊
Github: https://github.com/dntzhang/westore
眾所周知,小程式通過頁面或元件各自的 setData 再加上各種父子、祖孫、姐弟、嫂子與堂兄等等元件間的通訊會把程式搞成一團漿糊,如果再加上跨頁面之間的元件通訊,會讓程式非常難維護和除錯。雖然市面上出現了許多技術棧編譯轉小程式的技術,但是我覺沒有戳中小程式的痛點。小程式不管從元件化、開發、除錯、釋出、灰度、回滾、上報、統計、監控和最近的雲能力都非常完善,小程式的工程化簡直就是前端的典範。而開發者工具也在持續更新,可以想象的未來,元件佈局的話未必需要寫程式碼了。所以最大的痛點只剩下狀態管理和跨頁通訊。
受 Omi 框架 的啟發,且專門為小程式開發的 JSON Diff 庫,所以有了 westore 全域性狀態管理和跨頁通訊框架讓一切盡在掌握中,且受高效能 JSON Diff 庫的利好,長列表滾動載入顯示變得輕鬆可駕馭。總結下來有如下特性和優勢:
- 和 Omi 同樣簡潔的 Store API
- 超小的程式碼尺寸(包括 json diff 共100多行)
- 尊重且順從小程式的設計(其他轉譯庫相當於反其道行)
- this.update 比原生 setData 的效能更優,更加智慧
API
Westore API 只有三個, 大道至簡:
- create(store, option) 建立頁面
- create(option) 建立元件
- this.update() 更新頁面或元件
使用指南
定義全域性 store
export default {
data: {
motto: 'Hello World',
userInfo: {},
hasUserInfo: false,
canIUse: wx.canIUse('button.open-type.getUserInfo'),
logs: []
},
logMotto: function () {
console.log(this.data.motto)
}
}
你不需要在頁面和元件上再宣告 data 屬性。如果申明瞭也沒關係,會被 Object.assign 覆蓋到 store.data 上。後續只需修改 this.store.data 便可。
建立頁面
import store from '../../store'
import create from '../../utils/create'
const app = getApp()
create(store, {
onLoad: function () {
if (app.globalData.userInfo) {
this.store.data.userInfo = app.globalData.userInfo
this.store.data.hasUserInfo = true
this.update()
} else if (this.data.canIUse) {
app.userInfoReadyCallback = res => {
this.store.data.userInfo = res.userInfo
this.store.data.hasUserInfo = true
this.update()
}
} else {
wx.getUserInfo({
success: res => {
app.globalData.userInfo = res.userInfo
this.store.data.userInfo = res.userInfo
this.store.data.hasUserInfo = true
this.update()
}
})
}
}
})
建立 Page 只需傳入兩個引數,store 從根節點注入,所有子元件都能通過 this.store 訪問。
繫結資料
<view class="container">
<view class="userinfo">
<button wx:if="{{!hasUserInfo && canIUse}}" open-type="getUserInfo" bindgetuserinfo="getUserInfo"> 獲取頭像暱稱 </button>
<block wx:else>
<image bindtap="bindViewTap" class="userinfo-avatar" src="{{userInfo.avatarUrl}}" mode="cover"></image>
<text class="userinfo-nickname">{{userInfo.nickName}}</text>
</block>
</view>
<view class="usermotto">
<text class="user-motto">{{motto}}</text>
</view>
<hello></hello>
</view>
和以前的寫法沒有差別,直接把 store.data
作為繫結資料來源。
更新頁面
this.store.data.any_prop_you_want_to_change = 'any_thing_you_want_change_to'
this.update()
建立元件
import create from '../../utils/create'
create({
ready: function () {
//you can use this.store here
},
methods: {
//you can use this.store here
}
})
和建立 Page 不一樣的是,建立元件只需傳入一個引數,不需要傳入 store,因為已經從根節點注入了。
更新元件
this.store.data.any_prop_you_want_to_change = 'any_thing_you_want_change_to'
this.update()
setData 和 update 對比
拿官方模板示例的 log 頁面作為例子:
this.setData({
logs: (wx.getStorageSync('logs') || []).map(log => {
return util.formatTime(new Date(log))
})
})
使用 westore 後:
this.store.data.logs = (wx.getStorageSync('logs') || []).map(log => {
return util.formatTime(new Date(log))
})
this.update()
看似一條語句變成了兩條語句,但是 this.update 呼叫的 setData 是 diff 後的,所以傳遞的資料更少。
跨頁面同步資料
使用 westore 你不用關係跨頁資料同步,你只需要專注 this.store.data 便可,修改完在任意地方呼叫 update 便可:
this.update()
除錯
console.log(getApp().globalData.store.data)
超大型小程式最佳實踐(兩種方案)
不排除小程式被做大得可能,接觸的最大的小程式有 60+ 的頁面,所以怎麼管理?這裡給出了兩個最佳實踐方案。
- 第一種方案,拆分 store 的 data 為不同模組,如:
export default {
data: {
commonA: 'a',
commonB: 'b',
pageA: {
a: 1
xx: 'xxx'
},
pageB: {
b: 2,
c: 3
}
},
xxx: function () {
console.log(this.data)
}
}
- 第二種方案,拆分 store 的 data 到不同檔案且合併到一個 store 暴露給 create 方法,如:
a.js
export default {
data: {
a: 1
xx: 'xxx'
},
aMethod: function (num) {
this.data.a += num
}
}
b.js
export default {
data: {
b: 2,
c: 3
},
bMethod: function () {
}
}
store.js
import a from 'a.js'
import b from 'b.js'
export default {
data: {
commonNum: 1,
commonB: 'b',
pageA: a.data
pageB: b.data
},
xxx: function () {
//you can call the methods of a or b and can pass args to them
console.log(a.aMethod(commonNum))
},
xx: function(){
}
}
當然,也可以不用按照頁面拆分檔案或模組,也可以按照領域來拆分,這個很自由,視情況而定。
原理
--------------- ------------------- -----------------------
| this.update | → | json diff | → | setData()-setData()...| → 之後就是黑盒(小程式官方實現,但是 dom/apply diff 肯定是少不了)
--------------- ------------------- -----------------------
雖然和 Omi 一樣同為 store.updata 但是卻有著本質的區別。Omi 的如下:
--------------- ------------------- ---------------- ------------------------------
| this.update | → | setState | → | jsx rerender | → | vdom diff → apply diff... |
--------------- ------------------- ---------------- ------------------------------
都是資料驅動檢視,但本質不同,原因:
- 小程式 store 和 dom 不在同一個環境,先在 js 環境進行 json diff,然後使用 diff 結果通過 setData 通訊
- web 裡使用 omi 的話 store 和 dom 在同一環境,setState 直接驅動的 vdom diff 然後把 diff 結果作用在真是 dom 上
JSON Diff
先看一下我為 westore 專門定製開發的 JSON Diff 庫 的能力:
diff({
a: 1, b: 2, c: "str", d: { e: [2, { a: 4 }, 5] }, f: true, h: [1], g: { a: [1, 2], j: 111 }
}, {
a: [], b: "aa", c: 3, d: { e: [3, { a: 3 }] }, f: false, h: [1, 2], g: { a: [1, 1, 1], i: "delete" }, k: 'del'
})
Diff 的結果是:
{ "a": 1, "b": 2, "c": "str", "d.e[0]": 2, "d.e[1].a": 4, "d.e[2]": 5, "f": true, "h": [1], "g.a": [1, 2], "g.j": 111, "g.i": null, "k": null }
Diff 原理:
- 同步所有 key 到當前 store.data
- 攜帶 path 和 result 遞迴遍歷對比所有 key value
export default function diff(current, pre) {
const result = {}
syncKeys(current, pre)
_diff(current, pre, '', result)
return result
}
同步上一輪 state.data 的 key 主要是為了檢測 array 中刪除的元素或者 obj 中刪除的 key。
小程式 setData
setData 是小程式開發中使用最頻繁的介面,也是最容易引發效能問題的介面。在介紹常見的錯誤用法前,先簡單介紹一下 setData 背後的工作原理。setData 函式用於將資料從邏輯層傳送到檢視層(非同步),同時改變對應的 this.data 的值(同步)。
其中 key 可以以資料路徑的形式給出,支援改變陣列中的某一項或物件的某個屬性,如 array[2].message,a.b.c.d,並且不需要在 this.data 中預先定義。比如:
this.setData({
'array[0].text':'changed data'
})
所以 diff 的結果可以直接傳遞給 setData
,也就是 this.update
。
setData 工作原理
小程式的檢視層目前使用 WebView 作為渲染載體,而邏輯層是由獨立的 JavascriptCore 作為執行環境。在架構上,WebView 和 JavascriptCore 都是獨立的模組,並不具備資料直接共享的通道。當前,檢視層和邏輯層的資料傳輸,實際上通過兩邊提供的 evaluateJavascript 所實現。即使用者傳輸的資料,需要將其轉換為字串形式傳遞,同時把轉換後的資料內容拼接成一份 JS 指令碼,再通過執行 JS 指令碼的形式傳遞到兩邊獨立環境。
而 evaluateJavascript 的執行會受很多方面的影響,資料到達檢視層並不是實時的。
常見的 setData 操作錯誤:
- 頻繁的去 setData
- 每次 setData 都傳遞大量新資料
- 後臺態頁面進行 setData
上面是官方擷取的內容。使用 webstore 的 this.update 本質是先 diff,再執行一連串的 setData,所以可以保證傳遞的資料每次維持在最小。既然可以使得傳遞資料最小,所以第一點和第三點雖有違反但可以商榷。
Update
這裡區分在頁面中的 update 和 元件中的 update。頁面中的 update 在 onLoad 事件中進行例項收集。
const onLoad = option.onLoad
option.onLoad = function () {
this.store = store
rewriteUpdate(this)
store.instances[this.route] = []
store.instances[this.route].push(this)
onLoad && onLoad.call(this)
}
Page(option)
元件中的 update 在 ready 事件中進行行例項收集:
const ready = store.ready
store.ready = function () {
this.page = getCurrentPages()[getCurrentPages().length - 1]
this.store = this.page.store;
this.setData.call(this, this.store.data)
rewriteUpdate(this)
this.store.instances[this.page.route].push(this)
ready && ready.call(this)
}
Component(store)
rewriteUpdate 的實現如下:
function rewriteUpdate(ctx){
ctx.update = () => {
const diffResult = diff(ctx.store.data, originData)
for(let key in ctx.store.instances){
ctx.store.instances[key].forEach(ins => {
ins.setData.call(ins, diffResult)
})
}
for (let key in diffResult) {
updateOriginData(originData, key, diffResult[key])
}
}
}
License
MIT @dntzhang