MVC 模式
MVC 是 UI 编程领域中非常经典的设计模式,它通过将应用分离出 Model / View / Controller 三个模块,使得开发者能够借助该模式,构建出更易于扩展和维护的应用程序。
在本节中,我们将基于经典的 MVC 理念,实现一个 50 行内的极简 MVC 框架,用于支持一个 Todo App 示例应用的开发。在这个实现过程中,可以理解该模式在提升可维护性和可扩展性上所做出的努力。
简介与动机
MVC 模式在概念上,强调 Model / View / Controller 三个模块的分离:
- Model: 专注数据的存取,对基础数据对象的封装。
- Controller: 处理形如【用户登录】、【验证表单】等具体操作时的业务逻辑,从 Model 中存取数据,并渲染到 View 中。
- View: 负责界面视图,可理解为【输入数据,输出界面】的模块,在其中通常不涉及的业务逻辑。
需要注意的是,MVC 仅是一种模式理念,而非具体的规范。因此,根据 MVC 的理念所设计出的框架,在实现和使用上可能存在着较大的区别。下文中介绍的框架,仅是作者作为示例给出的一种实现,供读者参考。
那么,为什么需要 MVC 这一框架级的设计模式呢?相信许多前端开发者都经历过纯粹基于 jQuery / Zepto 等基础类库开发前端项目的阶段。在这种情境下,完全不需要考虑 MVC 的概念,对业务逻辑的实现是非常简单而直接的。以实现【点击某按钮时,从后台接口获取数据,然后渲染到页面】这样非常常见的需求为例,典型的业务代码实现形如:
// 点击某按钮时
$('.xx-btn').click(() => {
// 从后台接口获取数据
$.get('/xx-api', (data) => {
// 拼接模板
const template = `<div>${data}</div>`
// 渲染到页面上
$('#xx-table').html(template)
})
})
基于这些便捷的 API,确实能在寥寥数行之内实现业务需求。这时,存在的问题包括:
- 处理【用户输入】与【数据渲染】的逻辑,以硬编码的形式结合在了一起,一般项目中数据渲染的代码多直接书写在匿名回调函数内,难以抽离复用(尤其是在回调函数内使用了
this
时,更难复用相应的渲染代码)。 - 在没有 ES6 的时代,字符串拼接的代码十分难以维护,各种形如
'<div class="' + data.class + '">'
这样引号交错的代码虽然容易符合初学者的直觉写出,但显然难以阅读和维护。 - 将数据直接映射到页面 DOM 上的操作同样很符合初学者的直觉,但也造成应用数据模型的缺乏。在页面【存在多种可能的输入,而每种输入所对应的输出均可直接修改 DOM 结构】时,会带来陡增的状态复杂性。
虽然 MVC 不是银弹,但其出现确实提供了一种解决上述问题的思路。下文中将从一个极简的 MVC 框架所需要具备的特性出发,一步步介绍我们解决上述问题的思路。
框架特性
与许多设计模式一样,MVC 在概念定义上较为抽象,通过代码实现掌握它,显然比模糊的文字描述要更为有效。下面作为示例,我们将一步步实现这个名为 NanoMVC 的框架。
在开始编写具体代码之前,需要在设计阶段明确的是,这个框架将提供何种功能,使用者该如何使用。基于使用情景的需求,给出一些简单的示例代码,最后再从满足这些示例的目标出发,进行实际的编码实现。在【先明确需求,再进行编码实现】的这一点上,框架开发与业务逻辑开发是很接近的,这样目标驱动的开发模式也有利于保持开发流程的可控,而非像实现简单业务需求那样随心所欲地【想到什么写什么】,以至于最后产出缺乏架构支持而不易维护的代码。
那么,NanoMVC 框架的使用情景是什么呢?常见的后端框架所封装的功能,不外乎对数据的增查改删与渲染。在前端,我们以一个非常简单的 Todo App 作为框架所需要支持的业务开发场景,这时基于框架的业务代码,所需要实现的功能包括:
- TodoModel 模块实现 Todo 这一数据模型的存取。
- TodoView 模块实现将 Todo 数据模型渲染到页面。
- TodoController 模块实现对 Todo 数据的新增、编辑、删除等操作。
在用于实现 Todo 业务逻辑的 Model 和 Controller 模块,除了具备框架提供的若干功能外,还需要在其中编写特定的业务代码。这时,面向对象的编程范式就能够发挥作用了:通过形如 TodoController 的业务代码【继承】框架所提供的 Controller 基类,在其中即可调用框架所封装的功能。这也就意味着,该框架的功能是需要支持通过继承来使用的。
使用示例
在【支持 MVC 三个模块】与【支持通过继承使用】的两个目标确定后,即可以框架使用者的身份,给出调用框架的基础示例代码:
// app/index.js
// Todo App 的业务代码入口
// 该示例为实际业务代码,并非框架代码
// 依次导入 MVC 的三个模块
import { TodoModel } from './model'
import { TodoController } from './controller'
import { TodoView as view } from './view'
// 实例化各模块
// 此处 View 的实现由于过于简单而不需实例化
const model = new TodoModel()
const controller = new TodoController(model, view)
// 通过手动调用 render 方法,初始化页面
// 在 controller 中 render 值得商榷
// 但其不失为一种很方便的实现
controller.render()
在这个应用的入口模块中,仅仅明确了 MVC 模块拆分的基本结构,而并没有给出各业务模块的基本实现示例。下面给出以框架使用者的角度,在利用框架实现 TodoModel 模块时的基本示例代码:
// model.js
// 该示例为实际业务代码,并非框架代码
// 从框架中导入 Model 基类
import { Model } from 'framework'
// 通过继承实现自己的业务 Model
export class TodoModel extends Model {
// 在构造器中定义数据模型的基本结构
// 在此即为 Todo 列表
constructor () {
super({ todos: [] })
}
// 利用 ES6 class 语法定义模型实例的 getter
// 从而在调用 model.todos 时返回正确的 Todo 数据
get todos () {
return this.data.todos
}
// 利用 ES6 class 语法定义模型实例的 setter
// 从而在执行形如 modle.todos = newTodos 的赋值时
// 能够通知订阅了 Model 的模块进行相应更新
set todos (todos) {
this.data.todos = todos
this.publish(todos)
}
}
虽然 Model 模块的有效代码仅有十余行,但其实现中已体现了使用框架的基本方式:
- 导入框架提供的基类。
- 继承基类,根据实际业务需求定制出相应的模块实例。
与之类似地,实现 TodoController 模块的业务代码,也按照类似的思路进行组织:
// controller.js
// 该示例为实际业务代码,并非框架代码
import { Controller } from 'framework'
// 同样基于继承实现业务特定的 Controller
export class TodoController extends Controller {
constructor (model, view) {
// 在此提供实例化 Controller 时所需的参数
// 包括其对应的 Model 与 View,及相应的业务逻辑功能
super({
model,
view,
el: '#app',
// 提供点击页面特定按钮时的处理逻辑
onClick: {
// 点击【新增】按钮时,新增 Todo
'.btn-add' () {
// this.model.todos = ...
},
// 点击【删除】按钮时,删除相应的 Todo
'.btn-delete' (e) {
// this.model.todos = ...
},
// 点击【更新】按钮时,更新相应的 Todo
'.btn-update' (e) {
// 根据 id 更新元素
// this.model.todos = ...
}
}
})
// 订阅 Model 更新事件
// 在 Model 更新时执行 controller 的 render 方法
this.model.subscribers.push(this.render)
}
}
在实现了这个 Controller 后,基本的业务逻辑流程已经初见端倪了:
- Controller 根据 onClick 参数中提供的字段,声明式地定义形如【当 class 名称包含
btn-add
的按钮点击时】所触发的业务逻辑,从而避免$(...)
绑定点击事件的底层操作。 - 在点击事件触发时,不去直接修改 DOM,而是更新
this.model
中的数据。这样,就实现了数据模型与页面视图的分离解耦。 - 还记得
model.js
中实现的this.publish(todos)
吗?在上一步中数据更新触发数据模型的 setter 时,Model 将通知【数据变更】的事件至模型的所有订阅者。 - 在 Controller 的最后一行有效代码中,它订阅了数据模型的更新事件。故而在数据更新时,将触发 render 方法以重绘 DOM。
在这个流程中,不仅包含了对业务逻辑相关事件的配置式 / 声明式定义,还实现了发布 - 订阅这一设计模式,该模式也广泛应用在 MVC 框架设计中。而在 Controller 中业务逻辑成功更新后,最后需要的就是更新 View 了。相应的 TodoView 模块示例如下:
// view.js
// 该模块是一个【输入数据,输出 HTML 模板】的纯函数
// 故而在业务代码中即可实现其全部功能,不需要继承
export function TodoView ({ todos }) {
// 根据 Todo 数组数据
// 通过 map 得到带【编辑】与【删除】按钮的单条 Todo 页面模板
const todosList = todos.map(todo => `
<div>
<span>${todo.text}</span>
<button data-id="${todo.id}" class="btn-delete">
Delete
</button>
<span>
<input data-id="${todo.id}"/>
<button data-id="${todo.id}" class="btn-update">
Update
</button>
</span>
</div>
`).join('')
// 通过 ES6 模板字符串实现类似 JSX 格式的 View 输出
return (`
<main>
<input class="input-add"/>
<button class="btn-add">Add</button>
<div>${todosList}</div>
</main>
`)
}
到此为止,我们已经勾画出业务代码的全貌了。接下来需要的就是实现 NanoMVC 这一框架,从而让这些代码能够在框架的支持下正常运行。
编码实现
在给定了设计目标以及示例功能后,框架的开发并没有想象中复杂。根据上文中编写示例代码时的梳理,我们的 NanoMVC 框架中需实现的功能包括:
- Model 基类中实现对模型数据的发布 - 订阅设计模式。
- Controller 基类中实现:
- 与 Model / View 实例的绑定。
- 对点击事件、DOM 选择等底层 API 的封装。
- 用于渲染数据的 Render 方法。
Model 模块
由于 Model 中功能最为简单,且其为 Controller 实例的依赖,故而我们首先实现其中的发布 - 订阅模式:
// framework/index.js
// 对外暴露的 Model 基类
export class Model {
// 在构造器中实例化数据与订阅者
constructor (data) {
this.data = data
this.subscribers = []
}
// 由 TodoModel 实例调用的发布方法
// 在 TodoModel 中的 setter 更新时,将新数据传入该方法
// 由该方法将新数据推送到每个订阅者提供的回调中
// 在 Todo 项目中,订阅者为 TodoController 的 render 方法
publish (data) {
this.subscribers.forEach(callback => callback(data))
}
}
在示例中可以发现,所谓的发布 - 订阅模式,其思路和实现均非常简单:
- 区分出【发布者】和【订阅者】的概念。本例中 Model 为发布者,Controller 为订阅者。
- 在发布者中维护【我有哪些订阅者】信息的数组,每个元素为一个订阅者提供的回调。
- 发布者数据更新时,依次触发所有订阅者的回调。
不过,Model 中的代码仅实现了【初始化发布者】与【触发所有订阅】的功能,并不是一个完整的发布 - 订阅模式。在完整的模式实现中,其余代码包括:
- 【订阅者订阅发布者】机制的实现,其代码位置为
controller.js
中的最后一行this.model.subscribers.push(this.render)
,在此将 render 方法作为订阅者回调,提供给了发布者。 - 【数据更新时,触发订阅回调】机制的实现,其代码位置为
model.js
中set todos (todos)
内的一行this.publish(todos)
。在此,通过 ES6 中 Class 语法的 setter 机制,在模型数据更新时,调用 Model 基类提供的 publish 方法,触发订阅。 - 【订阅者提供的订阅方法】的实现,在此即为 TodoController 中提供的
this.render
方法。该方法并未在 TodoController 的业务代码中实现,而是在接下来的框架 Controller 基类中实现的。
Controller 模块
上文中已经明确,框架提供的 Controller 基类需要实现的功能为:
- 与 Model / View 实例的绑定。
- 对点击事件、DOM 选择等底层 API 的封装。
- 用于渲染数据的 Render 方法。
以下是相应的实现,去除注释后仅在 20 行的数量级:
// framework/index.js
// 对外暴露的 Controller 基类
export class Controller {
constructor (conf) {
// 根据子类提供的实例化参数,定义 Controller 基础配置
// 包括 DOM 容器、Model / View 实例及 onClick 事件等
this.el = document.querySelector(conf.el)
this.model = conf.model
this.view = conf.view
// 常见于 React 开发中的 bind
// 解决方法在 extend 出的实例中执行时的 this 指向问题
this.render = this.render.bind(this)
// 根据 TodoController 中提供的 onClick 配置规则执行业务逻辑
// 例如 TodoController 若在 onClick 属性中提供了名为 .btn-add 的函数
// 则在 Controller 对应 DOM 中点击事件触发时
// 事件 path 的根元素若带有匹配 btn-add 的 className
// 即执行 TodoController 提供的 .btn-add 函数
this.el.addEventListener('click', (e) => {
e.stopPropagation()
const rules = Object.keys(conf.onClick || {})
rules.forEach((rule) => {
// 匹配事件根元素,并据此执行业务 Controller 实例提供的业务逻辑
if (e.path[0].matches(rule)) conf.onClick[rule].call(this, e)
})
})
}
// 供业务 Controller 实例使用的 API 方法
// 从而避免在业务代码中直接操作 DOM
// 首先是事件对象,返回事件根元素的特定属性
getTargetAttr (e, attr) {
return e.path[0].getAttribute(attr)
}
// 同样供业务 Controller 实例使用的 API 方法
// 提供在 Controller 对应 DOM 内查找元素的 API
getChild (selector) {
return this.el.querySelector(selector)
}
// 全量重置 DOM 的 naive render 实现
render () {
// 由于 view 是纯函数,故而直接对其传入 Model 数据
// 将输出的 HTML 模板作为 Controller DOM 内的新状态
this.el.innerHTML = this.view(this.model)
}
}
基于这个 Controller 基类的实现,可以完善出前文中 TodoController 剩余的若干业务代码如下:
// controller.js
// ...
onClick: {
'.btn-add' () {
// 新增 Todo 时操作
// 由于简单的 setter 监测不到 push 方法的改动
// 故而对 Todos 数据全量赋值
this.model.todos = this.model.todos.concat([{
id: new Date().getTime().toString(),
// 使用 getter 获取【添加 Todo】输入框中的数据
text: this.addInputText
}])
},
// 根据 id 过滤掉待删除元素
'.btn-delete' (e) {
const id = this.getTargetAttr(e, 'data-id')
this.model.todos = this.model.todos.filter(
todo => todo.id !== id
)
},
// 根据 id 查找出待更新 Todo 文本并更新
'.btn-update' (e) {
const id = this.getTargetAttr(e, 'data-id')
const text = this.getUpdateText(id)
// 通过 map 全量对新 Todos 列表赋值
this.model.todos = this.model.todos.map(
todo => ({
id: todo.id,
text: todo.id === id ? text : todo.text
})
)
}
}
// ...
新增的 TodoController 实例方法如下:
// 调用框架 API,根据当前编辑 Todo 的 ID,获取其文本
getUpdateText (id) {
return super.getChild(`input[data-id="${id}"]`).value
}
// 获取【添加 Todo】输入框中的数据
get addInputText () {
return super.getChild('.input-add').value
}
最后实现的业务代码中,只需在名为形如 .btn-add
的函数中调用框架封装好的方法,就能实现对 DOM 的查找及对数据的增查改删,不再需要显式地查找与修改 DOM 了。
View 模块
如前文所述,框架的 View 实质上就是一个在 Model 中数据变更时,由 Controller 在 render 方法中执行的一个纯函数。由于 View 过于简单,故而在框架代码的实现中,没有涉及 View 这一模块。
总结
在实现 MVC 模式框架的过程中,框架的【模块拆分】与【功能封装】特性均得到了体现,而 ES6 所提供的 class 高级特性则大大简化这些特性的实现复杂度。例如,如果没有 setter 语法糖,那么实现发布 - 订阅模式中最关键的【在数据更新时执行代码】特性时,要么需要额外封装出框架的 get 和 set API,要么需要采用 Object.defineProperty
这样较为 Hack 的手段。
有趣的一点是,从实现框架过程中提供的示例代码来看,框架代码虽少,但其注释量显著多于业务代码。在许多成熟的开源项目中,注释所占比重也相当大。造成这种情况的原因,除了框架层开发者的编码习惯较好外,还有一个潜在的原因:框架开发中,提供的代码多是【供用户调用】的 API 代码,在没有用户提供代码的情况下,整个框架的执行流程可能是无法走通的(例如整个发布 - 订阅模式的实现代码,就分散在了各个模块中)。这时,对于许多执行流程不连续且缺乏调用者信息的代码片段,就需要提供传统的参数类型信息外,更丰富的【代码调用场景、作用场景、Hack 理由】等信息,来保证框架代码的可维护性。从这个角度上来说,框架级的开发也有助于培养开发者的编码习惯。
当然,这个框架毕竟只是一个简单 Demo,与真实世界中的框架相比,还存在许多问题:
- 不支持父子组件嵌套的机制。
- 基于 innerHTML 的渲染性能低下。
- Controller 与 View 是一对一的关系,且直接将 Model 传入 View 函数来获取模板,不够灵活。
- 不支持为特定元素单独设置事件监听器,只能拦截所有事件到 Controller DOM 顶层处理。
- 只支持通过对 Model 的全量赋值来触发发布 - 订阅机制,使得 TodoController 中的增查改删业务代码虽然剥离的 DOM,但仍然较为臃肿。
这些存在的问题,正是后续章节中所介绍的新类库 / 框架所要解决的。NanoMVC 正是引出对更高级的框架机制介绍的一个引子。