浅谈各种MVX架构
在Android、iOS以及Web前后端开发中,总是会接触到MVC、MVP、MVVM等术语,虽然很早以前就查阅了相关的概念并牢记于心(面试专用),但对于其中的设计思想实际上并没有很好的理解。本文尝试记录自己关于各种MV*架构的一点思考。
参考
- 谈谈UI架构设计的演化 - winter
- http://blog.hudongdong.com/ios/459.html/comment-page-1
- 浅谈 MVC、MVP 和 MVVM 架构模式,写的很好,建议阅读
- 《前端MVC变形记》
- 关于Android架构,你是否还在生搬硬套?
Model和View
上面提到的各种缩略词,都是MV*
,可见M
、V
是这些架构通用的概念
什么是Model
对后端而言,要查一个数据,直接在controller里面写sql也可以。但如果这个sql要在10个地方用呢?封装个函数嘛,将sql查询封装在函数内部,函数返回的结果就是需要的数据,这样就可以把这个函数理解成一个model
对前端而言,要查一个数据,需要发起一个请求,可以直接在页面上写http.post(url)
。同理,如果很多个地方都需要查这个数据,最简单的方法就是把这个请求封装成函数,这个函数就相当于是提供了某些数据的一个model
对数据的操作同理,也可以全部由model管理。
因此,可以把model简单理解成数据源,它也只负责提供查询数据和操作数据的接口。至于数据是从本地、数据库、还是rpc来的,全都封装在内部,使用方无需关心这个问题。
什么是View
view就是用户可以看见并交互的那部分内容,比如一个写着”click me“的按钮、一个有10条数据的文本列表等等,网页、手机App、桌面应用所展示出来的界面都是View。
为什么要将Model和View分离
软件分层是非常有必要的,分层之后,每层结构可以自由扩展和改变,而不会影响其他层。比如计算机网络七层模型架构,yyds。
前端开发其实就是做数据搬运,再展示到视图中。数据与视图是两个不同的概念,为了提高复用性以及可维护性,我们应当根据单一设计原则将二者进行分层处理,所以各种MV*最核心的点都是将数据与视图进行分层。
先从后端和角度的业务场景看一看这个问题。
后端角度
后端的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就完全分离了!
前端角度
前端的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中的字符串看起来就很麻烦,因此又出现了一堆五花八门的前端模板引擎,用来将数据源和模板分离。
var html = ejs.render(template, data)
innerHTML
很直观,但也会带来很多问题
- 效率问题,浏览器需要重新解析HTML,然后生成新的DOM
- 新的DOM节点绑定事件丢失问题,因此又产生了”事件委托等“等技术
因此在大部分时候,我们得从View中找到对应的DOM,然后通过DOM接口最小化更新内容
$("#h1").text('xxx)
$("#h2").test('xxx')
即使对Model和View进行了分层,单纯调用View接口来更新View的操作也还是是十分繁琐的,不久之后,页面上就充斥着各种查找选择器的和直接操作DOM节点的代码。后面会提到一些架构是如何解决这个问题的。
小结
对Model和View进行分层之后,可以更好地复用各个模块,也更容易测试:
Model与View的通信
上面描述了将Model和View分层的必要性,分层是分了,最后还是得在一个应用里面一起工作
- Model的数据需要展示在View上,
- View可以接收更新Model数据的交互操作
- Model的数据变化时需要更新View
所以接下来,就要研究这两层之间如何通信,这也是各种MV*
架构要解决的问题。
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,处理逻辑流程
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实现高效更新。
MVVM
参考
MVP中分离和View和Model,但是要在Presenter中处理大量的逻辑,尤其是操作View接口更新视图等工作,是比较繁琐的。
为了解决数据更新到视图的问题,MVVM引入了一个新的ViewModel对象,用来将数据映射到View上。当ViewModel对象的数据发生变化时,就可以自动更新View了。
因此MVVM的核心就是VM对象与View的数据绑定,实现这种绑定的技术有很多种,比如
- 借助
getter
、setter
实现发布-订阅,在getter时收集依赖的视图,在setter时触发对应视图的更新 - 借助
Proxy
等代理,收集依赖并通知更新 - 脏检查判断数据是否更新,从而决定是否更新视图
如何实现数据绑定并不是架构所关心的内容,需要理解的是VM的抽象,可以极大程度地减少开发中的工作量:不用再手动更新View中的数据了,框架会自动为你解决这个问题。
总结一下
从效果上来看,Vue无疑是满足MVVM架构的
- data配置项类似于Model
- render函数类似于View
- Vue组件实例本身就是数据绑定器,在构造Vue组件实例时,将Model和View进行了绑定,Model变化时会自动调用render函数。
官网上提到Vue并没有完全遵循MVVM架构,大概是因为提供了ref
等指令,可以在VM对象中直接操作DOM节点导致的。
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的核心思想也是一致的:单向数据流
小结
每种架构都有自己的合适的使用场景,不必纠结与各种架构的名字,也不用生搬硬套各种定义。
比如在前后端分离的今天,后端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,我们不必过于担心,也不必拘泥于各种定义和细节,如何写出更简洁、更容易维护的代码,才是我们应该思考的问题,至于选择哪种架构,业务场景会告诉我们答案的。
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。