通过样例来理解 MVC 模式

参考: 自制前端框架之 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))
    }
}

在示例中可以发现,所谓的发布 - 订阅模式,其思路和实现均非常简单:

  1. 区分出【发布者】和【订阅者】的概念。本例中 Model 为发布者,Controller 为订阅者。
  2. 在发布者中维护【我有哪些订阅者】信息的数组,每个元素为一个订阅者提供的回调。
  3. 发布者数据更新时,依次触发所有订阅者的回调。

不过,Model 中的代码仅实现了【初始化发布者】与【触发所有订阅】,【数据更新时,触发订阅回调】的功能,并不是一个完整的发布 - 订阅模式。在完整的模式实现中,其余代码包括:

  1. 【订阅者订阅发布者】机制的实现,其代码位置为 Controller 中的最后一行 this.model.subscribers.push(this.render),在此将 render 方法作为订阅者回调,提供给了发布者。
  2. 【订阅者提供的订阅方法】的实现,在此即为 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 => `
    
${todo.value}
`).join('') return (`
${todosList}
`) }

index.html




    
    
    
    Document


    

总结

在实现 MVC 模式的 todo app 的过程中,MVC 模式的特性得到了体现,【Model, View】模块直接只对外提供接口, 【Controller】模块则将两者连接了起来,而 ES6 所提供的 class 高级特性则大大简化这些特性的实现复杂度(setter getter 等)。

最后的最后,咱们再梳理下流程:

点击按钮 -> 触发 controller 里的事件 -> 更改 model 数据 -> 触发 render 函数 -> 更新视图

你可能感兴趣的:(通过样例来理解 MVC 模式)