Nodejs—MVVM

MVVM最早由微软提出来,它借鉴了桌面应用程序的MVC思想,在前端页面中,把Model用纯JavaScript对象表示,View负责显示,两者做到了最大限度的分离。

把Model和View关联起来的就是ViewModel。ViewModel负责把Model的数据同步到View显示出来,还负责把View的修改同步回Model。需要用JavaScript编写一个通用的ViewModel,这样,就可以复用整个MVVM模型了。

单向绑定

MVVM就是在前端页面上,应用了扩展的MVC模式,我们关心Model的变化,MVVM框架自动把Model的变化映射到DOM结构上,这样,用户看到的页面内容就会随着Model的变化而更新。

经过MVVM框架的自动转换,浏览器就可以直接显示Model的数据。目前,常用的MVVM框架有:

1
2
3
Angular:Google出品,名气大,但是很难用;
Backbone.js:入门非常困难,因为自身API太多;
Ember:一个大而全的框架,想写个Hello world都很困难。

我们选择MVVM的目标应该是入门容易,安装简单,能直接在页面写JavaScript,需要更复杂的功能时又能扩展支持。综合考察,最佳选择是尤雨溪大神开发的MVVM框架:Vue.js 目前,Vue.js的最新版本是2.0,我们会使用2.0版本作为示例。

创建基于koa的Node.js项目结构如下:

编写MVVM

Model是一个JavaScript对象,它包含两个属性:

1
2
3
4
{
name: 'hu',
age: 15
}

而负责显示的是DOM节点可以用和来引用Model的属性:

1
2
3
4
<div id="vm">
<p>Hello, {{ name }}!</p>
<p>You are {{ age }} years old!</p>
</div>

最后一步是用Vue把两者关联起来。

在内部编写的JavaScript代码,需要用jQuery把MVVM的初始化代码推迟到页面加载完毕后执行,否则,直接在内执行MVVM代码时,DOM节点尚未被浏览器加载,初始化将失败。正确的写法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<html>
<head>

<!-- 引用jQuery -->
<script src="/static/js/jquery.min.js"></script>

<!-- 引用Vue -->
<script src="/static/js/vue.js"></script>

<script>
// 初始化代码:
$(function () {
var vm = new Vue({
el: '#vm',
data: {
name: 'hu',
age: 15
}
});
window.vm = vm;
});
</script>

</head>

<body>
<div id="vm">
<p>Hello, {{ name }}!</p>
<p>You are {{ age }} years old!</p>
</div>

</body>
<html>

其中,el指定了要把Model绑定到哪个DOM根节点上,语法和jQuery类似。这里的’#vm’对应ID为vm的一个

节点:

在该节点以及该节点内部,就是Vue可以操作的View。Vue可以自动把Model的状态映射到View上,但是不能操作View范围之外的其他DOM节点。

然后,data属性指定了Model,我们初始化了Model的两个属性name和age,在View内部的

节点上,可以直接用引用Model的某个属性。

如果打开浏览器console,因为我们用代码window.vm = vm,把VM变量绑定到了window对象上,所以,可以直接修改VM的Model:

Vue作为MVVM框架会自动监听Model的任何变化,在Model数据变化时,更新View的显示。这种Model到View的绑定我们称为单向绑定。

单向绑定

在Vue中,可以直接写绑定某个属性。如果属性关联的是对象,还可以用多个.引用,例如,。

另一种单向绑定的方法是使用Vue的指令v-text,写法如下:

Hello, !

这种写法是把指令写在HTML节点的属性上,它会被Vue解析,该节点的文本内容会被绑定为Model的指定属性,注意不能再写双花括号{{ }}。

双向绑定

在Vue中,使用双向绑定非常容易,我们仍然先创建一个VM实例:

1
2
3
4
5
6
7
8
9
10
$(function () {
var vm = new Vue({
el: '#vm',
data: {
email: '',
name: ''
}
});
window.vm = vm;
});

然后,编写一个HTML FORM表单,并用v-model指令把某个<input>和Model的某个属性作双向绑定:

1
2
3
4
<form id="vm" action="#">
<p><input v-model="email"></p>
<p><input v-model="name"></p>
</form>

我们可以在表单中输入内容,然后在浏览器console中用window.vm.$data查看Model的内容,也可以用window.vm.name查看Model的name属性,它的值和FORM表单对应的<input>是一致的。

如果在浏览器console中用JavaScript更新Model,例如,执行window.vm.name=’22222’,表单对应的<input>内容就会立刻更新。

多个checkbox可以和数组绑定:

