1. 程式人生 > >Westore 1.0 正式發布 - 小程序框架一個就夠

Westore 1.0 正式發布 - 小程序框架一個就夠

license utils 掌握 writeup button store 模塊 例子 pen

世界上最小卻強大的小程序框架 - 100多行代碼搞定全局狀態管理和跨頁通訊

Github: https://github.com/dntzhang/westore

眾所周知,小程序通過頁面或組件各自的 setData 再加上各種父子、祖孫、姐弟、嫂子與堂兄等等組件間的通訊會把程序搞成一團漿糊,如果再加上跨頁面之間的組件通訊,會讓程序非常難維護和調試。雖然市面上出現了許多技術棧編譯轉小程序的技術,但是我覺沒有戳中小程序的痛點。小程序不管從組件化、開發、調試、發布、灰度、回滾、上報、統計、監控和最近的雲能力都非常完善,小程序的工程化簡直就是前端的典範。而開發者工具也在持續更新,可以想象的未來,組件布局的話未必需要寫代碼了。所以最大的痛點只剩下狀態管理和跨頁通訊。

受 Omi 框架 的啟發,且專門為小程序開發的 JSON Diff 庫,所以有了 westore 全局狀態管理和跨頁通訊框架讓一切盡在掌握中,且受高性能 JSON Diff 庫的利好,長列表滾動加載顯示變得輕松可駕馭。總結下來有如下特性和優勢:

  • 和 Omi 同樣簡潔的 Store API
  • 超小的代碼尺寸(包括 json diff 共100多行)
  • 尊重且順從小程序的設計(其他轉譯庫相當於反其道行)
  • this.update 比原生 setData 的性能更優,更加智能

  • API
  • 使用指南
    • 定義全局 store
    • 創建頁面
    • 綁定數據
    • 更新頁面
    • 創建組件
    • 更新組件
    • setData 和 update 對比
    • 跨頁面同步數據
    • 調試
    • 超大型小程序最佳實踐
  • 原理
    • JSON Diff
    • Update
  • License

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

Westore 1.0 正式發布 - 小程序框架一個就夠