/ JavaScript

单页系统前端MVC设计 – MVVM模式

上一篇中,Action已经可以获取所有需要的数据,下一步就是将数据与视图中的控件树进行关联,使得View模块可以获取、修改数据,并通过与Action的进一步交互来完成整个业务逻辑。

View模块

MVVM模式

在介绍本文的ValueStackModelDescriptor这两个在整个框架中意义非凡的组件之前,需要先简单地介绍一下MVVM模式。

这个MVC框架引入了MVVM模式,MVVM模式的全称是Model-View-ViewModel模式。该模式顾名思义地有三个部分组成:

  • Model:即数据,所有可持久化和不可持久化的,与业务有着关系的内容都可以被称为数据。Model的数据是扁平的,为了区别下面的ViewModel,这里可以称之为FlatModel。

    例如,一个系统有用户,以及用户发表的文章,期间是1:n的关系,那么Model就存在两条数据。其一为user,其二为postList,两者之间虽然有着“从属”的关系,但是在FlatModel这样“扁平”的结构中,两者是同一层次的。

    Model

  • View:即视图,一个单纯地与用户进行交互的组件。用户最终可以看到的是视图、可以操作的视图,只有通过视图才可以与整个系统的业务流程和数据产生交互。

  • ViewModel:即数据模型,是链接View与整个系统的数据的特殊的一类数据结构。ViewModel的数据结构是随着View的需要而定义的,他来自于Model,但并不完全与Model相同。ViewModel是系统经过对Model的转换、映射等各种操作,最后形成的包含且仅包含View所需要的数据内容的特定结构,且该结构也完全与View的需要相吻合。

    例如,上面的user和postList两个相互平行的“扁平”结构的数据,当一个View的组件需要两者产生关联时,就可能变成下面这种结构,形成一个新的ViewModel:

    ViewModel

需要注意的是,ViewModel中的所有数据值的来源都是Model,这仅仅是Model为了满足View的需求的一种投影。

ModelDescriptor组件

ModelDescriptor组件是在Action的执行流程中,将通过prepareData函数获取的Model转换为View需要的ViewModel的关键组件。

组件作用

ModelDescriptor的作用可以归纳为以下几点:

  • 形式上,ModelDescriptor可以用来定义一个ViewModel的具体结构。
  • 功能上,ModelDescriptor用于定义从ModelViewModel的转换中的映射过程。

因此,一个ModelDescriptor首先需要能在结构上表现一个ViewModel的组件,通过JSON的格式就可以很明确地进行定义:

var structure = {
    pager: {
        pageSize: /* ?? */,
        pageIndex: /* ?? */,
        pageCount: /* ?? */,
    },
    table: {
        title: /* ?? */,
        header: /* ?? */,
        data: /* ?? */,
    },
    searchBar: {
        keyword: {
            text: /* ?? */,
        },
        status: {
            value: /* ?? */
        }
    }
};

以上代码就指定了一个View需要的数据结构,对象中的每一个键都映射到一个控件或者控件中的某个属性,从上面的结构中,开发人员可以很容易地得出以下结论:

  1. 这个View中存在3个控件,分别叫pagetablesearchBar
  2. page控件需要的Model中包含pageSizepageIndexpageCount这三个属性。
  3. table控件需要的Model中包含titleheaderdata这三个属性。
  4. searchBar这个控件下又包含keywordstatus这两个控件。
  5. keyword这个控件需要的Model中包含text这个属性。
  6. status这个控件需要的Model中包含value这个属性。

当然,至于各个控件如何使用他自己的Model,尚不在Action的控制范围之内。

在定义了ViewModel的结构的同时,ModelDescriptor不能只有表达的能力,还需要可以完成一些工作,那就是将原先的扁平数据结构转换为他自描述的“树状”的结构,也就是说,在描述的同时,需要将上面一段代码中的/* ?? */都填进去才算完美。

由于在上一篇中,DataProxy在获取数据的过程中,已经使用了ValueConverter,将获取到的数据进行了一定的转换,因此在使用ModelDescriptor的时候已经不需要担心数据类型不正确之类的情况,而只需要一个映射的方式,将数据进行分解并重新组织。因此最简单的一个方案是,用“属性访问的路径”来代替/* ?? */

var structure = {
    pager: {
        pageSize: ['list.pageSize', 'defaultPageSize'], // Number
        pageIndex: 'list.pageIndex', // Number
        pageCount: 'list.pageCount' // Number
    },
    table: {
        title: 'tableTitle', // String:lang
        header: 'tableHeader', // String:config
        data: 'list.list' // Array
    },
    searchBar: {
        keyword: {
            text: 'search.keyword' // String
        },
        status: {
            value: 'search.status' // Number
        }
    }
};