1
2
3
<label><input type="checkbox" v-model="language" value="zh"> Chinese</label>
<label><input type="checkbox" v-model="language" value="en"> English</label>
<label><input type="checkbox" v-model="language" value="fr"> French</label>

对应的Model为:

1
language: ['zh', 'en']

单个checkbox可以和boolean类型变量绑定:

<input type="checkbox" v-model="subscribe">

对应的Model为:

subscribe: true; // 根据checkbox是否选中为true/false

下拉框<select>绑定的是字符串,但是要注意,绑定的是value而非用户看到的文本:

1
2
3
4
5
<select v-model="city">
<option value="bj">Beijing</option>
<option value="sh">Shanghai</option>
<option value="gz">Guangzhou</option>
</select>

对应的Model为:

city: ‘bj’ // 对应option的value

双向绑定最大的好处是我们不再需要用jQuery去查询表单的状态,而是直接获得了用JavaScript对象表示的Model。

处理事件

当用户提交表单时,传统的做法是响应onsubmit事件,用jQuery获取表单内容,检查输入是否有效,最后提交表单,或者用AJAX提交表单。

现在,获取表单内容已经不需要了,因为双向绑定直接让我们获得了表单内容,并且获得了合适的数据类型。

响应onsubmit事件也可以放到VM中。我们在

元素上使用指令:

<form id="vm" v-on:submit.prevent="register">

其中,v-on:submit=”register”指令就会自动监听表单的submit事件,并调用register方法处理该事件。使用.prevent表示阻止事件冒泡,这样,浏览器不再处理的submit事件。

因为我们指定了事件处理函数是register,所以需要在创建VM时添加一个register函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
var vm = new Vue({
el: '#vm',
data: {
...
},
methods: {
register: function () {
// 显示JSON格式的Model:
alert(JSON.stringify(this.$data));
// TODO: AJAX POST...
}
}
});

在register()函数内部,我们可以用AJAX把JSON格式的Model发送给服务器,就完成了用户注册的功能。

同步DOM结构

除了简单的单向绑定和双向绑定,MVVM还有一个重要的用途,就是让Model和DOM的结构保持同步。

