@gyyin
2020-01-11T16:05:41.000000Z
字数 5511
阅读 662
慕课专栏
前面我们讲了 JavaScript 面向对象编程,这篇文章我们会介绍一下面向对象中的经典编程模式 —— MVC。
MVC、MVVM、MVP 这三个概念在前端领域是老生常谈了,但这节课不会只在概念层面讲述三者的区别,而是更偏向实践、从编写业务代码层面讨论一下 MVC 模式在前端开发中的意思。
在Angualr、Vue等MVVM框架出现前,最火的前端框架当属 Backbone,这是一个典型的 MVC 框架。也许你会说 Backbone 不是过时了吗?那还在前端中讨论 MVC 还有什么意义?
Backbone 的确过时了,但是过时是因为 Backbone 的 MVC 实现方式过时了,并非是 MVC 思想过时了。如果以 Vue/React 作为 View 层,Vuex/Redux 作为 Model 层,那么就可以实现新的 MVC 框架。
MVC 分层有助于管理复杂的应用程序,因为你可以在一个时间内专门关注一个方面。例如,你可以在不依赖业务逻辑的情况下专注于视图设计。同时也让应用程序的测试更加容易。
MVC 是一种架构模式,最早由施乐的 Trygve Reenskaug 在1978年提出,原本是为了给程序语言 Smalltalk 提供架构,大大提高了程序的后期可维护性。
那么什么 MVC 呢?相信大家都对 MVC 比较熟悉了,不管是 Java 中的 spring mvc,还是前端里面的 Backbone,这些都是应用很广泛的 MVC 框架。
我这里引用一下维基百科的解释:
MVC 模式(Model–view–controller)是软件工程中的一种软件架构模式,把软件系统分为三个基本部分:模型(Model)、视图(View)和控制器(Controller)。
MVC 模式最早由 Trygve Reenskaug 在 1978 年提出,是施乐帕罗奥多研究中心(Xerox PARC)在 20 世纪 80 年代为程序语言 Smalltalk 发明的一种软件架构。MVC 模式的目的是实现一种动态的程序设计,使后续对程序的修改和扩展简化,并且使程序某一部分的重复利用成为可能。除此之外,此模式通过对复杂度的简化,使程序结构更加直观。软件系统通过对自身基本部分分离的同时也赋予了各个基本部分应有的功能。专业人员可以通过自身的专长分组:
控制器(Controller):负责转发请求,对请求进行处理。
视图(View):界面设计人员进行图形界面设计。
模型(Model):程序员编写程序应有的功能(实现算法等等)、数据库专家进行数据管理和数据库设计 (可以实现具体的功能)。
因此,我们可以知道 MVC 是由 Controller、Model和View 三部分组成,这三部分各司其职。
对于 MVC,业界有不同的定义,比如就有微软的版本、苹果的版本等等。
苹果版本的 MVC:

整理一下苹果版本的 MVC,是这样的:
这个版本的 MVC 模式比较符合我们前端开发的直觉,因此本文也以苹果的版本来讲解。
微软的版本(图片来自微软 ASP.NET core 官网):

微软的版本以 ASP.NET core MVC 为实现,可以这样理解:
可以看得出来,微软版本的 MVC 比较难理解,因此这里不做太多介绍。
阮一峰的版本:

