浅谈各种MVX架构

在Android、iOS以及Web前后端开发中,总是会接触到MVC、MVP、MVVM等术语,虽然很早以前就查阅了相关的概念并牢记于心(面试专用),但对于其中的设计思想实际上并没有很好的理解。本文尝试记录自己关于各种MV*架构的一点思考。

<!--more-->

参考

1. Model和View

上面提到的各种缩略词,都是MV*,可见MV是这些架构通用的概念

1.1. 什么是Model

对后端而言,要查一个数据,直接在controller里面写sql也可以。但如果这个sql要在10个地方用呢?封装个函数嘛,将sql查询封装在函数内部,函数返回的结果就是需要的数据,这样就可以把这个函数理解成一个model

对前端而言,要查一个数据,需要发起一个请求,可以直接在页面上写http.post(url)。同理,如果很多个地方都需要查这个数据,最简单的方法就是把这个请求封装成函数,这个函数就相当于是提供了某些数据的一个model

对数据的操作同理,也可以全部由model管理。

因此,可以把model简单理解成数据源,它也只负责提供查询数据和操作数据的接口。至于数据是从本地、数据库、还是rpc来的,全都封装在内部,使用方无需关心这个问题。

1.2. 什么是View

view就是用户可以看见并交互的那部分内容,比如一个写着”click me“的按钮、一个有10条数据的文本列表等等,网页、手机App、桌面应用所展示出来的界面都是View。

2. 为什么要将Model和View分离

软件分层是非常有必要的,分层之后,每层结构可以自由扩展和改变,而不会影响其他层。比如计算机网络七层模型架构,yyds。

前端开发其实就是做数据搬运,再展示到视图中。数据与视图是两个不同的概念,为了提高复用性以及可维护性,我们应当根据单一设计原则将二者进行分层处理,所以各种MV*最核心的点都是将数据与视图进行分层。

先从后端和角度的业务场景看一看这个问题。

2.1. 后端角度

后端的View就是静态页面文件

<h1>hello, shymean</h1>

如果需要动态展示用户名

<h1>hello,  
<?php
    $user = "shymean";
    echo $user;
?>
</h1>

看起来稍微有点复杂,如果$user是从数据库或文件或者其他地方获取的,则还需要在页面上实现连接数据库或访问文件等逻辑,

<h1>hello,  
<?php
    // 省略100行代码
    $user = get_username();
    echo $user;
?>
</h1>

这种在view里面写代码的事情,早期php、jsp等都干过,后面可能觉得干不动了,得想个法子处理下。怎么办呢,把逻辑抽出来,先把view的代码减少,最后就变成了在代码里面写页面

<?php
$user = get_username()

echo "<h1>hello, $user</h1>"
?>

既然做到这个地步了,干脆眼不见心不烦,把这堆页面字符串跟代码从物理上分开,用个模板文件保存

// 1.php
function get_index(){
    $user = "shymean"
    return this.render_view('1.html', $user)
}
// 1.html
<h1>hello, {{$user}}</h1>

这样Model和View就完全分离了!

2.2. 前端角度

前端的View是一个可以交互的界面

Model

考虑这样的场景:页面上有一个按钮,点击之后请求数据,然后更新页面内容

类似于下面的代码

<div id="button"></div>
<div id="content">
    <h1></h1>
    <p></p>  
</div>
// 获取到按钮,添加点击事件处理方法fetchData
$("#btn").click(async ()=>{
    const data = await $.ajax(xxx)
    // 更新View
    $("#content h1").html(data.username)
    $("#content p").html(data.slogan)
})

当这种场景变多之后,就会发现这里的逻辑太重了,散落着各种$.ajax$.click,不方便维护,

  • 其他地方可能需要相同的数据请求,会造成重复代码
  • 如果修改了数据源,需要挨个替换

因此需要把这部分逻辑单独抽出来,跟数据处理相关的都放在model中,model负责暴露操作数据的接口即可

