好的设计只有一种,我们认为 OMIX 2.0 的设计刚刚好。
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。
无状态视图设计
对小程序零入侵
只有一个 API
支持计算属性
轻松驾驭小项目、中项目和大型项目
也适用小游戏,是的没错,使用 小程序开发小游戏,本文第二个案例使用 OMIX 实现一个小游戏
create(store, option)
创建页面, store 从页面注入,可跨页面跨组件共享
create(option)
创建组件
this.store
和 this.data
全局 store 和 data,页面和页面所有组件可以拿到, 操作 data 会自动更新视图
不需要注入 store 的页面或组件用使用
Page
和Component
构造器,Component
通过 triggerEvent 与上层通讯或与上层的 store 交互
实现一个简单的 log 列表的展示
定义全局 store:
export default {
data: {
logs: []
}
}
定义页面:
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.data.logs[0] = 'Changed!'
}, 1000)
setTimeout(() => {
//响应式,自动更新视图
this.data.logs.push(Math.random(), Math.random())
}, 2000)
setTimeout(() => {
//响应式,自动更新视图
this.data.logs.splice(this.store.data.logs.length - 1, 1)
}, 3000)
}
})
{{index + 1}}. {{log}}
定义 test-store 组件, 组件内也可以组件使用全局的 logs,组件源码:
import create from '../../utils/create'
create({
use: ['logs'],
//计算属性
computed: {
logsLength() {
return this.logs.length
}
}
})
Log Length: {{logs.length}}
Log Length by computed: {{logsLength}}
修改 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.data.arr.size(2) //会触发视图更新
this.data.arr.length = 2 //不会触发视图更新
this.data.arr.push(111) //会触发视图更新
//每个数组的方法都有对应的 pure 前缀方法,比如 purePush、pureShift、purePop 等
this.data.arr.purePush(111) //不会触发视图更新
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 会变得非常臃肿,所以需要拆分为多个 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
数据绑定:
{{a.name}}
{{b.name}}-{{b.age}}
数据使用:
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 注入的完整的案例可以 点击这里
当 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
。
提取主要实体,比如(蛇、游戏)
从实体名词中总结出具体业务属性方法,
包含结束暂停状态、地图、分数、帧率、游戏主角、食物
包含开始游戏、暂停游戏、结束游戏、生产食物、重置游戏等方法
包含运动方向、body属性
包含移动和转向方法
蛇
游戏
建立实体属性方法之间的联系
游戏主角唯一,即蛇
蛇吃食物,游戏分数增加
食物消失,游戏负责再次生产食物
蛇撞墙或撞自身,游戏状态结束
核心循环设计
判断是否有食物,没有就生产一个(低帧率)
蛇与自身碰撞检测
蛇与障碍物碰撞检测
蛇与食物碰撞检测
蛇移动
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:
带有 class 为 s 的格式是黑色的,比如食物、蛇的身体,其余的会灰色底色。
对应 js:
import create from '../../utils/create'
create({
use: ['map']
})
map
代表依赖 store.data.map,map 更新会自动更新视图。
上
下
左
右
{{highSpeed? '减速': '加速'}}
重置
{{paused ? '继续' : '暂停'}}
主界面使用 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 秒计算一次,这样会降低帧率,所以可以记录上一次执行的时间用来控制帧率:
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 映射的开销。
1. 复用性
Model 和 View 之间解耦,Model 或 View 中的一方发生变化,Presenter 接口不变,另一方就没必要对上述变化做出改变,那么 Model 层的业务逻辑具有很好的灵活性和可重用性。
2. 灵活性
Presenter 的 data 变更自动映射到视图,使得 Presenter 很薄很薄,View 属于被动视图。而且基于 Presenter 的 data 可以使用任何平台、任何框架、任何技术进行渲染。
3. 测试性
假如 View 和 Model 之间的紧耦合,在 Model 和 View 同时开发完成之前对其中一方进行测试是不可能的。出于同样的原因,对 View 或 Model 进行单元测试很困难。现在,MVP模式解决了所有的问题。MVP 模式中,View 和 Model 之间没有直接依赖,开发者能够借助模拟对象注入测试两者中的任一方。
举个逻辑复用的例子,比如 OMI 团队发起的 snake-mvp 项目,下面的几个项目的 model 和 presenter 几乎一模一样,完全复用,只是渲染视图层根据不同的框架做了不同的适配。
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
[P]REACT + OMIS SNAKE
Up
Down
Left
Right
Gear
Reset
{paused ? 'Play' : 'Pause'}
},
useSelf: ['paused'],
store
})
Q: 比如我一个弹窗组件,可能在很多页面使用,也可能在同一个页面使用多次;如果使用store来作为组件间通信的话,怎么应用可以实现组件是纯组件而不跟业务相关呢?
A: 纯组件不用不用 create 创建,且该组件内使用 triggerEvent 通知父组件改变 store.data 或者调用 store 的方法与外界通讯。