博客
关于我
强烈建议你试试无所不能的chatGPT,快点击我
腾讯原生小程序框架 OMIX 2.0 发布
阅读量:4196 次
发布时间:2019-05-26

本文共 13201 字,大约阅读时间需要 44 分钟。

好的设计只有一种,我们认为 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 实现一个小游戏

快速入门

API

  • create(store, option) 创建页面, store 从页面注入,可跨页面跨组件共享

  • create(option) 创建组件

  • this.store 和 this.data 全局 store 和 data,页面和页面所有组件可以拿到, 操作 data 会自动更新视图

不需要注入 store 的页面或组件用使用PageComponent 构造器, 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 的值。

store 变化监听

const handler = function (evt) {  console.log(evt)}//监听,允许绑定多个store.onChange(handler)//移除监听store.offChange(handler)

复杂 store 拆分到多文件

当小程序变得非常复杂的时候,单文件单一的 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 注入的完整的案例可以 点击这里

Path 命中规则

当 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 个食物,你能在上图中找到吗?

定义 store

import Game from '../models/game'const game = new Gameconst { snake, map } = gamegame.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 映射的开销。

进化版 MVP 优势

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 几乎一模一样,完全复用,只是渲染视图层根据不同的框架做了不同的适配。

比如 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 

[P]REACT + OMIS SNAKE

Up
Down
Left
Right
Gear
Reset
{paused ? 'Play' : 'Pause'}
}, useSelf: ['paused'], store})

Q & A

Q: 比如我一个弹窗组件,可能在很多页面使用,也可能在同一个页面使用多次;如果使用store来作为组件间通信的话,怎么应用可以实现组件是纯组件而不跟业务相关呢?

A: 纯组件不用不用 create 创建,且该组件内使用 triggerEvent 通知父组件改变 store.data 或者调用 store 的方法与外界通讯。

转载地址:http://ptzli.baihongyu.com/

你可能感兴趣的文章
Eclipse REST 库使用
查看>>
页面的兼容性从认识<!DOCTYPE>开始
查看>>
数字图像处理--空间变换
查看>>
安装制作不用愁—Wise installation入门教程
查看>>
两个程序员的泰国普吉岛之行
查看>>
常见流媒体协议
查看>>
SQL group by的困惑
查看>>
用wifi调试android程序
查看>>
软件开发,维护与支持的困惑
查看>>
5 个常用的软件质量指标
查看>>
magento 多语言
查看>>
遍历magento缓存选项和编译是否开启
查看>>
magento 得到所有的active 支付和运费方式
查看>>
magento 多语言
查看>>
【fecmall】fecyo-1.5.1开源版本发布 - 重构优惠券部分
查看>>
[Fecmall 开源商城新扩展发布] Fecyo 扩展 - walletyo - 站内余额和站内积分
查看>>
fecmall国内版本】Fecyo-1.6.0版本发布 [ 技术分享 ]
查看>>
Fecyo-1.7【真正开源,免费商用】中文电商版本发布 - 支持微信小程序
查看>>
Fecmall-2.6.0 版本发布
查看>>
Fecmall】Fecyo-1.7.2版本发布 - 支持微信【小程序直播 】功能
查看>>