我们用一个TODO的列表作为示例,从用户角度看,一个TODO列表在DOM结构的表现形式就是一组

  • 节点:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    <ol>
    <li>
    <dl>
    <dt>产品评审</dt>
    <dd>新款iPhone上市前评审</dd>
    </dl>
    </li>
    <li>
    <dl>
    <dt>开发计划</dt>
    <dd>与PM确定下一版Android开发计划</dd>
    </dl>
    </li>
    <li>
    <dl>
    <dt>VC会议</dt>
    <dd>敲定C轮5000万美元融资</dd>
    </dl>
    </li>
    </ol>

    而对应的Model可以用JavaScript数组表示:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    todos: [
    {
    name: '产品评审',
    description: '新款iPhone上市前评审'
    },
    {
    name: '开发计划',
    description: '与PM确定下一版Android开发计划'
    },
    {
    name: 'VC会议',
    description: '敲定C轮5000万美元融资'
    }
    ]

    使用MVVM时,当我们更新Model时,DOM结构会随着Model的变化而自动更新。当todos数组增加或删除元素时,相应的DOM节点会增加

  • 或者删除
  • 节点。

    在Vue中,可以使用v-for指令来实现:

    1
    2
    3
    4
    5
    6
    7
    8
    <ol>
    <li v-for="t in todos">
    <dl>
    <dt>{{ t.name }}</dt>
    <dd>{{ t.description }}</dd>
    </dl>
    </li>
    </ol>

    v-for指令把数组和一组

  • 元素绑定了。在
  • 元素内部,用循环变量t引用某个属性,例如,。这样,我们只关心如何更新Model,不关心如何增删DOM节点,大大简化了整个页面的逻辑。

    我们可以在浏览器console中用window.vm.todos[0].name=’计划有变’查看View的变化,或者通过window.vm.todos.push({name:’新计划’,description:’blabla…’})来增加一个数组元素,从而自动添加一个

  • 元素。

    需要注意的是,Vue之所以能够监听Model状态的变化,是因为JavaScript语言本身提供了Proxy或者Object.observe()机制来监听对象状态的变化。但是,对于数组元素的赋值,却没有办法直接监听,因此,如果我们直接对数组元素赋值:

    1
    2
    3
    4
    vm.todos[0] = {
    name: 'New name',
    description: 'New description'
    };

    会导致Vue无法更新View。

    正确的方法是不要对数组元素赋值,而是更新:

    1
    2
    3
    4
    5
    6
    7
    8
    vm.todos[0].name = 'New name';
    vm.todos[0].description = 'New description';
    或者,通过splice()方法,删除某个元素后,再添加一个元素,达到“赋值”的效果:

    var index = 0;
    var newElement = {...};
    vm.todos.splice(index, 1, newElement);
    Vue可以监听数组的splice、push、unshift等方法调用,所以,上述代码可以正确更新View。

    和后端交互

    现在,如果要把这个简单的TODO应用变成一个用户能使用的Web应用,我们需要解决几个问题:

    • 用户的TODO数据应该从后台读取;
    • 对TODO的增删改必须同步到服务器后端;
    • 用户在View上必须能够修改TODO。

    第1个和第2个问题都是和API相关的。只要我们实现了合适的API接口,就可以在MVVM内部更新Model的同时,通过API把数据更新反映到服务器端,这样,用户数据就保存到了服务器端,下次打开页面时就可以读取TODO列表。

    在api.js文件中,我们用数组在服务器端模拟一个数据库,然后实现以下4个API:

    1
    2
    3
    4
    GET /api/todos:返回所有TODO列表;
    POST /api/todos:创建一个新的TODO,并返回创建后的对象;
    PUT /api/todos/:id:更新一个TODO,并返回更新后的对象;
    DELETE /api/todos/:id:删除一个TODO。

    和上一节的TODO数据结构相比,我们需要增加一个id属性,来唯一标识一个TODO。

    准备好API后,在Vue中,有两个方法:一是直接用jQuery的AJAX调用REST API,不过这种方式比较麻烦。第二个方法是使用vue-resource这个针对Vue的扩展,它可以给VM对象加上一个$resource属性,通过$resource来方便地操作API。

    使用vue-resource只需要在导入vue.js后,加一行

    我们给VM增加一个init()方法,读取TODO列表:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    var vm = new Vue({
    el: '#vm',
    data: {
    title: 'TODO List',
    todos: []
    },
    created: function () {
    this.init();
    },
    methods: {
    init: function () {
    var that = this;
    that.$resource('/api/todos').get().then(function (resp) {
    // 调用API成功时调用json()异步返回结果:
    resp.json().then(function (result) {
    // 更新VM的todos:
    that.todos = result.todos;
    });
    }, function (resp) {
    // 调用API失败:
    alert('error');
    });
    }
    }
    });

    注意到创建VM时,created指定了当VM初始化成功后的回调函数,这样,init()方法会被自动调用。

    类似的,对于添加、修改、删除的操作,我们也需要往VM中添加对应的函数。以添加为例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    var vm = new Vue({
    ...
    methods: {
    ...
    create: function (todo) {
    var that = this;
    that.$resource('/api/todos').save(todo).then(function (resp) {
    resp.json().then(function (result) {
    that.todos.push(result);
    });
    }, showError);
    },
    update: function (todo, prop, e) {
    ...
    },
    remove: function (todo) {
    ...
    }
    }
    });

    添加操作需要一个额外的表单,我们可以创建另一个VM对象vmAdd来对表单作双向绑定,然后,在提交表单的事件中调用vm对象的create方法:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    var vmAdd = new Vue({
    el: '#vmAdd',
    data: {
    name: '',
    description: ''
    },
    methods: {
    submit: function () {
    vm.create(this.$data);
    }
    }
    });

    vmAdd和FORM表单绑定:

    1
    2
    3
    4
    5
    <form id="vmAdd" action="#0" v-on:submit.prevent="submit">
    <p><input type="text" v-model="name"></p>
    <p><input type="text" v-model="description"></p>
    <p><button type="submit">Add</button></p>
    </form>

    最后,把vm绑定到对应的DOM:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    <div id="vm">
    <h3>{{ title }}</h3>
    <ol>
    <li v-for="t in todos">
    <dl>
    <dt contenteditable="true" v-on:blur="update(t, 'name', $event)">{{ t.name }}</dt>
    <dd contenteditable="true" v-on:blur="update(t, 'description', $event)">{{ t.description }}</dd>
    <dd><a href="#0" v-on:click="remove(t)">Delete</a></dd>
    </dl>
    </li>
    </ol>
    </div>

    这里我们用contenteditable=”true”让DOM节点变成可编辑的,用v-on:blur=”update(t, ‘name’, $event)”在编辑结束时调用update()方法并传入参数,特殊变量$event表示DOM事件本身。

    删除TODO是通过对节点绑定v-on:click事件并调用remove()方法实现的。

    如果一切无误,我们就可以先启动服务器,然后在浏览器中访问http://localhost:3000/static/index.html,对TODO进行增删改等操作,操作结果会保存在服务器端。

    通过Vue和vue-resource插件,我们用简单的几十行代码就实现了一个真正可用的TODO应用。