yinguangyao / blog

关于 JavaScript 前端开发、工作经验的一点点总结。

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

写好业务代码 —— 前端 MVC 分层的实践

yinguangyao opened this issue · comments

commented

前言

前面我们讲了 JavaScript 面向对象编程,这篇文章我们会介绍一下面向对象中的经典编程模式 —— MVC。

MVC、MVVM、MVP 这三个概念在前端领域是老生常谈了,但这节课不会只在概念层面讲述三者的区别,而是更偏向实践、从编写业务代码层面讨论一下 MVC 模式在前端开发中的意思。

并未过时的 MVC

在 Angualr、Vue 等 MVVM 框架出现前,最火的前端框架当属 Backbone,这是一个典型的 MVC 框架。也许你会说 Backbone 不是过时了吗?那还在前端中讨论 MVC 还有什么意义?

Backbone 的确过时了,但是过时是因为 Backbone 的 MVC 实现方式过时了,并非是 MVC **过时了。如果以 Vue/React 作为 View 层,Vuex/Redux 作为 Model 层,那么就可以实现新的 MVC 框架。

工业聚大佬在此基础上实现了一个 react-imvc 的框架,也为此写过一篇文章:IMVC(同构 MVC)的前端实践

MVC 分层有助于管理复杂的应用程序,因为你可以在一个时间内只关注于某一层。例如,你可以在不依赖业务逻辑的情况下专注于视图设计。同时也让应用程序的测试更加容易。

即使是不使用 Angular/React/Vue 这些框架,我们依然能用 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 三部分组成,这三部分各司其职。

  1. View: 展示给用户的界面,可以接受用户输入
  2. Model: 存放应用的所有数据,数据来源可能是接口、本地缓存等等
  3. Controller: 负责连接 Model 和 View,从 View 获得输入,修改相关 Model 的数据后,再去通知相关 View 进行更新

不同版本的 MVC

对于 MVC,业界有不同的定义,比如就有微软的版本、苹果的版本等等。

苹果版本

苹果版本的 MVC:

image_1df5q2h5q1t9b156vda41qkqfq19.png-35.4kB

整理一下苹果版本的 MVC,是这样的:

  1. View 发送用户的动作给 Controller
  2. Controller 去更新对应的 Model
  3. Model 改变后会去通知 Controller 更新 View
  4. Controller 收到通知后修改 View

这个版本的 MVC 模式比较符合我们前端开发的直觉,因此本文也以苹果的版本来讲解。

微软版本

微软的版本(图片来自微软 ASP.NET core 官网):

image_1df5q84hg1l1ftc2h1s1mgrsmdm.png-38.8kB

微软的版本以 ASP.NET core MVC 为实现,可以这样理解:

  1. 用户请求被路由到 Controller
  2. Controller 到 Model 执行用户操作和查询结果
  3. Controller 选择要显示给用户的 View
  4. Controller 为 View 提供所需的 Model 数据

可以看得出来,微软版本的 MVC 比较难理解,因此这里不做太多介绍。

阮一峰版

阮一峰的版本:

image_1dfa1ubm37e7gevk03hb917p113.png-31kB

阮一峰版本的 MVC 可以这样理解:

  1. 用户和应用交互
  2. 控制器的事件处理器被触发
  3. 控制器从模型中请求数据,并将其交给视图
  4. 视图将数据呈现给用户

原生 JS 实现 MVC

我们要实现的 MVC 模式如下,类似于 Backbone 的设计模式:

  1. view 会在 model 中进行注册
  2. 用户在页面上进行操作,触发 action 通知 controller
  3. controller 去更新 model 中的数据
  4. controller 通知 view 重新渲染

image_1e3bnr3d015sg1js51ua5u9b5oe9.png-26kB

这里我们要用原生 JS 来实现一个 MVC 模式,在业务代码中也可以使用这样的形式来组织项目。
首先,对于项目来说,我们应该以 app.js 为入口,这是一个业务模块。

Model

model.js 对应 Model,这里定义了应用的数据模型,只做提供数据存储和修改。
在这里可以实现了一个 发布-订阅 功能,在 view 中会订阅当前状态的变化,一旦状态发生变化,就去通知所有订阅的 view 重新渲染。

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

View.js 对应 View 层,提供数据进行视图渲染,一般是 html 模板文件,也可以是 React/View 等现代化 UI 框架。这里以 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>

当然,如果你觉得无法理解这个模板语法,你也可以用 es6 模板字符串来实现。

function ToDoList(props) {
    return `
        <ul id="todoList">
        ${props.todos.reduce((str, todo) => {
            return `
                ${str}
                <li data-id=${todo.id} class=${todo.isActive ? "active" : ""}>
                    ${todo.name}
                </li>
            `
        }, '')}
        </ul>
    `
}

然后将 model 中的数据传给这个模板来渲染。

// 在render方法中对模板进行重新渲染
app.View = class View {
    render(model) {
        const template = _.template($('#tpl').html(), model)
        $('body').html(template);
    }
}
// es6 写法
app.View = class View {
    render(model) {
        $('body').html(ToDoList(model));
    }
}

Controller

controller.js 对应 Controller,主要是 Model 和 View 之间的纽带,进行一系列事件绑定等功能。
如果你有开发过大型的 jQuery 项目,就会遇到这种问题。当绑定的事件越来越多的时候,在代码里面很难看出来 DOM 和事件之间的绑定关系,维护起来也越来越吃力。
所以我们可以将 DOM 和事件绑定集中在 Controller 中来处理。在 events 属性中,我们将需要绑定的 DOM 元素、事件以及回调函数用键值对的形式进行映射,在 delegateEvents 中解析后进行事件绑定。这样的好处就是可以很清晰地看到项目中所有的绑定事件,后期维护起来更容易。

const events = {
    "click li": "toggle",
    "submit #form": "submit"
}
// 解析上面的 events 属性
const delegateEvents = () => {
    // 正则表达式分组捕获
    var eventSplitter =  /^(\w+)\s*(.*)$/;
    for (var key in events) {
        var methodName = events[key];
        var match = key.match(eventSplitter);
        // 解析出来的事件名和绑定的 DOM
        var 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.js
new TodoController('#todoList').init(new app.View()), new app.Model());

这样我们就实现了一个完整的 MVC 骨架。这种形式可以适用于很多项目,甚至不需要依赖框架,后续还可以将 underscore template 替换成 React/Vue 等框架,增加了项目的可维护性,层次结构更加清晰。

React 中的 MVC

其他分层

如果遇到更复杂的业务逻辑,上面的 MVC 分层是远远不够的,部分逻辑可以从 Controller 和 Model 层中剥离出来,也许你还需要下面的这些分层。

Service

Service 层一般是封装了和请求数据相关的操作,比如 Ajax 请求、localStorage、indexDB,甚至是 JS Bridge 等等,这里类似于后端里面的 Model 概念。
比如向后端请求数据我们可以这么来写:

class Service {
    async getList() {
        const {
            data = {}
        } = await http({
            method: 'post',
            url: '/getList',
            data: {}
        });
        return data.list;
    }
}

在需要用到这个接口的地方(一般是 Controller 层)导入对应的 Service 文件,将接口请求的操作放到 Service 中统一处理,方便后期维护。

format

在将数据请求回来后,往往会出现接口的数据和前端要展示的数据结构不一致的情况。这个时候,从 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"

utils

除了数据请求和格式化之外,往往项目中也会出现一些共用的工具函数,比如日期格式化、埋点、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 中。

image_1dooi085a3afmg7175ab1u8du9.png-17.7kB

总结

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