Skip to main content

整理: MVVM 设计模式: 用 mobx 建立模型

距离上一次初学的 MVVM 和 mobx,我已经把它们运用到生产环境2个月了,现再次整理一下,记下我的编码习惯和风格。

MVVM 的结构

MVVM 类似 MVC 结构,但 MVVM 更适合现时前后端分离解决方案的情形。它有 Model、View、ViewModel 3个结构:

  • Model: 提供业务接口,与后端通信,或调用系统接口

  • ViewModel: 持有 Model,处理界面事件,管理界面的变量

  • Model: 持有 ViewModel,用 vm 的变量来绘制界面,把界面事件移交给 vm。

例子: CDKey 兑换界面

一个 CDKey 的例子: 输入 CDK,点击确定后,提交到后端查看兑换结果。

构建 Model

Model 的行为很简单,只是把 CDKey 传送到后端并获取结果。这里写了一个抽象类,是因为这个它可能走 在线HTTP 方式请求后端,也可能走离线激活的方式验证 CDKey,据此可以把这个 Model 作为父类,派生出不同的实现方法。

abstract class CDKeyModel {
  abstract redeem(cdKey: string): Promise<boolean>

  static Factory = { // implementation
    createOnlineVerifier (url: string): CDKeyModel {
      return new class extends CDKeyModel {
        async redeem(cdKey: string): Promise<boolean> {/* 发起 HTTP 请求获取结果 */}
      }()
    },
    createOfflineVerifier (seed: string): CDKeyModel {
      return new class extends CDKeyModel {
        async redeem(cdKey: string): Promise<boolean> {/* 计算系统识别码计算结果 */}
      }()
    }
  }
}

构建 ViewModel

ViewModel 对界面行为建模,从需求可以看到,vm 管理 CDKey 的输入状态,并处理提交事件。我更喜欢 ViewModel 通过事件发送一次性通知,比如弹窗通知3秒,这样可以不直接引用UI组件库,实现 vm 的可移植。

ViewModel 要求成员发生变化时,通知 View 层更新界面,这个过程相当于观察者模式,在这个实验用 mobxjs 实现,mobx 是一个强大的状态管理库,我们可以用它来对 ViewModel 建模。

mobx 提供 @observable、@action、@computed 等易用的装饰符。mobx 是 0依赖

  • @observable 被装饰的变量更新时,访问者会被通知
  • @action 被装饰的方法执行后,访问者会被通知
  • @computed 装饰 getter 方法
  • makeObservable(object) 把 object 变成观察源
import { observable, action } from 'mobx'

enum Msg { RedeemOK, RedeemFail }
class CDKeyViewModel {
  constructor (protected model: CDKeyModel) {
    makeObservable(this)
  }

  channel = new EventEmitter<'msg'>()

  @observable
  cdKey = '';

  @computed
  get canSubmit() {
    return !!this.cdKey
  }

  @action
  inputCDKey (cdKey: typeof this.cdKey) {
    this.cdKey = cdKey
  }

  @action
  submit () {
    this.model
      .redeem(this.cdKey)
      .then(() => this.channel.emit('msg', Msg.RedeemOK))
      .catch((err) => this.channel.emit('msg', Msg.RedeemFail))
  }
}

构建 View

这个阶段即是搭建界面,实现 CDKey 兑换界面千变万化,而这里则用最简单的输入框+按钮。

界面层可以用 Vue 实现,也可以用 React 实现,甚至 Flutter,View 有这些好处是因为 业务层model 和 界面控制器层vm 都分开来了,对应地,View 层则强依赖于所用的 UI 平台和组件库。

针对 React 平台

mobx 在 React 提供了观察者连接库 mobx-react-lite,在 React 端可直接使用。

import React from 'react';
import { observer } from 'mobx-react-lite';

const CDKeyView = observer(({ vm }: { vm: CDKeyViewModel }) => {
  React.useEffect(() => { // 处理一次性通知
    const handleMsg = (msg: Msg) => {
      switch (msg) {
        case Msg.RedeemOK:
          return alert("兑换成功")
        case Msg.RedeemFail:
          return alert("兑换失败")
      }
    }
    vm.channel.addListener('msg', handleMsg)
    return () => {
      vm.channel.removeListener('msg', handleMsg)
    }
  }, [vm])
  // 渲染界面
  const { cdKey, canSubmit } = vm
  return (
    <form>
        <label>CDKey:</label>
        <input 
          name="cdkey" type="text" 
          value={cdKey} onChange={e => vm.inputCDKey(e.currentTarget.value)}
        />
        <input 
          name="submit" type="submit" 
          disabled={canSubmit} onClick={() => vm.submit()}
        />
    </form>
  )
})