阮一峰版本的 MVC 可以这样理解:
1. 用户和应用交互
2. 控制器的事件处理器被触发
3. 控制器从模型中请求数据,并将其交给视图
4. 视图将数据呈现给用户
这里我们使用原生 JS 来实现一个 MVC,我们以一个 todoList 为例子,将会用到 jQuery 和 Underscore 两个库,在业务代码中也可以使用这样的形式来组织项目。
首先,对于项目来说,我们应该以 app.js 为入口,这是一个业务模块。
Model.js 对应 Model,这里定义了应用的数据模型,只做提供数据存储和修改。
这里还实现了一个发布-订阅功能,在 register 中保存待渲染的视图,在 notify 中通知所有相关视图重新渲染。
app.Model = class Model {constructor(todos = []) {this.todos = todos;this.views = [];}findTodoById(id) {// 根据id查找}getAll() {return this.todos;}addTodo(todo) {this.todos.push(todo);}updateTodo(id) {// 更新todo信息}removeTodoById(id) {// 根据id删除}getActiveTodo() {// 拿到未完成的}getCompletedTodo() {// 拿到已完成的}toggleStatusById(id) {}// 这里可以对view做一个注册register(view) {this.views.push(view);}// 对所有监听的view进行通知notify() {for(var i = 0; i < views.length; i++) {this.views[i].render(this);}}}
View.js 对应 View 层,提供数据进行视图渲染,一般是 html 模板文件,也可以是 React/View 等现代化 UI 框架。这里我们以 underscore template 来举例子,希望大家提前了解一下 underscore template 的语法。
// 这是underscore的模板语法<script type="text/template" id="tpl"><ul id='todoList'><% for (var i = 0; i < model.todos.length; i++) {%><li data-id='<%=model.todos[i].id%>' class='<% model.todos[i].isActive ? 'active' : '''%><%=model.todos[i].name%></li><% } %></ul></script>
// 在render方法中对模板进行重新渲染app.View = class View {render(model) {const template = _.template($('#tpl').html(), model)$('body').html(template);}}
Controller.js 对应 Controller,主要是 Model 和 View 之间的纽带,提供事件绑定以及提醒视图渲染等功能。
在 events 属性中,我们将需要绑定的 DOM 元素、事件以及回调函数用键值对的形式进行映射,在 delegateEvents 中解析后进行事件绑定。这样的好处就是可以很清晰地看到项目中所有的绑定事件,后期维护起来更容易。
const events = {"click li": "toggle"}// 解析上面的 events 属性const delegateEvents() {// 正则表达式分组捕获var eventSplitter = /^(\w+)\s*(.*)$/;for (var key in events) {var methodName = events[key];var match = key.match(eventSplitter);// 解析出来的事件名和绑定的 DOMvar eventName = match[1],selector = match[2];// 如果没有要绑定的 DOM,那么绑定在根节点上if (selector === '') {el.bind(eventName, methodName);} else {el.delegate(selector, eventName, methodName);}}
同时,在初始化以及每次操作之后,我们可以选择手动通知(执行 notify 方法)相关视图进行渲染。
最终的代码实现如下:
app.Controller = class Controller {events = {"click li": "toggle"}constructor(el) {this.el = el;}init(view, model) {this.view = view;this.model = model;this.model.register(view);this.model.notify();this.delegateEvents();}// 解析上面的 events 属性delegateEvents() {var eventSplitter: /^(\w+)\s*(.*)$/;for (var key in this.events) {var methodName = this.events[key];var match = key.match(eventSplitter);var eventName = match[1], selector = match[2];if (selector === '') {this.el.bind(eventName, method);} else {this.el.delegate(selector, eventName, method);}}toggle(event) {const id = event.dataset.id;this.view.toggleStatusById(id);this.model.notify();}}// app.jsnew TodoController('#todoList').init(new app.View()), new app.Model());
这样我们就实现了一个完整的 MVC 骨架。这种形式可以适用于很多项目,甚至不需要依赖框架,后续还可以将 underscore template 替换成 React/Vue 等框架,增加了项目的可维护性,层次结构更加清晰。
如果遇到更复杂的业务逻辑,上面的 MVC 分层是远远不够的,部分逻辑可以从 Controller 和 Model 层中剥离出来,也许你还需要下面的这些分层。
Service 层一般是封装了和请求数据相关的操作,比如 Ajax 请求、localStorage、indexDB,甚至是 JS Bridge等等。
比如向后端请求数据我们可以这么来写:
class Service {async getList() {const {data = {}} = await http({method: 'post',url: '/getList',data: {}});return data.list;}}
在需要用到这个接口的地方(一般是 Controller 层)导入对应的 Service 文件,将接口请求的操作放到 Service 中统一处理,方便后期维护。
在将数据请求回来后,往往会出现接口的数据和前端要展示的数据结构不一致的情况。这个时候,从 api -> model 就需要一层额外的转化,这就是 format 层存在的意义。
理论上,format 函数全都应该是纯函数,接收从接口获取的数据,返回需要传给 model 的数据。
下面举个例子,这是一个将接口返回的时间戳转换为 YYYY-MM-DD 格式日期的 format 函数。
const formatDate = (datespan) => {let date = new Date(datespan),year = date.getFullYear(),month = date.getMonth() ,day = date.getDate();const addPrefix = (num) => {return num > 9 ? num : `0${num}`;}month = addPrefix(month + 1);day = addPrefix(day);return `${year}-${month}-${day}`}formatDate(1572781090844); // "2019-11-03"
除了数据请求和格式化之外,往往项目中也会出现一些共用的工具函数,比如日期格式化、埋点、DOM 相关的方法等等。
因此,一个完整的项目目录结构应当是这样的(加号代表文件夹,减号代表文件):
+ project+ pages+ home+ css- index.scss- model.js- view.js- controller.js- app.js+ components+ share+ utils+ service+ formatter
service 到底是放到每个页面下面维护还是放到全局维护,这个主要看你的接口是否会经常在多个页面使用,这样的话更推荐你放到全局。
最后,一个完整的流程应当是这样,Controller 层调用 Service 层的方法去获取数据,将拿到的数据传给 format 和 utils 等函数进行转换,最后将转换的数据存入到 Model 中。

虽历经几十年的洗礼,MVC 架构依然没有过时,在各种语言中我们也经常能看到对应的 MVC 框架。
在前端开发中,我们也可以借鉴 MVC 的思想来对项目进行重新组织和解耦,这样可以大大提高项目的灵活性。