用KnockoutJS实现ToDoMVC代码分析
体验地址
Knockout 版todo web app在线体验
http://todomvc.com/examples/knockoutjs/
源码地址
项目源码地址,此地址包含了各种JS框架实现的todo web app
https://github.com/tastejs/todomvc
HTML View
以下是html view中主要部分
<section id="todoapp"> <header id="header"> <h1>todos</h1> <input id="new-todo" data-bind="value: current, enterKey: add" placeholder="What needs to be done?" autofocus> </header> <section id="main" data-bind="visible: todos().length"> <input id="toggle-all" data-bind="checked: allCompleted" type="checkbox"> <label for="toggle-all">Mark all as complete</label> <ul id="todo-list" data-bind="foreach: filteredTodos"> <li data-bind="css: { completed: completed, editing: editing }"> <div class="view"> <input class="toggle" data-bind="checked: completed" type="checkbox"> <label data-bind="text: title, event: { dblclick: $root.editItem }"></label> <button class="destroy" data-bind="click: $root.remove"></button> </div> <input class="edit" data-bind="value: title, enterKey: $root.saveEditing, escapeKey: $root.cancelEditing, selectAndFocus:editing, event: { blur: $root.cancelEditing }"> </li> </ul> </section> <footer id="footer" data-bind="visible: completedCount() || remainingCount()"> <span id="todo-count"> <strong data-bind="text: remainingCount">0</strong> <span data-bind="text: getLabel(remainingCount)"></span> left </span> <ul id="filters"> <li> <a data-bind="css: { selected: showMode() == 'all' }" href="#/all">All</a> </li> <li> <a data-bind="css: { selected: showMode() == 'active' }" href="#/active">Active</a> </li> <li> <a data-bind="css: { selected: showMode() == 'completed' }" href="#/completed">Completed</a> </li> </ul> <button id="clear-completed" data-bind="visible: completedCount, click: removeCompleted"> Clear completed (<span data-bind="text: completedCount"></span>) </button> </footer> </section>
核心JS
/*global ko, Router */ (function () { 'use strict'; var ENTER_KEY = 13; var ESCAPE_KEY = 27; // A factory function we can use to create binding handlers for specific // keycodes. function keyhandlerBindingFactory(keyCode) { return { init: function (element, valueAccessor, allBindingsAccessor, data, bindingContext) { var wrappedHandler, newValueAccessor; // wrap the handler with a check for the enter key wrappedHandler = function (data, event) { if (event.keyCode === keyCode) { valueAccessor().call(this, data, event); } }; // create a valueAccessor with the options that we would want to pass to the event binding newValueAccessor = function () { return { keyup: wrappedHandler }; }; // call the real event binding's init function ko.bindingHandlers.event.init(element, newValueAccessor, allBindingsAccessor, data, bindingContext); } }; } // a custom binding to handle the enter key ko.bindingHandlers.enterKey = keyhandlerBindingFactory(ENTER_KEY); // another custom binding, this time to handle the escape key ko.bindingHandlers.escapeKey = keyhandlerBindingFactory(ESCAPE_KEY); // wrapper to hasFocus that also selects text and applies focus async ko.bindingHandlers.selectAndFocus = { init: function (element, valueAccessor, allBindingsAccessor, bindingContext) { ko.bindingHandlers.hasFocus.init(element, valueAccessor, allBindingsAccessor, bindingContext); ko.utils.registerEventHandler(element, 'focus', function () { element.focus(); }); }, update: function (element, valueAccessor) { ko.utils.unwrapObservable(valueAccessor()); // for dependency // ensure that element is visible before trying to focus setTimeout(function () { ko.bindingHandlers.hasFocus.update(element, valueAccessor); }, 0); } }; // represent a single todo item var Todo = function (title, completed) { this.title = ko.observable(title); this.completed = ko.observable(completed); this.editing = ko.observable(false); }; // our main view model var ViewModel = function (todos) { // map array of passed in todos to an observableArray of Todo objects this.todos = ko.observableArray(todos.map(function (todo) { return new Todo(todo.title, todo.completed); })); // store the new todo value being entered this.current = ko.observable(); this.showMode = ko.observable('all'); this.filteredTodos = ko.computed(function () { switch (this.showMode()) { case 'active': return this.todos().filter(function (todo) { return !todo.completed(); }); case 'completed': return this.todos().filter(function (todo) { return todo.completed(); }); default: return this.todos(); } }.bind(this)); // add a new todo, when enter key is pressed this.add = function () { var current = this.current().trim(); if (current) { this.todos.push(new Todo(current)); this.current(''); } }.bind(this); // remove a single todo this.remove = function (todo) { this.todos.remove(todo); }.bind(this); // remove all completed todos this.removeCompleted = function () { this.todos.remove(function (todo) { return todo.completed(); }); }.bind(this); // edit an item this.editItem = function (item) { item.editing(true); item.previousTitle = item.title(); }.bind(this); // stop editing an item. Remove the item, if it is now empty this.saveEditing = function (item) { item.editing(false); var title = item.title(); var trimmedTitle = title.trim(); // Observable value changes are not triggered if they're consisting of whitespaces only // Therefore we've to compare untrimmed version with a trimmed one to chech whether anything changed // And if yes, we've to set the new value manually if (title !== trimmedTitle) { item.title(trimmedTitle); } if (!trimmedTitle) { this.remove(item); } }.bind(this); // cancel editing an item and revert to the previous content this.cancelEditing = function (item) { item.editing(false); item.title(item.previousTitle); }.bind(this); // count of all completed todos this.completedCount = ko.computed(function () { return this.todos().filter(function (todo) { return todo.completed(); }).length; }.bind(this)); // count of todos that are not complete this.remainingCount = ko.computed(function () { return this.todos().length - this.completedCount(); }.bind(this)); // writeable computed observable to handle marking all complete/incomplete this.allCompleted = ko.computed({ //always return true/false based on the done flag of all todos read: function () { return !this.remainingCount(); }.bind(this), // set all todos to the written value (true/false) write: function (newValue) { this.todos().forEach(function (todo) { // set even if value is the same, as subscribers are not notified in that case todo.completed(newValue); }); }.bind(this) }); // helper function to keep expressions out of markup this.getLabel = function (count) { return ko.utils.unwrapObservable(count) === 1 ? 'item' : 'items'; }.bind(this); // internal computed observable that fires whenever anything changes in our todos ko.computed(function () { // store a clean copy to local storage, which also creates a dependency on the observableArray and all observables in each item localStorage.setItem('todos-knockoutjs', ko.toJSON(this.todos)); alert(1); }.bind(this)).extend({ rateLimit: { timeout: 500, method: 'notifyWhenChangesStop' } }); // save at most twice per second }; // check local storage for todos var todos = ko.utils.parseJson(localStorage.getItem('todos-knockoutjs')); // bind a new instance of our view model to the page var viewModel = new ViewModel(todos || []); ko.applyBindings(viewModel); // set up filter routing /*jshint newcap:false */ Router({ '/:filter': viewModel.showMode }).init(); }());
JS代码解析
在上述版本todo app中,Todo可视为app的Model,包含3 个属性分别是title,completed,editing,并且这三个属性均被注册为observable的
var Todo = function (title, completed) {
this.title = ko.observable(title);
this.completed = ko.observable(completed);
this.editing = ko.observable(false);
};
在ViewModel中,首先定义了下面属性:
todos,current,showmode,filteredTodos
其中todos被注册为observableArray,并用形参todos,调用map方法为ViewModel的todos属性赋值
current没有被赋值
showmode被初始化为'all'
filteredTodos是一个依赖属性,通过ko.computed()计算获得,其值依赖于todos和showmode属性,根据showmode的值,在todos中选择合适的todo对象
在计算todos和filteredTodos属性时,发现调用了map,filter方法,这些是ECMAScript5中定义的 Array的标准方法,其余常用的还有forEach,every,some等。
this.todos = ko.observableArray(todos.map(function (todo) {
return new Todo(todo.title, todo.completed);
}));
// store the new todo value being entered
this.current = ko.observable();
this.showMode = ko.observable('all');
this.filteredTodos = ko.computed(function () {
switch (this.showMode()) {
case 'active':
return this.todos().filter(function (todo) {
return !todo.completed();
});
case 'completed':
return this.todos().filter(function (todo) {
return todo.completed();
});
default:
return this.todos();
}
}.bind(this));
接下来依次为Viewmodel定义了add,remove,removeCompleted,editItem,saveEditing,cancelEditing,completedCount,remainingCount,allCompleted,getLabel方法
其中completedCount,remainingCount,allCompleted也是通过ko.computed()计算得出
与completedCount,remainingCount不同,allCompleted同时定义了read和write方法,在write方法中,将todos集合中各个todo对象的computed属性赋值
下面片段应用到了knockout中一个扩展用法
使用了Rate-limiting observable notifications,来达到在更新发生后的指定时间,来触发ko.computed()中的匿名函数
下面片段中,localStorage在knockout判断所有更新已结束,notifyWhenChangesStop函数触发后的500毫秒后将序列化为JSON对象的todos存到浏览器localStorage中
// internal computed observable that fires whenever anything changes in our todos
ko.computed(function () {
// store a clean copy to local storage, which also creates a dependency on the observableArray and all observables in each item
localStorage.setItem('todos-knockoutjs', ko.toJSON(this.todos));
}.bind(this)).extend({
rateLimit: { timeout: 500, method: 'notifyWhenChangesStop' }
}); // save at most twice per second
在将viewmodel绑定至ko时,以下代码先从localStorage读取,如有则使用,没有则为空,利用localStorage的本地存储功能,已经可以完成一个完整体验的todo app
最下面使用了一个Router来路由All Active Completed三个tab的请求,knockout自身不包含路由模块,这里的Router是由其余模块提供的
// check local storage for todos
var todos = ko.utils.parseJson(localStorage.getItem('todos-knockoutjs'));
// bind a new instance of our view model to the page
var viewModel = new ViewModel(todos || []);
ko.applyBindings(viewModel);
// set up filter routing
/*jshint newcap:false */
Router({ '/:filter': viewModel.showMode }).init();
说完了todo Model和ViewModel,再来看一下todo app中自定义绑定
在todo app中,分别提供了对键盘回车键ENTER_KEY、取消键ESCAPE_KEY的事件绑定
当为dom元素绑定enter_key、escape_key事件时,会以当前dom元素作用域执行赋予的valueAccessor函数
在selectAndFocus自定义绑定中,同时定义了init方法和update方法,在init中为dom元素注册了foucs方法,在update方法中来触发元素的focus,其目的是为了在选中todo元素,可以立即进入可编辑的状态
// A factory function we can use to create binding handlers for specific
// keycodes.
function keyhandlerBindingFactory(keyCode) {
return {
init: function (element, valueAccessor, allBindingsAccessor, data, bindingContext) {
var wrappedHandler, newValueAccessor;
// wrap the handler with a check for the enter key
wrappedHandler = function (data, event) {
if (event.keyCode === keyCode) {
valueAccessor().call(this, data, event);
}
};
// create a valueAccessor with the options that we would want to pass to the event binding
newValueAccessor = function () {
return {
keyup: wrappedHandler
};
};
// call the real event binding's init function
ko.bindingHandlers.event.init(element, newValueAccessor, allBindingsAccessor, data, bindingContext);
}
};
}
// a custom binding to handle the enter key
ko.bindingHandlers.enterKey = keyhandlerBindingFactory(ENTER_KEY);
// another custom binding, this time to handle the escape key
ko.bindingHandlers.escapeKey = keyhandlerBindingFactory(ESCAPE_KEY);
// wrapper to hasFocus that also selects text and applies focus async
ko.bindingHandlers.selectAndFocus = {
init: function (element, valueAccessor, allBindingsAccessor, bindingContext) {
ko.bindingHandlers.hasFocus.init(element, valueAccessor, allBindingsAccessor, bindingContext);
ko.utils.registerEventHandler(element, 'focus', function () {
element.focus();
});
},
update: function (element, valueAccessor) {
ko.utils.unwrapObservable(valueAccessor()); // for dependency
// ensure that element is visible before trying to focus
setTimeout(function () {
ko.bindingHandlers.hasFocus.update(element, valueAccessor);
}, 0);
}
};
HTML View解析
在todo的输入框中,默认绑定了current属性,初始时current默认为空,所以显示的是placeholder中的值
输入框使用上上述自定义绑定,将ViewModel的add方法传给了enterKey
placeholder和autofucus都是HTML5支持的标准属性
<input id="new-todo" data-bind="value: current, enterKey: add" placeholder="What needs to be done?" autofocus>
下面片段是todo app的展示列表
列表ul元素使用foreach绑定了ViewModel的filteredTodos属性
每一个li对应一个todo对象
随着该todo被勾选为完成与否的变化,该li元素的css class同时发生变化
event:{dbclickL:$root.editItem}即为label元素绑定了双击事件,事件处理函数为ViewModel的editItem方法,而删除按钮绑定了click事件,事件处理函数为ViewModel的remove方法
当双击label元素时,li中的input输入框可见,可以对todo对象进行编辑,这里也采用了自定义绑定,同时绑定了enterKey,escapeKey,selectAndFocus事件,也绑定了标准事件blur,其事件处理函数为ViewModel的cancelEditing(该方法未实现)
<ul id="todo-list" data-bind="foreach: filteredTodos"> <li data-bind="css: { completed: completed, editing: editing }"> <div class="view"> <input class="toggle" data-bind="checked: completed" type="checkbox"> <label data-bind="text: title, event: { dblclick: $root.editItem }"></label> <button class="destroy" data-bind="click: $root.remove"></button> </div> <input class="edit" data-bind="value: title, enterKey: $root.saveEditing, escapeKey: $root.cancelEditing, selectAndFocus:editing, event: { blur: $root.cancelEditing }"> </li> </ul>
最后
以上基本解析了knockout版todo app的主要代码逻辑
更多资料,建议去官网学习
地址:http://knockoutjs.com/