// 组装 Model、View 和 ViewModel
const CDKeyIndex = () => {
  const model = React.useRef(
    CDKeyModel.Factory.createOnlineVerifier("https://example.org")
  ).current
  const vm = React.useRef(new CDKeyViewModel(model)).current;
  return <CDKeyView vm={vm}>
}

针对 微信小程序平台

对于小程序,mobx 没有直接的组件支持,但可以直接用 mobx 的核心功能把 ViewModel 和 View 连起来。

在自行连接 mobx 对象的情况,必须要在适当的时候绑定观察、解除观察,否则会出现内存泄漏。

mobxjs 提供 autorun(callback): Function 方法,如果 callback 中访问的 mobx 对象有变化,则会触发 callback。返回一个 cancel 方法,可以取消观察。

import { autorun } from 'mobx'

Page({
  data: { cdKey: '' },
  vm: null,
  dispose: null,

  onLoad() { // 页面加载阶段,生成并连接 ViewModel
    const handleMsg = (msg: Msg) => {
      switch (msg) {
        case Msg.RedeemOK:
          return wx.showToast({ title: '兑换成功', icon: 'success', duration: 2000 });
        case Msg.RedeemFail:
          return wx.showToast({ title: '兑换失败', icon: 'error', duration: 2000 });
      }
    }
    this.vm = new CDKeyViewModel(
      CDKeyModel.Factory.createOnlineVerifier("https://example.org")
    )
    const unsubscribe = autorun(() => {
      this.setData({ cdKey: vm.cdKey })
    })
    vm.channel.addListener('msg', handleMsg)
    this.dispose = () => { // 布置解除连接
      unsubscribe()
      vm.channel.removeListener('msg', handleMsg)
      this.vm = null
    }
  }

  onUnload () { // 页面卸载阶段,调用布置好的方法
    this.dispose?.()
  }

  onCDKeyInput (e) {
    this.vm?.inputCDKey(e.detail.value)
  }

  onSubmitTap () {
    this.vm?.submit()
  }
})
<view>
  <view>CDKey:</view>
  <input bindinput="onCDKeyInput" value="{{ cdKey }}" />
  <button bindtap="onSubmitTap">Submit</button>
</view>

其他平台

通过上方2个 View 的例子来看,React 有 mobx-react-lite,而小程序没有连接库一样能手写连接 vm,所以在绝大多数情况下,mobxjs 可以在非常多的平台下实现业务移植,包括 VueReact NativeFlutterElectron,甚至 REPL 环境,etc。这正是我喜爱用 mobx 构建 MVVM 的原因所在。

Vue 版本有连接库 mobx-vue

什么时候使用 MVVM?

这个 CDKey 输入和提交的例子,当然可以直接几行代码解决完,但问题是,很多业务并不会像输个 CDKey 那么简单,有时候还会包括多语言,主题注入。这时候还坚持 KISS 原则,则在重构上会越来越辛苦。

我的总结是,在CRUD场景,直接用 CRUD 整合库代替 MVVM 可以省去大量时间,直到需要重构时,再用 MVVM 构建。而在其他有针对性的业务场景,还是直接 MVVM 构建吧,如果觉得分得太多,也可以把 ViewModel 和 Model 合起来,看这个页面的复杂程度。

我为什么比 Vue 更倾向于 React

我觉得 Vue 的语法绑得太死,虽然 Vue 本身就是 MVVM 的典型代表且支持双向绑定。但是 Vue 的模板渲染机制导致它的编码灵活性没有 React 高。

React 的弹框可以直接用代码实现,并支持不同的 content 展示方式。

showModal({
  title: '提示',
  content: <>
    <p>Alert</p>
  </>
})

Vue 则需要把 modal 侵入到页面代码中,以预编译的模板展示出来,同时占用页面逻辑一个 visible data。当然这是 Vue 比 React 渲染得更快的原因。

<template>
  <!-- 页面布局 -->
  <!-- ... -->

  <modal v-model="visible">
    <modal-title>提示</modal-title>
    <modal-content>
      <p>Alert</p>
    </modal-content>
  </modal>
</template>

附录

[Medium] Level up your React architecture with MVVM

[Medium] Building a React & MobX application with MVVM

[StackOverflow] Model 和 ViewModel 的区别

Model 包含业务代码,而 ViewModel 处理界面的回调,并对数据输入做处理。