诸如以上的代码,pageSize: ['list.pageSize', 'defaultPageSize']这一段,就表示对于ViewModel中的pager下的pageSize这个属性,需要使用list.pageSizedefaultPageSize这两个路径从数据池中获取。当然如果通过list.pageSize已经获取到了想要的值,是不会再使用defaultPageSize来获取并覆盖前面的值的。

接口设计

ModelDescriptor作为一个组件,是一个预先定义的类。该类提供以下的接口:

  • 构造函数,接受一个普通的object作为ViewModel的结构参考的映射配置,即上文代码中的structure变量。
  • translate(valueStack)函数,通过给定一个ValueStack组件的实例,可以将这个ValueStack管理的Model转换成需要的ViewModel

实现原理

ModelDescriptortranslate函数的实现是最关键的一点是结构定义的分析,即对structure变量的分析,这个分析遵循以下原则:

  • 如果某个键对应的值是字符串,则视为对ValueStack对象的访问路径。
  • 如果某个键对应的值是对象,则视为一个子的嵌套的结构定义,在这个时候可以用这个子结构产生一个新的ModelDescriptor进行递归地分析。
function translate(valueStack) {
    var output = {};
    for (var key in this.structure) {
        var value = this.structure[key];
        if (typeof value == 'string') {
            output[key] = valueStack.get(value);
        }
        else {
            output[key] = new ModelDescriptor(value).translate(valueStack);
        }
    }
    return output;
}

ValueStack组件

如果说ModelDescriptor用于转换ModelViewModel,那么ValueStack就是真正提供ModelDescriptor转换用的基础Model的组件。

组件作用

刚开始接触这个框架的时候,可能会不明白,既然DataProxy组件已经获取了所有的数据,为什么还要凭空弄出一个ValueStackModelDescriptor合作,为什么不让ModelDescriptor组件直接去DataProxy获取的数据中取得相应的对象。

这是因为,并不是所有的数据都是通过DataProxy获取的,也并不是所有的数据的有效性都被控制在Action的生命周期中。例如,对于一个带查询的列表页面,如果需要用户在操作其他模块之后,再回到这个页面,可以保留同样的查询条件,那么此时“查询参数”这一数据项已经脱离了整个Action的生命周期,确切地说,他是被存放在Session中,被同一个Action的多次执行流程所共享的。

但这里又产生了一个问题,如果在Action的执行流程中,DataProxy已经获取了相关的查询条件,那么是不是还需要Session中的那些条件呢?从需求上来说,DataProxy获取的查询条件是最新的,必然更接近用户的需要,因此这些条件会覆盖掉Session中的内容。换一种说法,DataProxy已经获取了相关的值的前提下,Session中存放的数据就失去了作用。

因此,这里就有了一个Stack的概念,数据是分层级存放在不同的对象之中的,但是对于ModelDescriptor组件,他们并不关心自己需要的数据来自哪里,无论这个数据是DataProxy最新获取的,还是3小时前就存放在Session中差点老死的,他所关心的就是自己需要取得这样的数据,仅此而已。

在这样的前提下,就需要有一个组件,他可以从多个优先级不同的数据池中,智能地获取到需要的数据,例如有以下的一个Stack结构:

数据栈

在这种情况下,如果需要获取一个名为user的数据,就会先访问DataProxy,如果DataProxy无法提供,则访问Session,依次类推,直到有一个数据池可以给出需要的内容。而进行这一操作的,正是ValueStack组件。

接口设计

ValueStack的作用是访问数据池并获取对应路径的值,因此有以下接口:

  • 构造函数,接受一个数组,该数据提供若干个数据池,按提供数据的优先级排序。
  • get(path)函数,根据path参数指定的路径来获取值,path可以是简单的属性名称如user,也可以是使用.分隔的一个路径如user.name.firstName

实现原理

ValueStack重要的是实现对属性的访问,一个属性的访问路径通过.进行分隔,需要深层的访问。

实现该功能可以有2种方法,第一种是利用Function的构造函数创建出一个访问函数来调用,相当于eval;第二种则是分析路径再逐级访问:

function accessByFunction(obj, path) {
    var accessor = new Function('o', 'return o.' + path + ';');
    return accessor(obj);
}

function accessByParse(obj, path) {
    path = path.split('.');
    var length = path.length,
        i = 0;
    for (; i < length; i++) {
        obj = obj[path[i]];
    }
    return obj;
}

这两种方案无所谓哪一种特别好。第一种方案可以有效地缓存住accessor变量以加速访问,当然第二种方案也是有办法实现缓存的;而第二种方案由于是逐级地访问,有利于在循环深入的过程中及时地判断是否为nullundefined等值,避免出现异常。

在最后的实际使用中,我们并没有采用MVVM模式。我理解很多人对MVVM模式的崇敬和向往,但它并不是万精油,也不是适应所有场景的魔方,对于我们的业务系统,MVVM会带来更大的负面效应,也许有一天我会写点什么来说明我对MVC选型的思考。