const model = {
    getData1(){
        return $.ajax(xxx)
    }
}

$("#btn").on("click", ()=>{
    const data = await model.getData1()

    $("#content h1").html(data.username)
    $("#content p").html(data.slogan)
})

当然,Model的封装不仅限于网络数据,本地存储、业务数据都可以通过类似的方法封装。

View

View是前端关注的核心。

前端最简单的更新View的方式:借助innerHTML,通过拼接字符串的方式更新

var data = await $.ajax('xxx')

var str = '<ul>'
for(var i = 0; i < data.length;++i){
    str += '<li>'+data[i]+'</li>'
}
str += '</ul>'

$(dom).html(str)

当处理的数据变得复杂时,一堆混杂在JavaScript中的字符串看起来就很麻烦,因此又出现了一堆五花八门的前端模板引擎,用来将数据源和模板分离。

列举了几个印象比较深刻的:ejsswigjade

var html = ejs.render(template, data)

innerHTML很直观,但也会带来很多问题

  • 效率问题,浏览器需要重新解析HTML,然后生成新的DOM
  • 新的DOM节点绑定事件丢失问题,因此又产生了”事件委托等“等技术

因此在大部分时候,我们得从View中找到对应的DOM,然后通过DOM接口最小化更新内容

$("#h1").text('xxx)
$("#h2").test('xxx')

即使对Model和View进行了分层,单纯调用View接口来更新View的操作也还是是十分繁琐的,不久之后,页面上就充斥着各种查找选择器的和直接操作DOM节点的代码。后面会提到一些架构是如何解决这个问题的。

2.3. 小结

对Model和View进行分层之后,可以更好地复用各个模块,也更容易测试:

3. Model与View的通信

上面描述了将Model和View分层的必要性,分层是分了,最后还是得在一个应用里面一起工作

  • Model的数据需要展示在View上,
  • View可以接收更新Model数据的交互操作
  • Model的数据变化时需要更新View

所以接下来,就要研究这两层之间如何通信,这也是各种MV*架构要解决的问题。

3.1. MVC

参考

新增的C是Controller控制器,貌似很多人对于MVC都有不同的理解,查到的很多资料描述的内容各有差异。下面是我对于MVC的理解

现在有一个countModel用来提供计数器和相关的操作

const countModel = {
    value:1,
    getValue(){
        return this.value
    },
    add(num){
        this.value += num
    },
    minus(nums){
        this.value -= num
    }
    // .. 其他数据操作
}

将Model数据展示在View上是比较简单的

function render(){
    const val = countModel.getValue()
    content.innerText = val
}

View可以接受用户交互,更新Model中的数据。

由于可能存在多个View都响应用户的同一个交互,因此将处理方法封装成策略函数更合适,这些策略函数由Controller提供

const countController = {
    addCount(){
        countModel.add(1)
    },
    minusCount(){
        countModel.minus(1)
    }
}

btn1.onclick = countController.addCount

策略函数的另外一个好处是可以随时替换View对于用户交互的处理函数,比如

btn1.onclick = countController.minusCount

Model数据变化时需要同时更新View

const countController = {
    addCount(){
        countModel.add(1)
        render()
    }
    // ...
}

Model是可以被多个视图共享的,如果有10个view使用了这个数据,onClick代码里面就充斥着各种更新视图的代码

addCount(){
    countModel.add(1)
    // 更新视图中的按钮
    render()
    render2()
    render3()
    // ...
}

可以让Model数据变化时主动通知View更新,看起来是一个发布-订阅模式

const countModel = {
    views: []
    register(view){
        this.views.push(view)
    },
    notify(){
        this.views.forEach(view=>{
            view.render(this)
        })
    },
}

然后需要改造一下view,使他能够响应countModel的更新通知

const view1 = {
    render(){
        btn1.onclick = countController.addCount

        const count = countModel.getValue()
        content.innerText = val
    }
}

const view2 = {
    render(){
        btn2.onclick = countController.minusCount

        const count = countModel.getValue()
        content2.innerText = val
    }
}

最后在初始化的时候让view订阅countModel


const countController = {
    init(){
        countModel.register(view1)
        countModel.register(view2)
    },
    addCount(){
        countModel.add(1)
        // 通知所有依赖这个数据的地方更新
        countModel.notify()
    },
    minusCount(){
        countModel.minus(1)
        countModel.notify()
    }
}

总结一下,整体流程

其中 实线表示”方法调用“,虚线表示”事件通知“

  • View调用Model的方法获取数据,通过事件注册通知Controller响应用户交互
  • Model可以通过消息通知View更新
  • Controller用来关联Model和View,处理逻辑流程

3.2. MVP

参考

在MVC中,View和Model是有关联的。在实际业务中,一个View可以使用多个Model,一个Model也对应多个View,这种多对多的关系维护起来还是有一定困难的。

因此出现了MVP(其中P表示:Presenter展示器)。MVP中视图和模型是完全解耦的,View和Model对于彼此完全不知情,Presenter用来管理View和Model之间的通信。

我们对上面MVC的代码稍加改动,隔离View和Model

const view1 = {
    render({count}){
        btn1.onclick = countController.addCount
        content.innerText = count
    }
}

const countPresenter = {
    addCount(){
          // 更新model
        countModel.add(1)
          // 获取model数据,重新更新视图
        const data = {
            count: countModel.getValue(),
          otherData: otherModel.getValue()
        }
        view1.render(data)
    },
}

对于Model而言,现在也可以不用再通知View去更新了。

现在的View层只关心展示逻辑和交互事件、Model层只关心数据操作,业务逻辑都放在了Presenter层,这会导致Presenter一般会比较庞大。

这么看起来,感觉后端的MVC架构更像是MVP架构,因为View层对应的HTML和Model层对应的数据库是无法直接通信的,必须借助Controller/Presenter

  • 访问url,被控制器方法接收,在其中获取或更新Model数据,然后通过模板引擎输出HTML文件
  • 在HTML中点击a链接等访问新的url,重复上述步骤

不必拘泥于到底叫MVC还是MVP,主要是看View和Model有没有直接通信。

总结一下MVP的流程

现在变成了

  • View调用Presenter的接口获取展示的数据,通过Presenter提供的事件处理函数响应用户的交互
  • Presenter内部调用Model的接口更新数据,在数据变化时调用View的接口更新视图

在目前流行的前端框架中,React很容易用来作为MVP中的View层,其本身设计非常简单

UI = render(data)

因此,只要在Presenter中获取到data之后,就可以渲染出新的UI,借助虚拟DOM实现高效更新。

3.3. MVVM

参考

MVP中分离和View和Model,但是要在Presenter中处理大量的逻辑,尤其是操作View接口更新视图等工作,是比较繁琐的。

为了解决数据更新到视图的问题,MVVM引入了一个新的ViewModel对象,用来将数据映射到View上。当ViewModel对象的数据发生变化时,就可以自动更新View了。

因此MVVM的核心就是VM对象与View的数据绑定,实现这种绑定的技术有很多种,比如

  • 借助gettersetter实现发布-订阅,在getter时收集依赖的视图,在setter时触发对应视图的更新
  • 借助Proxy等代理,收集依赖并通知更新
  • 脏检查判断数据是否更新,从而决定是否更新视图

如何实现数据绑定并不是架构所关心的内容,需要理解的是VM的抽象,可以极大程度地减少开发中的工作量:不用再手动更新View中的数据了,框架会自动为你解决这个问题。

总结一下

从效果上来看,Vue无疑是满足MVVM架构的

  • data配置项类似于Model
  • render函数类似于View
  • Vue组件实例本身就是数据绑定器,在构造Vue组件实例时,将Model和View进行了绑定,Model变化时会自动调用render函数。

官网上提到Vue并没有完全遵循MVVM架构,大概是因为提供了ref等指令,可以在VM对象中直接操作DOM节点导致的。

3.4. MVI

参考

MVI(I表示 Intent,看起来就是Android四大组件之间通信的那个intent)主要是为了在MVC架构模式的思想上实现响应式编程范式

响应式编程

首先,什么是编程范式?面向对象编程就是一种范式,可以理解成是一种思想。

接着,什么是响应式编程范式?在命令式编程中,比如

a = b + c

当a计算得到结果之后,当b和c重新变化,a的值也不会再改变,除非重新运行这段代码。

而响应式编程就是为了解决这个问题。Excel中的公式可以看成是响应式的,比如某个单元格的值是=B1+C1,在B1和C1这两个单元格发生变化时,就会自动计算新的值。

看起来跟computed计算属性比较像:当依赖的数据发生变化时,会重新计算获得新值。

甚至在某种程度上,也可以认为React实现了响应式编程范式,因为其本质是在data变化时重新运行了render方法,然后得到了新的结果。

单向数据流

查到了很多资料,目前看起来在Android开发中开始了MVI,其声明是从cyclejs中获取了灵感。

正如cycle的名字一样,MVI强调单向数据流,这也是与MVVM数据绑定差别较大的地方。

下面展示了cyclejs示例代码


import {run} from '@cycle/run';
import {makeDOMDriver, div, button} from '@cycle/dom';
import _ from 'lodash';
import xs from 'xstream';

function main (sources) {
  // 监听用户交互,
  const add$ = sources.DOM
    .select('.add')
    .events('click')
    .map(ev => 1);

  // 更新Model
  const count$ = add$.fold((total, change) => total + change, 0);

  // Model变化 更新View
  return {
    DOM: count$.map(count =>
      div('.counter', [
        'Count: ' + count,
        button('.add', 'Add')
      ])
    )
  };
}

const drivers = {
  DOM: makeDOMDriver('.app')
}

run(main, drivers);

其流程大概为

  • 用户操作以Intent的形式通知Model
  • Model基于Intent更新State
  • View接收到State变化刷新UI

跟前端各种状态管理工具redux、vuex的核心思想也是一致的:单向数据流

4. 小结

每种架构都有自己的合适的使用场景,不必纠结与各种架构的名字,也不用生搬硬套各种定义。

比如在前后端分离的今天,后端MVC中的View基本上都看不到了,只能姑且把JSON当做是View层吧。

再比如MVVM定义需要通过ViewModel自动更新View,对于弹窗、toast这种独立的View就很尴尬:首先需要在View中注册一个组件,然后通过绑定数据声明visible:false,在达到某些条件后设置visible:true,最后展示弹窗。


<button onclick="onClick"></button>
<dialog :visible="visible" @close="onClose"></dialog>

<script>
const vm = {
    visible: false
}
function onClick(){
    vm.visible = true 
    // 数据更新后,自动更新视图
}
function onClose(){
    vm.visible = false 
}
</script>

相较于自己在控制器中操作View展示弹窗

function onClick(){
    controller.showDialog()
}

从代码的阅读性来说,后者看起来要直观很多(减少了visible变量的定义),虽然他违背了直接操作View的思想。

各种MV*框架的本质就是让关注点Model和View进行分离,方便程序的维护和扩展,因此我认为

  • Controller用来响应View的事件,同时绑定View和Model
  • Presenter作为View和Model的中间人,避免他们二者直接通信
  • ViewModel也是View和Model的中间人,与Presenter的区别在于ViewModel通过数据绑定避免了在业务逻辑中写大量更新View的代码
  • Intent强调单向数据流,View和Model之间通过Intent流的方式通信

也许MVC框架还会出现变种,也许后面会出现更高级的MVX思想管理Model和View,我们不必过于担心,也不必拘泥于各种定义和细节,如何写出更简洁、更容易维护的代码,才是我们应该思考的问题,至于选择哪种架构,业务场景会告诉我们答案的。

富文本编辑器Quill源码分析关于TailwindCSS的一些思考