數據觸手可及,狀態無處遁形css
OMIX 2.0 是 westore 的進化版,westore 使用的是數據變動先後的 diff,diff 出的 json 就是 setData 的 patch,omix 2.0 使用的是 observer 監聽數據的變動獲得 setData 的 patch。 和 omix 對比,westore 運行時須要更多的計算,omix 初始化時須要更多的內存和計算,可是數據變動時 omix 速度比 westore 快,編程體驗方面,omix 不須要手動 update,westore 須要手動 update。html
好的設計只有一種,咱們認爲 OMIX 2.0 的設計剛恰好。react
create(store, option)
建立頁面, store 從頁面注入,可跨頁面跨組件共享,若是 option 定義了 data,store 的 data 會掛載在 this.data.$
下面create(option)
建立組件this.store.data
和 data,頁面和頁面全部組件能夠拿到, 操做 data 會自動更新視圖不須要注入 store 的頁面或組件用使用
Page
和Component
構造器,Component
經過 triggerEvent 與上層通信或與上層的 store 交互git
實現一個簡單的 log 列表的展現github
定義全局 store:編程
export default {
data: {
logs: []
}
}
複製代碼
定義頁面:json
import create from '../../utils/create'
import util from '../../utils/util'
import store from '../../store'
create(store, {
// 聲明依賴
use: ['logs'], //也支持複雜路徑依賴,好比 ['list[0].name']
// 計算屬性,能夠直接綁定在 wxml 裏
computed: {
logsLength() {
return this.logs.length
}
},
onLoad: function () {
// 響應式,自動更新視圖
this.store.data.logs = (wx.getStorageSync('logs') || []).map(log => {
return util.formatTime(new Date(log))
})
setTimeout(() => {
//響應式,自動更新視圖
this.store.data.logs[0] = 'Changed!'
}, 1000)
setTimeout(() => {
//響應式,自動更新視圖
this.store.data.logs.push(Math.random(), Math.random())
}, 2000)
setTimeout(() => {
//響應式,自動更新視圖
this.store.data.logs.splice(this.store.data.logs.length - 1, 1)
}, 3000)
}
})
複製代碼
<view class="container log-list">
<block wx:for="{{logs}}" wx:for-item="log">
<text class="log-item">{{index + 1}}. {{log}}</text>
</block>
</view>
複製代碼
定義 test-store 組件, 組件內也能夠組件使用全局的 logs,組件源碼:小程序
import create from '../../utils/create'
create({
use: ['logs'],
//計算屬性
computed: {
logsLength() {
return this.logs.length
}
}
})
複製代碼
<view class="ctn">
<view>Log Length: {{logs.length}}</view>
<view>Log Length by computed: {{logsLength}}</view>
</view>
複製代碼
修改 store.js 的 debug 字段用來打開和關閉 log 調試:數組
export default {
data: {
motto: 'Hello World',
userInfo: {},
hasUserInfo: false,
canIUse: wx.canIUse('button.open-type.getUserInfo'),
logs: []
},
debug: true, //調試開關,打開能夠在 console 面板查看到 store 變化的 log
updateAll: true //當爲 true 時,無腦所有更新,組件或頁面不須要聲明 use
}
複製代碼
全局更新開發默認是關閉的,調試開關默認打開,能夠在store.data
的全部變更都會出如今開發者工具 log 面板,以下圖所示:架構
這裏須要注意,改變數組的 length 不會觸發視圖更新,須要使用 size 方法:
this.store.data.arr.size(2) //會觸發視圖更新
this.store.data.arr.length = 2 //不會觸發視圖更新
this.store.data.arr.push(111) //會觸發視圖更新
//每一個數組的方法都有對應的 pure 前綴方法,好比 purePush、pureShift、purePop 等
this.store.data.arr.purePush(111) //不會觸發視圖更新
this.store.set(this.store.data, 'newProp', 'newPropVal') //會觸發視圖更新
this.store.data.newProp = 'newPropVal' //新增屬性不會觸發視圖更新,必須使用 create.set
複製代碼
use: [
'motto',
'userInfo',
'hasUserInfo',
'canIUse'
],
computed: {
reverseMotto() {
return this.motto.split('').reverse().join('')
}
}
複製代碼
計算屬性定義在頁面或者組件的 computed
裏,如上面的 reverseMotto
, 它能夠直接綁定在 wxml 裏,motto 更新會自動更新 reverseMotto 的值。
const handler = function (evt) {
console.log(evt)
}
//監聽,容許綁定多個
store.onChange(handler)
//移除監聽
store.offChange(handler)
複製代碼
當小程序變得很是複雜的時候,單文件單一的 store 會變得很是臃腫,這裏有兩種方案:
兩種方案能夠視狀況任選一種,或者兩種混合使用,好比對於超過100個頁面的小程序來講,多頁面多 store 應該是很常見的。
store-a.js:
export const data = {
name: 'omix'
}
export function changeName(){
data.name = 'Omix'
}
複製代碼
store-b.js:
export const data = {
name: 'omix',
age: 2
}
export function changeAge(){
data.age++
}
複製代碼
store.js 合併因此子 store 到對應模塊(a, b):
import { data as dataA, changeName } from 'store-a.js'
import { data as dataB, changeAge } from 'store-b.js'
const store = {
data:{
a: dataA,
b: dataB
},
a: { changeName },
b: { changeAge }
}
export default store
複製代碼
數據綁定:
<view>
<text>{{a.name}}</text>
<text>{{b.name}}-{{b.age}}</text>
</view>
複製代碼
數據使用:
import create from '../../utils/create'
import store from '../../store/store'
create(store, {
//聲明依賴
use: ['a.name', 'b'],
onLoad: function () {
setTimeout(_ => {
store.a.changeName()
}, 1000)
setTimeout(_ => {
store.b.changeAge()
}, 2000)
}
})
複製代碼
多 store 注入的完整的案例能夠 點擊這裏
Page A:
import create from '../../utils/create'
import store from '../../store/store-page-a.js'
create(store, {
})
複製代碼
Page B:
import create from '../../utils/create'
import store from '../../store/store-page-b.js'
create(store, {
})
複製代碼
Page A 的 Page B 的 store 徹底是兩個不一樣的 store。
當 store.data
發生變化,相關依賴的組件會進行更新,舉例說明 Path 命中規則:
Observer Path(由數據更改產生) | use 中的 path | 是否更新 |
---|---|---|
abc | abc | 更新 |
abc[1] | abc | 更新 |
abc.a | abc | 更新 |
abc | abc.a | 不更新 |
abc | abc[1] | 不更新 |
abc | abc[1].c | 不更新 |
abc.b | abc.b | 更新 |
只要注入組件的 path 等於 use 裏聲明 或者在 use 裏聲明的其中 path 子節點下就會進行更新,以上只要命中一個條件便進行更新!
若是你的小程序真的很小,那麼請無視上面的規則,直接把 store 的 updateAll 聲明爲 true 即可。若是小程序頁面不少很複雜,爲了更優的性能,請給每個頁面或非存組件聲明
use
。
class Snake {
constructor() {
this.body = [3, 1, 2, 1, 1, 1]
this.dir = 'right'
}
move(eating) {
const b = this.body
if (!eating) {
b.pop()
b.pop()
}
switch (this.dir) {
case 'up':
b.unshift(b[0], b[1] - 1)
break
case 'right':
b.unshift(b[0] + 1, b[1])
break
case 'down':
b.unshift(b[0], b[1] + 1)
break
case 'left':
b.unshift(b[0] - 1, b[1])
break
}
}
turnUp() {
if (this.dir !== 'down')
this.dir = 'up'
}
turnRight() {
if (this.dir !== 'left')
this.dir = 'right'
}
turnDown() {
if (this.dir !== 'up')
this.dir = 'down'
}
turnLeft() {
if (this.dir !== 'right')
this.dir = 'left'
}
}
複製代碼
蛇的轉向有個邏輯,就是不能反方向後退,好比正在向上移動,不能直接直接向下轉向,因此在 turnUp
,turnRight
,turnDown
,turnLeft
中都有對應的條件判斷。
import Snake from './snake'
class Game {
constructor() {
this.map = []
this.size = 16
this.loop = null
this.interval = 500
this.paused = false
this._preDate = Date.now()
this.init()
}
init() {
this.snake = new Snake
for (let i = 0; i < this.size; i++) {
const row = []
for (let j = 0; j < this.size; j++) {
row.push(0)
}
this.map.push(row)
}
}
tick() {
this.makeFood()
const eating = this.eat()
this.snake.move(eating)
this.mark()
}
mark() {
const map = this.map
for (let i = 0; i < this.size; i++) {
for (let j = 0; j < this.size; j++) {
map[i][j] = 0
}
}
for (let k = 0, len = this.snake.body.length; k < len; k += 2) {
this.snake.body[k + 1] %= this.size
this.snake.body[k] %= this.size
if (this.snake.body[k + 1] < 0) this.snake.body[k + 1] += this.size
if (this.snake.body[k] < 0) this.snake.body[k] += this.size
map[this.snake.body[k + 1]][this.snake.body[k]] = 1
}
if (this.food) {
map[this.food[1]][this.food[0]] = 1
}
}
start() {
this.loop = setInterval(() => {
if (Date.now() - this._preDate > this.interval) {
this._preDate = Date.now()
if (!this.paused) {
this.tick()
}
}
}, 16)
}
stop() {
clearInterval(this.loop)
}
pause() {
this.paused = true
}
play() {
this.paused = false
}
reset() {
this.paused = false
this.interval = 500
this.snake.body = [3, 1, 2, 1, 1, 1]
this.food = null
this.snake.dir = 'right'
}
toggleSpeed() {
this.interval === 500 ? (this.interval = 150) : (this.interval = 500)
}
makeFood() {
if (!this.food) {
this.food = [this._rd(0, this.size - 1), this._rd(0, this.size - 1)]
for (let k = 0, len = this.snake.body.length; k < len; k += 2) {
if (this.snake.body[k + 1] === this.food[1]
&& this.snake.body[k] === this.food[0]) {
this.food = null
this.makeFood()
break
}
}
}
}
eat() {
for (let k = 0, len = this.snake.body.length; k < len; k += 2) {
if (this.snake.body[k + 1] === this.food[1]
&& this.snake.body[k] === this.food[0]) {
this.food = null
return true
}
}
}
_rd(from, to) {
return from + Math.floor(Math.random() * (to + 1))
}
}
複製代碼
能夠看到上圖使用了 16*16 的二維數組來存儲蛇、食物、地圖信息。蛇和食物佔據的格子爲 1,其他爲 0。
[
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
]
複製代碼
因此上面表明了一條長度爲 5 的蛇和 1 個食物,你能在上圖中找到嗎?
import Game from '../models/game'
const game = new Game
const { snake, map } = game
game.start()
class Store {
data = {
map,
paused: false,
highSpeed: false
}
turnUp() {
snake.turnUp()
}
turnRight() {
snake.turnRight()
}
turnDown() {
snake.turnDown()
}
turnLeft() {
snake.turnLeft()
}
pauseOrPlay = () => {
if (game.paused) {
game.play()
this.data.paused = false
} else {
game.pause()
this.data.paused = true
}
}
reset() {
game.reset()
}
toggleSpeed() {
game.toggleSpeed()
this.data.highSpeed = !this.data.highSpeed
}
}
export default new Store
複製代碼
會發現, store 很薄,只負責中轉 View 的 action,到 Model,以及隱藏式自動映射 Model 上的數據到 View。
WXML:
<view class="game">
<view class="p" wx:for="{{map}}" wx:for-item="row" wx:for-index="index">
<block wx:for="{{row}}" wx:for-item="col">
<block wx:if="{{col}}">
<view class="b s"></view>
</block>
<block wx:else>
<view class="b"></view>
</block>
</block>
</view>
</view>
複製代碼
帶有 class 爲 s 的格式是黑色的,好比食物、蛇的身體,其他的會灰色底色。
對應 js:
import create from '../../utils/create'
create({
use: ['map']
})
複製代碼
map
表明依賴 store.data.map,map 更新會自動更新視圖。
<view>
<game />
<view class="ctrl">
<view class="btn cm-btn cm-btn-dir up" bindtap="turnUp"><i></i><em></em><span>上</span></view>
<view class="btn cm-btn cm-btn-dir down" bindtap="turnDown"><i></i><em></em><span>下</span></view>
<view class="btn cm-btn cm-btn-dir left" bindtap="turnLeft"><i></i><em></em><span >左</span></view>
<view class="btn cm-btn cm-btn-dir right" bindtap="turnRight"><i></i><em></em><span >右</span></view>
<view class="btn cm-btn space" bindtap="toggleSpeed"><i></i><span >{{highSpeed? '減速': '加速'}}</span></view>
<view class="btn reset small" bindtap="reset"><i ></i><span >重置</span></view>
<view class="btn pp small" bindtap="pauseOrPlay"><i></i><span >{{paused ? '繼續' : '暫停'}}</span></view>
</view>
</view>
複製代碼
主界面使用 page,引用 component:
{
"usingComponents": {
"game": "/components/game/index"
}
}
複製代碼
對應 JS:
import create from '../../utils/create'
import store from '../../store/index'
create(store, {
use: ['paused', 'highSpeed'],
turnUp() {
store.turnUp()
},
turnDown() {
store.turnDown()
},
turnLeft() {
store.turnLeft()
},
turnRight() {
store.turnRight()
},
toggleSpeed() {
store.toggleSpeed()
},
reset() {
store.reset()
},
pauseOrPlay() {
store.pauseOrPlay()
}
})
複製代碼
怎麼控制主幀率和局部幀率。通常狀況下,咱們認爲 60 FPS 是流暢的,因此咱們定時器間隔是有 16ms,核心循環裏的計算量越小,就越接近 60 FPS:
this.loop = setInterval(() => {
//
}, 16)
複製代碼
可是有些計算沒有必要 16 ms計算一次,這樣會下降幀率,因此能夠記錄上一次執行的時間用來控制幀率:
this.loop = setInterval(() => {
//執行在這裏是大約 60 FPS
if (Date.now() - this._preDate > this.interval) {
//執行在這裏是大約 1000/this.interval FPS
this._preDate = Date.now()
//暫停判斷
if (!this.paused) {
//核心循環邏輯
this.tick()
}
}
}, 16)
複製代碼
因爲小程序 JSCore 裏不支持 requestAnimationFrame
,因此這裏使用 setInterval。固然也可使用 raf-interval 循環執行 tick:
this.loop = setRafInterval(() => {
//執行在這裏是大約 60 FPS
if (Date.now() - this._preDate > this.interval) {
//執行在這裏是大約 1000/this.interval FPS
this._preDate = Date.now()
//暫停判斷
if (!this.paused) {
//核心循環邏輯
this.tick()
}
}
}, 16)
複製代碼
用法和 setInterval 一致,只是內部使用 setTimeout 且若是支持 requestAnimationFrame
會優先使用 requestAnimationFrame
。
那麼是整個項目是 MVC、MVP 仍是 MVVM?
從貪吃蛇源碼能夠看出:視圖(components,pages)和模型(models)是分離的,沒有相互依賴關係,可是在 MVC 中,視圖依賴模型,耦合度過高,致使視圖的可移植性大大下降,因此必定不是 MVC 架構。
在 MVP 模式中,視圖不直接依賴模型,由 Presenter 負責完成 Model 和 View 的交互。MVVM 和 MVP 的模式比較接近。ViewModel 擔任這 Presenter 的角色,而且提供 UI 視圖所須要的數據源,而不是直接讓 View 使用 Model 的數據源,這樣大大提升了 View 和 Model 的可移植性,好比一樣的 Model 切換使用 Flash、HTML、WPF 渲染,好比一樣 View 使用不一樣的 Model,只要 Model 和 ViewModel 映射好,View 能夠改動很小甚至不用改變。
從貪吃蛇源碼能夠看出,View(components) 裏直接使用了 Presenter(stores) 的 data 屬性進行渲染,data 屬性來自於 Model(models) 的屬性,並無出現 Model 到 ViewModel 的映射。因此必定不是 MVVM 架構。
因此上面的貪吃蛇屬於 MVP !只不過是進化版的 MVP,由於 M 裏的 map 的變動會自定更是 View,從 M->P->V的迴路是自動化的,代碼裏看不到任何邏輯。僅僅須要聲明依賴:
use: ['map']
複製代碼
這樣也規避了 MVVM 最大的問題: M 到 VM 映射的開銷。
一、複用性
Model 和 View 之間解耦,Model 或 View 中的一方發生變化,Presenter 接口不變,另外一方就不必對上述變化作出改變,那麼 Model 層的業務邏輯具備很好的靈活性和可重用性。
二、靈活性
Presenter 的 data 變動自動映射到視圖,使得 Presenter 很薄很薄,View 屬於被動視圖。並且基於 Presenter 的 data 可使用任何平臺、任何框架、任何技術進行渲染。
三、測試性
假如 View 和 Model 之間的緊耦合,在 Model 和 View 同時開發完成以前對其中一方進行測試是不可能的。出於一樣的緣由,對 View 或 Model 進行單元測試很困難。如今,MVP模式解決了全部的問題。MVP 模式中,View 和 Model 之間沒有直接依賴,開發者可以藉助模擬對象注入測試二者中的任一方。
舉個邏輯複用的例子,好比 OMI 團隊發起的 snake-mvp 項目,下面的幾個項目的 model 和 presenter 幾乎如出一轍,徹底複用,只是渲染視圖層根據不一樣的框架作了不一樣的適配。
好比 react 的視圖層 :
import React from 'react'
import Game from '../game'
import store from '../../stores/index'
import { $ } from 'omis'
require('../../utils/css').add(require('./_index.css'))
export default $({
render() {
const { store } = $
const { paused } = store.data
return <div className="container"> <h1>[P]REACT + OMIS SNAKE</h1> <Game></Game> <div className="ctrl"> <div className="btn cm-btn cm-btn-dir up" onClick={store.turnUp}><i></i><em></em><span>Up</span></div> <div className="btn cm-btn cm-btn-dir down" onClick={store.turnDown}><i></i><em></em><span>Down</span></div> <div className="btn cm-btn cm-btn-dir left" onClick={store.turnLeft}><i></i><em></em><span >Left</span></div> <div className="btn cm-btn cm-btn-dir right" onClick={store.turnRight}><i></i><em></em><span >Right</span></div> <div className="btn cm-btn space" onClick={store.toggleSpeed}><i></i><span >Gear</span></div> <div className="btn reset small" onClick={store.reset}><i ></i><span >Reset</span></div> <div className="btn pp small" onClick={store.pauseOrPlay}><i></i><span >{paused ? 'Play' : 'Pause'}</span></div> </div> </div>
},
![](https://user-gold-cdn.xitu.io/2019/10/31/16e1f8959beb0fea?w=1344&h=950&f=png&s=110201)
useSelf: ['paused'],
store
})
複製代碼
純組件不用不用 create 建立,且該組件內使用 triggerEvent 通知父組件改變 store.data 或者調用 store 的方法與外界通信。
TypeScript 版本的例子能夠點擊這裏 omix-ts
項目一開始的時候沒有使用 omix 作狀態管理,後來因爲業務需求引用了 omix,可是使用過程當中發現 omix v2 不容許定義私有 data。
若是不支持定義私有 data 的話,那麼在項目上使用 omix 的時候,是否就須要把全部頁面原有的 data 都合併到 store 中?
在頁面數量過多的時候,我想這是一件比較痛苦的事。
對比了一下 omix、omix-v一、westore、dd-store,發現後三者都支持定義私有 data。
想請教一下,是什麼緣由,致使 omix 捨棄了 omix-v1 支持定義私有 data 的特性呢?
這是否違背了 omix 對小程序零入侵的特性?
舉個例子:
create(store, {
use: [
'motto',
'userInfo',
'hasUserInfo',
'canIUse'
],
computed: {
reverseMotto() {
return this.motto.split('').reverse().join('')
}
},
data: {
name: 'omix'
},
複製代碼
這個時候全局 store 的 data 會掛在在 data.$
下,因此綁定 wxml 的時候須要加上前綴,好比:
<!--index.wxml-->
<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}}-{{reverseMotto}}-{{name}}</text>
</view>
<test-store />
</view>
複製代碼
注意,data 和 computed 的屬性不須要 $ 前綴。
任何意見建議歡迎反饋。