参考: 自制前端框架之 MVC
参考: MVC,MVP 和 MVVM 的图示
如何设计一个程序的结构,这是一门专门的学问,叫做"架构模式"(architectural pattern),属于编程的方法论。MVC 模式就是架构模式的一种,在 UI 编程领域大有大量使用 MVC 模式的开发框架 (Django等后端框架),使得开发者能够借助该模式,构建出更易于扩展和维护的应用程序。
MVC 简介
大体上可将 MVC 模式的结构分为三层,即 Model(模型)、View(视图)和Controller(控制)
- Model: 专注数据的存取,对基础数据对象的封装。
- Controller: 处理具体业务逻辑,即根据用户从"视图层"输入的指令,从 Model 中存取数据,并渲染到 View 中。
- View : 负责界面视图,供用户查看和操作,可理解为【输入数据,输出界面】的模块,在其中通常不涉及的业务逻辑。
这三层是紧密联系在一起的,但又是互相独立的,每一层内部的变化不影响其他层。每一层都对外提供接口(Interface),供其他层调用。这样一来,软件就可以实现模块化,修改外观或者变更数据都不用修改其他层,大大方便了维护和升级。
需要注意的是,MVC 仅是一种模式理念,而非具体的规范。因此,根据 MVC 的理念所设计出的框架,在实现和使用上可能存在着较大的区别。
咱们的目标
常见的后端框架所封装的功能,不外乎对数据的增查改删与渲染。在前端,我们以一个非常简单的 Todo App 作为示例,来实际看看 MVC 模式到底是怎样工作的。
- Model 模块实现 Todo 这一数据模型的存取。
- View 模块实现将 Todo 数据模型渲染到页面。
- Controller 模块实现对 Todo 数据的新增、编辑、删除等操作。
编写代码
Model 模块
按照 MVC 模式,Model 模块的主要工作是存取数据,并且在数据变化时将新数据传给 View 模块。Model 模块的核心是订阅-发布者模式
。
class Model {
// 在构造器中实例化数据与订阅者
// 本例中的数据就是 一个个的 todo
constructor() {
// 数据格式 [{id: 1, value: '123'}]
this.todo = []
this.todo
// 【初始化订阅者】
this.subscribers = []
}
// 利用 ES6 class 语法定义模型实例的 getter
// 从而在调用 model.data 时返回正确的 Todo 数据
get data() {
return this.todo
}
// 利用 ES6 class 语法定义模型实例的 setter
// 从而在执行形如 modle.data = newData 的赋值时
// 能够通知订阅了 Model 的模块进行相应更新 【数据更新时,触发订阅回调】
set data(data) {
this.todo = data
this.publish(this.todo)
}
// 由 Model 实例调用的发布方法
// 在 Model 中的 setter 更新时,将新数据传入该方法
// 由该方法将新数据推送到每个订阅者提供的回调中
// 在 本项目中,订阅者为 Controller 的 render 方法 【触发所有订阅】
publish(data) {
// 此处的订阅者是 业务中的函数
this.subscribers.forEach(render => render(data))
}
}
在示例中可以发现,所谓的发布 - 订阅模式,其思路和实现均非常简单:
- 区分出【发布者】和【订阅者】的概念。本例中 Model 为发布者,Controller 为订阅者。
- 在发布者中维护【我有哪些订阅者】信息的数组,每个元素为一个订阅者提供的回调。
- 发布者数据更新时,依次触发所有订阅者的回调。
不过,Model 中的代码仅实现了【初始化发布者】与【触发所有订阅】,【数据更新时,触发订阅回调】的功能,并不是一个完整的发布 - 订阅模式。在完整的模式实现中,其余代码包括:
- 【订阅者订阅发布者】机制的实现,其代码位置为
Controller
中的最后一行this.model.subscribers.push(this.render)
,在此将 render 方法作为订阅者回调,提供给了发布者。 - 【订阅者提供的订阅方法】的实现,在此即为 Controller 中提供的
this.render
方法。
Controller 模块
上文中已经明确,Controller 模块需要实现的功能为:
- 与 Model / View 实例的绑定。
- 对点击事件、DOM 选择等底层 API 的封装。
- 用于渲染数据的 Render 方法。
class Controller {
constructor(conf) {
// 根据实例化参数,定义 Controller 基础配置
// 包括 DOM 容器、Model / View 实例及 onClick 事件等
this.el = document.querySelector(conf.el)
this.model = conf.model
this.view = conf.view
// 为 容器 dom 设置事件
this.bindEvent(this.el)
// 给 render 函数绑定 this
this.render = this.render.bind(this)
// 在 Model 更新时执行 controller 的 render 方法
this.model.subscribers.push(this.render)
}
// 根据点击 btn 的 class 属性绑定不同的事件回调
bindEvent() {
this.el.addEventListener('click', (event) => {
let el = event.target
let id = el.dataset.id
if (el.classList.contains('todo-delete')) {
deleteTodo(id)
} else if (el.classList.contains('todo-update')) {
updateTodo(el, id)
} else if (el.classList.contains('todo-add')) {
addTodo(el)
}
})
// 点击 add 时,把新的 todo 添加到 model 中
const addTodo = (el) => {
let input = el.parentElement.querySelector('.input-add')
let value = input.value
if (value !== '') {
let data = this.model.data
data.push({
id: data.length,
value: value
})
this.model.data = data
}
}
// 点击 delete 时,把对应 todo 从 model 中删除
const deleteTodo = (id) => {
this.model.data = this.model.data.filter((todo) => {
return id !== String(todo.id)
})
}
// 点击 update 时,把对应 todo 在 model 中更新
const updateTodo = (el, id) => {
this.model.data = this.model.data.map((todo) => {
return ({
id: todo.id,
value: setValue(id, todo)
})
})
}
// 辅助 更新函数
const setValue = (id, todo) => {
if (id === String(todo.id)) {
let updateInput = document.querySelector(`input[data-id="${todo.id}"]`)
return updateInput.value
} else {
return todo.value
}
}
}
// 全量重置 DOM 的 naive render 实现
render() {
// 由于 view 是纯函数,故而直接对其传入 Model 数据
// 将输出的 HTML 模板作为 Controller DOM 内的新状态
this.el.innerHTML = this.view(this.model.todo)
}
}
可以看到 Controller 模块在点击事件触发时,没有直接修改 dom, 只是修改了 model 中的数据, dom 视图的改变是在 model 中数据变化时自动 render 的。
View 模块
如前文所述, View 模块实质上就是一个在 Model 中数据变更时,由 Controller 在 render 方法中执行的一个纯函数。
function view(todos) {
const todosList = todos.map(todo => `
<div>
<span data-id="${todo.id}">
${todo.value}
</span>
<button data-id="${todo.id}" class="todo-delete">
删除
</button>
<span>
<input data-id="${todo.id}"/>
<button data-id="${todo.id}" class="todo-update">
Update
</button>
</span>
</div>
`).join('')
return (`
<main>
<input class="input-add"/>
<button class="todo-add">Add</button>
<div>${todosList}</div>
</main>
`)
}
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<div id="app"></div>
<script src="./mvc.js"></script>
<script>
const model = new Model()
let conf = {
model,
view,
el: '#app',
}
const controller = new Controller(conf)
controller.render()
</script>
</body>
</html>
总结
在实现 MVC 模式的 todo app 的过程中,MVC 模式的特性得到了体现,【Model, View】模块直接只对外提供接口, 【Controller】模块则将两者连接了起来,而 ES6 所提供的 class 高级特性则大大简化这些特性的实现复杂度(setter getter 等)。
最后的最后,咱们再梳理下流程:
点击按钮 -> 触发 controller 里的事件 -> 更改 model 数据 -> 触发 render 函数 -> 更新视图
网友评论