Skip to content

不用框架,也能用MVC模式组织代码 #2

Description

@Lucifier129

用MVC模式组织代码

前言

MVC这个名词,在前端领域还处于意义不明确的阶段。打着MV*名号的框架层出不穷,概括MVC的文章也让人应接不暇。然而究竟什么是MVC,那些作者各执一词。

winter 做了下正本清源的工作:

既然经典MVC模式与前端的view.onclick天然的互斥,不再适用;既然大家都在打扮MVC,本文也尝试重新演绎。介绍一种以ModelViewController为命名的代码组织方案。

这个方案给我带来了良好的编程体验,并且几乎立即可以应用在所有项目中,不管历史遗留的代码多么庞杂,新增的业务需求都能按照新方案进行组织。

Talk is cheap, show me the code

Github的todomvc项目,提供了各种以MV*为名号的框架的实现,帮助前端工程师选择合适的框架或库。其中的vanillajs版本,是一个无框架无库的原生JavaScript实现;它也是按照MVC的理念来组织代码。框架或库不是MVC模式的必需品,只是说有了相应的框架与库,写起来更为便利。

本文也准备了一个原生实现todos-vanillajs,可作MVC模式参考案例。

在线体验地址:http://lucifier129.github.io/todos-vanillajs/index.html

MVC模式

一个模块是一个文件夹

在一些nodejs前端模块化编程的介绍中,常常能看到一个文件就是一个模块的说法,用module.exports的值作为模块输出。暂且把这类模块叫做nodejs模块AMD/CMD模块

我们真正关注的模块,其实是业务模块,它是指与DOM相关的视图模型、数据模型与事件交互模型的总和。比如一个页面的header模块,它包含html模板填充的数据以及随着滚动高度而固定在顶部的事件交互等结构。

业务模块不等价于AMD/CMD模块!如果产生这个误解,可能将所有东西塞一个js文件。只把html模板提取出来,也不足够。

一个业务模块应该是好几个AMD/CMD模块的组合,它是一个文件夹,包含好几个AMD/CMD模块文件,其中:

  • view.js文件对应视图模型,存储html模板,并提供根据数据输入渲染视图的方法、从视图中获取并输出数据的方法
  • model.js文件对应数据模型,提供数据存储与数据处理的所有方法
  • controller.js文件对应事件交互模型,提供事件绑定与组织视图模型和数据模型的所有方法
  • app.js对应业务模块出口文件
  • README.md是一个描述业务逻辑markdown文件,随业务逻辑变更而更新
  • 其它文件如package.jsondata_mock.json根据实际需求决定是否添加

并且:

  • 当视图模型里包含太多内容时,view也应该变成文件夹,里面包含类似template.jstodo-list.js, filter.js以及出口文件view.js
  • viewmodel绝不知道事件何时绑定,何时触发,一切事件逻辑都放在controller.js
  • view只对html模板进行get/set数据
  • model处理数据输入,数据转换、数据储存与数据输出
  • ajax行为,属于controller,不属于model;在success回调函数里调用model提供的方法处理数据

controller.js的一般形式如下:

function Controller(View, Model) {
    this.View = View
    this.Model = Model
}

Controller.prototype = {
    init: function() {
        //初始化
    },
    listen: function() {
        //事件绑定
    }
}

controller.js拿到了视图模型与数据模型,也就掌握了如何处理数据、如何渲染视图的所有方法;关于数据处理与视图渲染的时机问题,则由设计事件绑定来完成。所以它是事件交互控制器

定义和调用分离

todomvc是一个功能简单的页面,因此用一个js文件夹足以。其中的view.jsmodel.js以及controller.js都只是定义了一些方法接口, app.js这个出口文件才产生了调用。

//view.js
app.View = {
        TodoList: TodoList,
        TodoElem: TodoElem,
        Counter: Counter,
        Footer: Footer,
        ToogleAll: ToogleAll,
        Filters: Filters
    }

视图只有7个信息渲染点,用6个定义了渲染方法。没有产生调用。

//model.js

    function Model(name) {
        this.name = name
        this.todos = localStorage.getItem(name)
        if (this.todos) {
            this.todos = JSON.parse(this.todos)
        } else {
            this.todos = []
        }
    }

    Model.prototype = {
        getTodo: function(id) {
            //根据id获取todo项
        },
        getAll: function() {
            //获取所有todo项
        },
        getActive: function() {
            //获取未完成的todo项
        },
        getCompleted: function() {
            //获取已完成的todo项
        },
        addTodo: function(todo) {
            //添加新的todo项
        },
        removeTodo: function(id) {
            //根据id删除todo项
        },
        save: function() {
            //保存数据
        }
    }

数据模型Model提供了所有跟数据处理相关的方法。没有产生调用。

//controller.js

    function Controller(View, Model) {
        this.View = View
        this.Model = Model
    }

    Controller.prototype.init = function() {
        //初始化
    }

    Controller.prototype.listen = function() {

        //绑定事件代理
        //每个事件中,涉及渲染的用View提供的方法,涉及数据的用Model提供的方法

        tools.$listen('change', '#new-todo', function() {
            //输入框的change事件中,添加新todo项
        })

        tools.$listen('keyup', '#new-todo', function(e) {
            if (e.keyCode === ENTER_KEY) {
                //输入框聚焦时,按回车键也添加新todo项
            }
        })

        tools.$listen('dblclick', '#todo-list label', function() {
            //todo-list里双击进行内容编辑
        })

        tools.$listen('change', '#todo-list .edit', function() {
            //内容编辑框的change事件,标志完成编辑
        })

        tools.$listen('keyup', '#todo-list .edit', function(e) {
            var keyCode = e.keyCode

            if (keyCode === ESCAPE_KEY || keyCode === ENTER_KEY) {
                //内容编辑框按回车键,或者ESC退出键,标志完成编辑
            }
        })

        tools.$listen('change', '#todo-list .toggle', function() {
            //todo-list每一项提供的checkbox,其change事件中切换[未完成-已完成]状态
        })

        tools.$listen('change', '#toggle-all', function() {
            //该checlbox的切换所有todo项的状态
        })

        tools.$listen('click', '#todo-list .destroy', function() {
            //每个todo项的视图中,提供删除按钮,其click事件触发删除
        })

        tools.$listen('click', '#clear-completed', function() {
            //一个按钮,其click事件触发时,清除所有已完成的todo项
        })

        //dom ready时更新页面数据
        document.addEventListener('DOMContentLoaded', this.update.bind(this), false)
        //hashchange时,更新页面数据
        window.addEventListener('hashchange', this.update.bind(this), false)
        //页面关闭时,保存数据到localStorage
        window.addEventListener('beforeunload', this.model.save.bind(this.model), false)
    }

    Controller.prototype.otherMethod = function() {
        //其它方法,用于封装可复用的model与view的交互方式
    }

事件交互控制器虽然写了与业务逻辑耦合严重的硬编码,比如事件代理的各个特定的selector;但是它仍然封装在实例方法中,不形成调用。

//app.js
app.todos = new app.Controller(app.View, app.Model).init()

真正的调用,发生在出口模块中,并且也是因为此app.js是整个页面的出口模块。假设它只是其中一个业务模块,很多时候也不应调用init方法,而是放到页面出口模块中,择时机初始化。

定义与调用分离的好处:

  • 其它业务模块可以无副作用的复用你的view/model/controller提供的方法
  • 定义与调用分离,更容易促使工程师写出可复用的、面向对象的代码

尾声

以上所谓的MVC模式,算不上最佳实践,只是一个经验分享。如果你有好的建议,或者更好的方案,欢迎指出与分享。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions