引子
在现代前端开发中,MVVM(Model-View-ViewModel)模式已经成为一个重要的设计模式,但不幸的是,许多人对它存在一些误解。这些误解导致了前端项目代码混乱、维护和扩展困难以及性能问题。在本文中,我将深入探讨MVVM模式的本质,以及我认为正确的MVVM理解。我会"手把手"的一步步的教你怎么使用MVVM更好地组织和构建业务代码,并在此过程中详细说明为什么这样做更好,使你完全明白MVVM模式下的业务代码架构。
理解MVVM
在网络上搜索对MVVM的理解,肯定会看到上面的这张图. 但这张图过于抽象了,以至于不同人对这张图的理解存在很大差异。
常见对MVVM的错误理解
对MVVM模式的理解最常犯的一个错误是不理解MVVM模式的本质是一个架构设计模式。 好多人认为数据驱动就是MVVM模式,认为更改数据后页面自动渲染就是MVVM模式,有好多人甚至认为只要实现了双向数据绑定就是MVVM模式。数据驱动和双向数据绑定都是设计模式的一种,是为了解决特定问题而设计的,他们不能是架构设计的模式, 这里有本质的不同。当把他们理解成MVVM模式,并用于架构设计的时候,天然的很难使架构达到良好的可维护性可扩展性。
比如以下两种理解.
错误理解一:认为"View"就是react等前端框架,"Model"对应mobx等状态管理工具,而"ViewModel"则是mobx-react等把状态与组件关系起来的工具。(参考:zhuanlan.zhihu.com/p/35211052)
错误理解二:一个vue组件就代表MVVM模式,组件中的data是model,组件的模板是view,其它的计算属性方法等是viewModel。
很显然这两种理解都把关注点放在了实现双向数据绑定上了。回答不了代码要怎么架构的问题。
我对MVVM的理解
通常对MVVM各层的描述是这样的:
view封装用户界面和任何用户界面逻辑,viewModel封装呈现逻辑和状态,而model封装业务逻辑和数据。view通过数据绑定、函数调用与viewModel进行交互。viewModel观察model将数据转换和汇总以在视图中显示,并协调对模型的更新。
这个描述是对MVVM各层功能的一个描述,它回答了代码逻辑到底应该放到哪层的问题,但是回答不了代码逻辑到底应该放到哪个model或者哪个viewModel的问题,而这个问题也是至关重要的。
我对MVVM模式的理解是:
一个model封装一个业务实体的能力和属性;一个viewModel封装一个业务功能或业务流程;view封装用户界面,要按照view所代表的业务不同而划分view;
我认为用MVVM模式架构代码的核心和出发点不是页面而是业务。每个model或viewModel都只封装自己业务归属的逻辑就能做到良好的划分代码。所以model应该是对一个业务实体的封装,并且一个业务实体只有一个model,而viewmodel是对一个业务功能的封装,一个业务功能只能有一个viewModel。如图所示:
下面详细解释。
model-业务实体
所谓实体即客观存在的具体的或抽象的事物,实体具有属性和能力。比如在我们经常接触的电商业务中商品、店铺和顾客就是具体的事物,订单、评价和地址就是抽象的事物,这些都应该抽象成model。
抽象model的关键点是,一个model只能维护和实现所代表的业务实体的属性和能力,千万不要"多管闲事",去做不属于本业务实体的事情。
比如商品model中就需要有代表商品的各种信息的属性比如名称、图片、介绍、颜色、库存等,还应该有代表自己能力的方法比如增减自己的库存的方法或者修改自己的介绍的方法。商品model中不能有店铺的信息,即使商品详情页要展示店铺的信息,但显然商品的展示方式不是一个商品应该具备的功能。一个实体只抽离一个model,因此我们不能在详情页去创建一个商品model用它去展示店铺信息,不能在定订单页去创建一个商品model用它去展示订单或者购买顾客的信息。这里体现出MVVM模式要以业务而不是页面为根本为出发点去架构代码。
另外还有一个很重要的事情要注意,就是业务实体没有管理同类业务实体的能力。比如一个商品他没有创建另一个商品的能力,没有把自己删除的能力,也没有查看其它商品的能力。所以model中不能抽象model所有代表业务实体的管理功能。
那这些逻辑应该放到哪儿呢?应该是什么业务实体有这些能力,就抽象到哪个业务实体中。比如店铺的老板和员工有管理商品的能力,这些功能就应该抽象到老板的model和员工的model中。这时你可能会发现,明明都是和商品相关的业务却都放到了员工的model中,好像不太合适,代码组织上也不好组织。最好的解决方案是对需要被管理的业务实体,抽象一个manager的model。哪个业务实体有这项能力,就继承这个manager。
viewModel-业务功能或流程
业务功能是与特定业务相关的操作、任务、或者活动。比如在电商业务中购买商品这个活动,包括选择商品,选择商品的颜色大小等型号,下单和支付等过程。这个业务活动就应该抽象成一个viewModel,viewModel在完成功能的过程中应该会调用商品model完成商品信息的展示和型号的选择,调用顾客model完成下单,调用顾客model和订单model用于选择支付方式和支付等。
viewModel也只关心自己业务功能的事件,也不需要知道view层的存在。当实现一个viewModel后,即使没有页面我们也可以只通过调用viewModel的方法实现整个业务功能。
view-用户界面和界面逻辑
用户界面即软件与用户交互的界面,包括页面的布局、信息展示和与用户交互的逻辑。
在MVVM模式下,界面一般分逻辑组件与UI组件,逻辑组件负责UI组件与viewModel的通信,UI组件负责展示信息和交互功能。UI组件也要按照业务划分,千万不要拿过设计稿来,看到一个页面有上中下三部分就直接分成三个组件。
一般一个PC页面比较复杂,一个页面就能完成一个业务功能,所以这个页面就包含一个逻辑组件用于维护一个viewModel和若干UI组件,并负责两者之间的通信,同时它还一般负责页面的布局。而对于好多移动端页面来说,可能完成一个业务流程要多个页面,所以这多个页面就应该在一个逻辑组件中,多个页面中的多个组件通过这个逻辑组件与viewModel通信。
为什么这种架构好
一、超强的可维护性
这种架构做到了代码模块之间最大化的解耦,使代码具备了超强的可维护性。主要表现在以下几方面。
1,代码复用很容易
第一,model的复用。一个软件可能有好多功能好多业务流程,但是这些功能和业务流程必定是围绕着几个业务实体来进行的。在把这些业务实体抽象成model后,每个业务功能都是调这些model的方法而已。model不知道viewModel使得model可以很容易的被复用。而且因为业务实体的功能很明确,在复用model过程中对model的一些更改与增强不怕有什么副作用。
第二,viewModel的复用。一个软件可能支持多端,比如PC端、手机端、pad端等,每个端可能又分h5与客户端。端与端之间的交互或者界面框架是不一样的,但是业务流程肯定不会变。用这种架构每个端只需要实现自己的界面,然后调用统一的viewModel就可以实现功能了。
第三,跨软件复用代码。好多商业软件有客户端与管理后台之分,但两者的功能应该是围绕着相同的业务实体来进行的,所以抽象一遍model后,两端也是可以共用的。
第四,UI组件很容易复用。UI组件大部分按业务实体划分,不与特定业务流程或界面绑定,很容易复用。
2,代码改动很容易
在软件开发中需求变更很常见,而绝大部分变更都是界面或交互的变更,少部分业务流程的变更。对于界面变更,甚至整个重新设计UI,viewModel和model都是不关心的,只需要更改一下view就可以了。对于业务流程的变更,也不用更改model。幸运的是,本架构下的代码大部分逻辑在model层和viewModel层!
3,扩展很容易
软件需要增加功能时,这时只要组合一下几个现有model就可以完成viewModel, 再组合或新写几个UI组件,功能就完成了。如果新功能需要现有业务实体增加新的功能,则给对应model添加功能;如果增加了新的业务实体,就抽象一个新的model;然后继续组合成整个功能。
二、单测容易
开发者可以很容易的为viewModel和model创建单元测试,而不必使用视图。viewModel的单元测试可以完全测试视图所使用的功能。开发者也可以很容易的为view层的UI组件写单元测试。
三、降低开发测试成本,提高效率
由于充分的代码复用,开发者在开发时基本上会越来越快,每开发完一个业务流程就为下一个业务流程的开发积攒了更多可复用的model。开发完一个"端",开发另一个"端"时,就只用实现另一个"端"的view层就好了。测试过程同样如此,如果一个"端"测试完成,另一个"端"的测试将很快完成。
代码组织结构
现在好多人喜欢以文件内代码类型和页面划分目录。比如src下有一个pages或者modules目录装每个页面或者业务流程相关代码,还有几个以文件内代码类型命名的目录:utils,constants,apis,classes,hooks,models,components等装各自类型的供多个页面使用的代码。
这样做问题很大:
第一,很难确定一块代码是否是多页面共用的。比如甲同学在开发a业务时封装了一个组件Component1, 他认为这个组件就这个页面用,于是他把Component1放到了pages/a目录下了。这个时候乙同学在开发b业务时也要用到相似的组件,他可能看了一下components目录下没有相似组件就新开发了一个,也可能通过寻问团队成员知道甲同学开发过Component1并把Component1移到了Components下然后使用,他还可能自己到产品中找到了这个组件然后再慢慢从代码中找到了这个组件。不管哪种情况都增加了开发成本和沟通成本。
第二,很难确定一个小功能在哪个目录中。比如甲同学在开发过程中要处理一下数据再展示,而这个逻辑在产品中多次用到,他认为这肯定被封装成了一个公共的方法。于是他去utils目录中找但是没找到,然后他又去classes中找了一下没找到。于是他在疑惑这么常用的功能怎么没封装公共方法的同时自己写了一个放到了utils中。直到有一天,他发现原来这个逻辑放在了hooks中,或者原来这个逻辑叫这个名字呀...
第三,当公共目录中的文件过多时又涉及到分目录的问题。当项目变大后多页面复用的的代码会很多,每个通用目录中文件会很多。怎么组织这些文件又是一个问题。
我认为一个比较好的代码组织结构是这样的:
1,objects目录
是整个代码结构的核心,应该包含项目的绝大多数代码。 objects目录中每个目录都是一个业务实体相关代码。其中包括一个model.js是当前业务实体的模型, 一个manager.js是当前业务实体的管理者(如果有的话)的模型。有一个components目录只放和当前业务实体相关的组件,如展示实体信息的组件、配置实体的组件、实体某个功能的组件等。utils、apis、constants等也同理,只和当前业务实体相关。
2,businesses目录
businesses目录中每个目录都是一个业务功能。有一个vmodel.js是当前业务功能的viewModel。一个page.js(业务功能包含多页面时应该是代表业务功能的名字),作为本业务功能的逻辑组件。如果实现业务功能需要多个页面,可以加一个components目录,里面放各个页面组件。如果有多个业务实体相关的组件,也可以放到这里,但是尽量不要写这种组件。
3,其它通用代码目录
除以上两个目录外,src下还要有components、utils、constants等几个目录,来放和业务实体完全无关的全项目通用的代码。因为一个项目的绝大部分代码肯定和业务实体有关系,因此这几个目录中的文件应该很少。
这样做,整个代码结构会非常清晰,很容易判断一个文件到底要放到哪个目录下。比如一个选择商品型号的组件肯定是要放到objects/commodity/components下,计算优惠券叠加逻辑的代码也肯定要放到objects/coupon/utils下。写代码用到一个组件或处理逻辑时,我们可以直接定位到它应该在的目录。因为一个业务实体相关的组件或者处理函数不太可能特别多,定位到目录后可以很快找到,不太会因为命名的原因而找不到。如果这个目录中没有对应的代码,就可以确信这个代码不会出现在其它地方,可以放心的在这里添加了。
同时,这样做会让开发同学不自觉的严格按照MVVM模式开发。
实例分析
在读完上面的内容后,你肯定对MVVM模式有了很深的理解。但是有些同学可能还有些怀疑,感觉这种架构有一些理想;有些同学可能感觉上面都是理论,不知道具体怎么用。下面我将以一个假想的产品的开发迭代为例,一步步的设计代码的架构。以使你完全理解MVVM模式。
迭代一:会员功能
以一种大家都非常熟悉的业务举例,你要给一家超市实现会员功能。
需求
需求背景:
1,这是一家中型超市。
2,这家超市目前只有一个简单的收银系统。
3,这个系统有老板,收银员,导购员等角色。
功能需求:
1,顾客可以成为店铺的会员。
2,会员可以充值100,200或者500元。
3,会员来店消费时,充值100的金额可以打95折,充值200的金额可以打9折,充值500的金额可以打85折。
业务流程分析:
1,办会员:系统添加一个办会员的入口,点击后进入办会员页面,输入手机号昵称等信息,点击确定,办理完毕。
2,充值功能:系统添加一个充值入口,点击充值后,输入要充值的会员手机号,选择充值金额,点击确定后下充值单,进入原有支付流程,等支付完成后充值成功。
3,消费功能:在原有的支付页面添加一种会员卡支付方式,点击后输入会员手机号,支付成功。
设计
设计model
显然,我们应该抽象一个会员的model------MemberModel,另外因为涉及到会员的管理(这里是创建)要抽象一个会员管理者model------MemberManagerModel。这里涉及的不同金额充的值要会有不同的折扣,我们可以抽象一个充值卡的model------CardModel(100元卡、200元卡、500元卡),同时抽象一个卡管理者model------CardManagerModel。另外充值过程涉及到下单支付,我们可以抽象一个订单的model------OrderModel,同时抽象一个订单管理者model------OrderMamangerModel。
CardModel:
scss
card
属性
id
balance //余额
discount //折扣
方法
consume(金额)// 消费多少钱
recharge() // 充值
CardManagerModel:
scss
cardManager
属性
cardList[] // 卡列表
方法
listCard(params) // 请求卡列表
createCard(cardInfo, 会员id) // 创建卡
MemberModel 继承cardManagerMoel
scss
member
属性
id
phoneNumber
name
方法
getInfo(id) // 获取会员信息
MemberManagerModel:
scss
memberManager
属性
memberList[] // 卡列表
方法
listMember(params) // 请求卡列表
createMember(memberInfo) // 创建卡
OrderModel:
scss
order
属性
id
type // 充值订单与销售商品不同
status // 状态
info // 订单的销售信息,不同类型订单不同
payInfo //订单支付信息
方法
getStatus(id) // 获取最新状态
getInfo(id) // 获取订单全部信息
OrderManagerModel:
scss
orderManager
属性
orderList[] // 订单列表
方法
listOrder(params) // 请求订单列表
createOrder(info) // 创建订单
设计viewModel
viewModel直接根据业务流程来设计就可以。
1,CreateMember对应办会员流程: 其中维护一个memberManagerModel实例, 并有一个create方法用于调用memberManagerModel的createMember方法完成创建。
2,Recharge对应充值流程: 其中维护一个memberModel实例,一个orderManagerModel实例和一个orderModel实例。
实现一个获取会员信息方法queryMemberInfo, 可接受一个手机号做参数,并调用memberModel的getInfo、listCard方法获取会员的基本信息与会员的卡列表。
再实现一个下单方法,接受充值金额和会员id等信息,调用orderManager的创建订单方法,成功后用返回的订单信息初始化orderModel实例。
最后实现一个充值方法,接受一个金额参数,判断要充值的金额是哪种类型的卡,然后判断会员有无这种类型的卡,如有的话,从卡列表中找出来,初始化成一个cardModel,执行其充值方法。如果没有的话,执行memberModel的createCard方法创建一个卡,把返回的卡信息初始化成一个cardModel,执行其充值方法。
3,Consume对应消费流程,其中维护一个memberManagerModel实例;
实现一个获取会员信息方法queryMemberInfo, 可接受一个手机号做参数,并调用memberModel的getInfo、listCard方法获取会员的基本信息与会员的卡列表。 实现一个消费方法,接受一个金额参数和卡类型参数,从会员的卡列表中找到对应的卡,初始化一个cardModel,执行消费方法。
设计view
ui组件
名称 | 作用 | 说明 |
---|---|---|
MemberInfo | 会员信息展示 | 在objects/member/components中 |
MemberForm | 会员配置表单,包括校验功能 | 在objects/member/components中 |
MemberChoice | 选择或输入会员 | 在objects/member/components中 |
CardInfo | 卡展示 | 在objects/card/components中 |
CardList | 卡列表展示和选择 | 在objects/card/components中 |
MenoySelector | 金额选择 | 在objects/card/components中 |
逻辑组件
1,创建会员组件
主要是MemberForm(会员配置表单)的展示,当表单提交时调用CreateMember的创建方法,成功后用MemberInfo组件展示会员信息,失败后提示。
2,充值流程组件,包括会员选择页面、选择金额页面和支付页面。
会员选择页面使用MemberChoice选择一个会员,然后调用Recharge的queryMemberInfo把会员信息获取过来,进入选择金额页。选择金额页用MemberInfo组件展示会员信息,用MenoySelector组件选择可充值金额,增加一个确认按钮,点击后调用Recharge的下单方法,成功后进入支付页面,通过Recharge的orderModel的getStatus方法等待支付完成,然后调用Recharge的充值方法。(为了简化,这里没有加入支付相关过程)
3,消费流程组件
使用MemberChoice组件选择一个会员,然后调用Consume的queryMemberInfo方法把会员信息获取过来,进入一个核销页面,用MemberInfo组件展示会员信息,用CardList组件展示会员所有的卡,增加一个确定按钮。选择卡后点击确定按钮,调用Consume的消费方法。
至此,一个会员及会员充值功能就做完了,很清晰很easy。
迭代二:会员密码
需求
你做的会员功能上线后,老板很快发现了一个安全问题,就是会员充的值有可能被别人盗用。所以老板让你增加下面的功能:
1,成为会员时,会员可以设置一个密码。
2,消费时,需要会员输入一下密码再消费。
设计
model的更改,只需要给MemberManagerModel增加一下校验会员和密码的方法validatePassword。
viewModel的更改,CreateMember的创建会员方法的参数中增加密码字段;Consume增加一个方法调用validatePassword方法对密码进行校验。
view的更改,MemberForm组件增加密码配置项。增加一个密码输入组件,消费流程组件使用密码输入组件获取会员输入的密码然后调用Consume的密码校验方法对密码进行校验。
迭代三:导购激励
需求
你做的会员功能上线后,增加了用户粘性提高了复购率老板很满意,但是店里的员工对推广会员不太积极。于是老板想让你做如下功能:
1,办会员和会员充值时记录下推荐员工,老板会根据这个进行奖励。
设计
model的更改,增加员工model(继承memberManageModel)及员工管理model。创建会员方法和充值下单方法增加员工id参数。
view的更改,增加员工选择组件。在创建会员表单中增加员工选择表单项。在充值页面增加员工选择组件。
迭代四:客户端小程序
需求
老板发现别的店铺都有一个小程序,会员可以查看自己的卡,也可以自己充值。老板想让你做如下功能:
1,会员打开店铺小程序可以看到自己有哪些卡和余额。
2,会员可以自己用小程序充值。
设计
你发现小程序的绝大部分功能都已经在上面的model与viewModel中实现了,你决定用多包或者git子包的方式在小程序和收银系统上共用上面的model和viewModel文件,并做如下事情:
model层无更改。
viewModel的更改,增加一个会员详情查看viewModel------MemberInfo, 里面维护一个memberModel, 实现getInfo方法调用memberModel的getInfo和listCard方法,把需要的信息请求过来。
view层:实现小程序版的MemberInfo、CardInfo、CardList等组件。实现一个信息展示逻辑组件------MemberInfo,MemberInfo用CardList组件把memberModel中的信息都展示出来。实现一个充值组件,和收银系统的充值逻辑组件几乎一样,也是调用Recharge这个viewModel, 只是开始时没有选择会员的交互、支付时调用微信的支付功能。
总结
通过上面的举例,相信你能明白怎么用MVVM模式设计代码架构了。相信如果再让你设计一个后台来管理会员、卡和订单等信息,你马上就知道通过直接复用上面各个model并开发相应view和viewModel能很快开发出来。
同时,相信你也能深刻的体会到这种模式的好处了。用它开发的代码可扩展性真的非常强,代码复用很非常容易,开发速度会越来越快。
同时,你也可能意识到了,这种开发模式非常依赖于你对业务的理解。上面例子的业务很常见,大家能很快理解。但对一些专业化的业务,作为前端的你可能不是特别理解。所以就要求你在开发中多和产品后端讨论明白业务。还有一种最直接的方法是问后端设计的数据模型,直接按照他们的数据模型设计model大部分时候没错。如果后端不说或回答不了,你还可以参考数据库表结构,排除掉关联表后剩下的一般可以抽象数据模型。
需要注意的问题
1,UI组件能不能用model和viewModel?
这篇文章描述的是MVVM模式作为架构设计模式,并不是说MVVM模式只能做架构设计只能是一个架构设计模式,它可以用于特定功能的实现。所以既然我们封装了这么多model,UI组件当然可以直接使用这些model完成特定功能。
而且单个UI组件也可以分model、view、viewModel三层。但因为单个功能组件大部分时候业务功能不复杂,而且一般以业务进行划分的UI组件只会用到一个model。因此viewModel层一般不复杂,所以经常把viewModel和view放到一起,于是大家可能以为就两层。
2,上面说UI组件可以使用model但是要注意,尽量不要从props中取model使用。
可复用性是判断一个UI组件封装好坏的重要指标,组件用到的model依赖父组件传递后,就要求使用这个组件的人不但要对这个组件很熟悉也必须对其依赖的model很熟悉。对,我使用的是"很熟悉"三个字,如果你不对这两者都很熟悉的话,你根本不知道组件用到了model中的哪些数据,要把model中的哪些数据设置好后传给组件才能使组件正常工作。这直接导致UI组件的可复用性大打折扣,基本很难复用了。
好的做法是,把组件需要使用的数据通过props传给组件,然后组件自己再根据这些数据初始化model。
3,一个model需要有多个其它业务实体的管理者功能时怎么办?
从语义上来说一个model有另一个实体的管理者功能时就继承这个实体的管理者model是自然的。但是我认为在model中保存一个管理者model的实例用于完成对另一个实体的管理功能也是可以的。主要原因有二,一是js原生不支持多继承,二是防止命名冲突。
4,特别重前端的业务可不可以用这种模式?
我认为对于大部分业务逻辑在前端的软件(像是编辑器、网页小游戏等),也适用于MVVM模式架构代码。这种业务中的业务实体一般不够具体,所以需要开发者在抽象和区分业务实体时更加小心,防止出现业务实体的model之间功能出现交叉,或者一个model抽象的其实是多个业务实体等问题。
5,MVVM模式是否强依赖于面向对象编程的方式?
虽然把MVVM各层抽象成类特别好理解且容易进行编码。但我认为MVVM模式是一种架构模式,它不应该限制工程只能用面向对象编程。现在前端的发展方向是函数式编程,我推测用hooks实现MVVM各层,应该是业务实体抽象成各种模型hooks,业务流程也抽象成使用了各种模型hooks的hooks,view层还是分逻辑组件与UI组件并且它们的功能和上面讲的差不多。这种方式显而易见的一个问题是作为viewModel的hooks返回的属性和方法肯定特别多。至于到底能不能这样用,因为我没有实践过还不能确定。
6,MVVM模式会不会对分工产生影响?
因为model和viewModel不依赖于view,用这种架构模式开发时,会不会出现一个新的分工方式,让对业务很熟悉的同学只开发model和viewModel层,让对实现样式和效果很熟悉的同学开发view层并与另两层交互?这种方式会不会提效?我没有实践过,行不行我也不知道,大家可以当做一个有意思的小问题思考一下。
结语
以上就是我对使用MVVM模式进行代码架构的理解,希望对大家有所帮助。因为每个人对代码架构的理解不同,同时我的学识也有限,这篇文章的中的很多内容肯定有很大争议。希望读者能把自己的观点留在评论区,咱们友好交流,共同进步。