下面我们继续实现 todomvc。按照 todomvc 应用规范,一个 todo 项目有三种交互方式:完成、编辑和删除。
“完成”显然要求我们记录下每一个 todo 的状态。按照我们目前的实现,每一个 todo 只是一个字符串,没有办法记录其状态。所以我们必须修改模型的数据结构,将 todo 存储为一个对象。我们为每一个 todo 对象添加一个 label 属性、一个 completed 属性。前者用来保存 todo 的内容;后者用于标记这个 todo 是否已经完成。由于修改了存储结构,我们已有的代码就需要做一定的修改:
addTodo: function () { if (this.newTodo) { var todo = this.newTodo.trim(); this.todolist.push({label: todo, completed: false}); this.newTodo = ''; } }
<li v-for="todo in todolist"> <div class="view"> <input class="toggle" type="checkbox"> <label>{{todo.label}}</label> <button class="destroy"></button> </div> <input class="edit" value="Rule the web"> </li>
下面我们开始实现“完成”的功能。“完成”功能需要让我们点击每一个 todo 前面的选择框,将对应的 todo 标记为完成状态,同时需要设置其父组件的 class 为 completed。我们实现的代码如下:
<li v-for="todo in todolist" :class="{completed: todo.completed}"> <div class="view"> <input class="toggle" type="checkbox" v-model="todo.completed"> <label>{{todo.label}}</label> <button class="destroy"></button> </div> <input class="edit" value="Rule the web"> </li>
首先,我们将<input>
标签绑定到todo.completed
。这意味着,todo.completed
与<input>
的选择状态是一致的:当<input>
选中时,todo.completed
会被设置为true
;否则为false
。
父组件<li>
的 class 属性则绑定到todo.completed
。注意我们的语法:
:class="{completed: todo.completed}"
:class
是v-bind:class
的缩写,这一点我们前面已经说过。:class
的值是一个 JavaScript 对象。我们设置为{completed: todo.completed}
,意味着,当todo.completed
为true
时,class 会被设置为 completed。例如,当我们写:
<div class="static" :class="{ 'class-a': isA, 'class-b': isB }"></div>
如果
data: { isA: true, isB: false }
那么,上面的代码将被渲染为:
<div class="static class-a"></div>
当data
的值发生改变时,class 会一起变化,这正是我们所需要的。
不仅仅绑定data
中的值,:class
同样支持绑定到data
中的一个对象,这与前面的介绍类似。
下面我们先来实现“删除”操作,这个更简单一些,因为之前我们已经有过类似的实现了。
<li v-for="todo in todolist" :class="{completed: todo.completed}"> <div class="view"> <input class="toggle" type="checkbox" v-model="todo.completed"> <label>{{todo.label}}</label> <button class="destroy" @click="removeTodo(todo)"></button> </div> <input class="edit" value="Rule the web"> </li>
removeTodo()
函数的实现与前面类似,不过我们换了种写法:
methods: { addTodo: function () { if (this.newTodo) { var todo = this.newTodo.trim(); this.todolist.push({label: todo, completed: false}); this.newTodo = ''; } }, removeTodo: function (todo) { this.todolist.$remove(todo); } }
前面我们使用 JavaScript 的slice()
函数删除数组中的元素,现在我们使用的是 Vue.js 提供的$remove()
函数。Vue.js 提供的函数都以 $ 开始。$remove()
函数可以删除数组中的一个元素,其参数即需要删除的元素,而不是元素索引位置。这正是与slice()
函数的不同之处。
“编辑”是最具挑战性的,具有以下几点要求:
- 通过双击进入编辑模式;
- 只显示包含 todo 标题的输入框;
- 输入框自动获得焦点;
- 失去焦点或者按下回车结束编辑;
- 如果输入为空则移除该项;
- 按下 Esc 键退出编辑,撤销所有修改。
下面我们一一实现这些功能。首先,双击事件可以使用@dblclick
完成。我们监听<label>
标签的双击事件:
<div class="view"> <input class="toggle" type="checkbox" v-model="todo.completed"> <label @dblclick="editTodo(todo)">{{todo.label}}</label> <button class="destroy" @click="removeTodo(todo)"></button> </div>
editTodo()
函数实现如下:
editTodo: function (todo) { this.editingTodoOldValue = todo.label; todo.editing = true; }
当发生了双击事件时,调用editTodo()
函数。由于按下 Esc 键需要撤销修改,所以在editTodo()
函数中,我们需要把原来的值用this.editingTodoOldValue
保存下来。注意,我们并不需要将this.editingTodoOldValue
在data
声明,因为它并不是一个需要绑定的值,只是为了临时存储数据的。前面我们说过,由于 JavaScript 是动态类型的,可以随时添加新的属性,只不过这些在运行时动态添加的属性是不能够触发视图的自动更新的。接下来,我们把todo
的editing
属性设置为true
,这与之前的completed
类似。但是,由于界面需要根据editing
属性的值动态切换 CSS 类,因此我们还必须修改addTodo()
函数,为每一个 todo 添加editing
属性:
addTodo: function () { if (this.newTodo) { var todo = this.newTodo.trim(); this.todolist.push({label: todo, completed: false, editing: false}); this.newTodo = ''; } },
这样就能支持绑定了:
<li v-for="todo in todolist" :class="{completed: todo.completed, editing: todo.editing}">
然后,我们需要修改用于编辑的<input>
标签:
<input class="edit" type="text" v-model="todo.label" @blur="finishEdit(todo)" @keyup.enter="finishEdit(todo)" @keyup.esc="cancelEdit(todo)">
<input>
标签绑定到 todo 的label
属性,因此当双击显示时,<input>
默认显示了对应的 todo label
属性的值。@blur
和@keyup.enter
事件都会调用finishEdit()
函数。该函数的实现如下:
finishEdit: function (todo) { if (!todo.editing) { return; } todo.editing = false; todo.label = todo.label.trim(); if (!todo.label) { this.removeTodo(todo); } }
如果todo.editing
不为true
,则直接返回;否则将其设置为false
,表示编辑完成。最后要判断todo.label
是否为空,如果为空则需要删除。
@keyup.esc
事件则绑定到了cancelEdit()
函数:
cancelEdit: function (todo) { todo.editing = false; todo.label = this.editingTodoOldValue; }
与finishEdit()
函数类似,我们也需要设置todo.editing
为false
,表示编辑完成。然后要将之前保存的旧值重新赋给当前的 todo,实现撤销的目的。
自动获得焦点却不那么好实现。因为 Vue 是不推荐直接操作 DOM 的,但是,focus()
函数却必须要对 DOM 进行操作。考虑到每次显示<input>
标签时都需要设置其焦点,我们选择使用指令(directives)实现。Vue 的指令提供了一种将数据的变化映射到 DOM 行为的机制。我们可以使用directives
注册指令。每个指令都可以有bind
、update
和unbind
三个回调,分别对应着数据绑定时、数据变化时和数据解绑时三个时间点。显然,我们需要的指令应该是在数据变化时。下面我们开始实现这个指令:
new Vue({ el: '.todoapp', data: { todolist: [], newTodo: '' }, methods: { ... }, directives: { 'auto-focus': function (value) { if (!value) { return; } var el = this.el; Vue.nextTick(function () { el.focus(); }); } } });
directives
作为Vue
构造时的一个参数,其中我们定义了一个指令auto-focus
。auto-focus
只有一个函数。当指令仅有一个函数时,这个函数默认作为update
的回调,否则我们需要用一个对象实现,例如:
directives: { 'a-directive': { bind: function () { ... }, update: function (newValue, oldValue) { ... }, unbind: function () { ... } } }
在auto-focus
指令的实现中,首先判断value
是否为空。如果为空则返回;否则,我们用this.el
取到指令所在元素对应的 DOM 对象。
指令的回调函数中的this
代表这个指令对象,this
提供了很多有用的属性:
- el: 指令绑定的元素。
- vm: 拥有该指令的上下文,也就是这个
Vue
对象。 - expression: 指令的表达式,不包括参数和过滤器。
- arg: 指令的参数。
- name: 指令的名字,不包含前缀。
- modifiers: 一个对象,包含指令的修饰符。
- descriptor: 一个对象,包含指令的解析结果。
当我们使用this.el
取到指令绑定的元素时,只需调用其focus()
函数即可。不过,这个指令的实现有一些有趣的东西:
Vue.nextTick(function () { el.focus(); });
要理解Vue.nextTick()
函数,首先需要了解 Vue 的 DOM 更新机制。Vue 默认使用异步方式更新 DOM。这意味着,每次观察到数据发生变化时,Vue 开始一个队列,将同一事件循环内所有的数据变化缓存起来。如果一个观察对象被多次触发,也只会向队列推入一次。等到下一次事件循环,Vue 将清空队列,只进行必要的 DOM 更新。例如:
var vm = new Vue({ el: '#example', data: { msg: '123' } }); vm.msg = 'new message'; // 修改数据 vm.$el.textContent === 'new message'; // false Vue.nextTick(function () { vm.$el.textContent === 'new message'; // true });
因此,我们需要在这里等待 DOM 更新时再去调用focus()
函数。所以,我们必须将el.focus();
放在Vue.nextTick()
的回调函数中。
现在,指令已经定义好了,只需要添加到对应的<input>
标签:
<input class="edit" type="text" v-model="todo.label" v-auto-focus="todo.editing" @blur="finishEdit(todo)" @keyup.enter="finishEdit(todo)" @keyup.esc="cancelEdit(todo)">
下面我们可以运行下所有的代码了:点击这里运行。
其实这个的实现也有很多种方式。比如,现在我们使用了editing
属性标记当前正在编辑的 todo 项目。这种实现比较自然,但是不足之处在于我们必须为每一个 todo 都添加一个标记位。如果只是一个属性尚可接受,但是如果需要大量属性,则显得不那么合理。那么,我们可以选择另外一种实现方式:在data
中定义一个editingTodo:null
属性,每次在editTodo()
函数中,将函数参数todo
赋值给this.editingTodo
,而对应的<li>
标签的:class
属性修改为{completed: todo.completed, editing: todo == editingTodo}
即可。当然,我们还必须在每次编辑完毕时,将editingTodo
重新设置为null
。
最后,我们对比一下看已经完成了哪些功能:
没有 todo 时
没有 todo 的时候,#main
和#footer
应当隐藏。
新增 todo
在应用上方的输入框按下回车新增 todo。当页面加载完毕后,输入框应该获得焦点,这可以使用autofocus
属性实现。按下回车创建 todo,将其追加到 todo 列表最后,同时清空输入框。确保对输入值调用.trim()
,并且在创建新 todo 之前检查是否为空。
标记所有为完成
该checkbox
将所有 todo 设置为与自己相同的状态。在点击“Clear completed”按钮之后清空所选状态。当一个 todo 项目被选择或反选是,“Mark all as complete”应该同步更新。例如,当所有 todo 都被勾选时,它也应该是选择状态。
项目
一个 todo 项目有三种交互:
点击选择框,将其标记为完成状态,更新其completed
属性值,并且要设置其父组件的 class 为completed
双击标签进入编辑模式,为其添加.editing
类鼠标滑过是显示删除按钮(.destroy
)
编辑
进入编辑模式时,其它组件将被隐藏,只显示包含 todo 标题的输入框,这个输入框应该获得焦点(.focus()
)。失去焦点或者按下回车结束编辑,移除.editing
类。确保对输入值调用.trim()
,并且在创建新 todo 之前检查是否为空。如果为空,该 todo 应该被销毁。如果在编辑时点击了Esc
,应该退出编辑状态,丢弃所有修改。
计数器
以复数形式显示活动的 todo 数。该数值需要使用<strong>
标签。并且要保证复数形式是正确的:0 items,1 item,2 items。例如,2 items left
Clear completed 按钮
点击按钮移除已完成 todo。如果没有已完成 todo,隐藏该按钮。
持久化
应用应该能够将 todo 动态持久化到 localStorage。如果框架自身能够处理持久化数据(例如 Backbone.sync),使用框架提供的即可。否则,使用 vanilla localStorage。如果可能的话,为每个 todo 定义 id、title、completed。localStorage 使用如下名称格式:todos-[framework]。编辑模式不应该被持久化。
路由
所有实现都需要路由。如果框架支持路由,使用内置的路由机制。否则,使用 /assets 文件夹中的 Flatiron Director 路由库。需要实现下列路由:#/ (全部 - 默认);#/active 和 #/completed (也可以使用 #!/)。路由变化时,todu 列表应该在模型级别进行过滤,过滤器链接应该添加selected
类。在过滤状态下,一个项目被修改,它应该同步更新。例如,如果过滤器是“活动的”而该项目被“完成”,那么它应该被隐藏。注意在每次加载时,持久化活动过滤器。