深入淺出KnockoutJS
寫在前面,本文資料大多來源網上,屬於自己的學習筆記整理。
其中主要內容來自learn.knockoutjs.com,源碼解析部分資料來自司徒正美博文《knockout.js學習筆記》系列。
1. Knockout初體驗
1.1 Before Knockout
<div id=”itemName”></div> <input type=”text” id=”itemNameEdit”/>
使用JQuery,上述交互邏輯可以如下實現
var item = { id: 88, name: "Apple Pie" }; $("#itemName").text(item.name); $("#itemNameEdit").val(item.name).change(function() { item.name = $(this).val(); $("#itemName").text(item.name); });
采用這種方式的缺點
- 當UI和data的交互越來越多時,代碼量迅速增長到難以維護
-
•Dom Query Based
- 上述代碼耦合度高,不可重用
- Id、classname命名難以管理
1.2 Use Knockout
HTML View如下<div data-bind=”text:name”></div> <input type=”text” data-bind=”value:name”/>
Javascript如下
function ViewModel={ this.id=88; this.name=ko.observable(“Apple”); }; ko.applyBindings(new ViewModel());
現在,當輸入框中值發生變化時,div中顯示的值也會自動發送變化
2. Knockout基礎
2.1 MVVM模式
Knockoutjs遵循Model(M)—View(V)—ViewModel(VM)模式
2.2 單次綁定
從ViewModel綁定至UI這一層只進行一次綁定,不追蹤數據在任何一方的變化,適用於數據展現
Javascript與Html示例如下
function AppViewModel() { this.firstName = "Bert"; this.lastName = "Bertington"; } ko.applyBindings(new AppViewModel());
<p>First name: <strong data-bind="text: firstName"></strong></p> <p>Last name: <strong data-bind="text: lastName"></strong></p>
效果如下圖所示
2.3 雙向綁定
無論數據在ViewModel或者是UI中變化,將會更新另一方,最為靈活的綁定方式,同時代價最大
function AppViewModel() { this.firstName = ko.observable("Bert"); this.lastName = ko.observable("Bertington"); } ko.applyBindings(new AppViewModel());
<p>First name: <strong data-bind="text: firstName"></strong></p> <p>Last name: <strong data-bind="text: lastName"></strong></p> <p>First name: <input data-bind="value: firstName" /></p> <p>Last name: <input data-bind="value: lastName" /></p>
上述綁定,當輸入框中值發生改變時,<p>標簽中顯示內容相應發生改變
2.4 依賴綁定
以其它observable的值為基礎來組成新的值,新值也是雙向綁定的
function AppViewModel() { this.firstName = ko.observable("Bert"); this.lastName = ko.observable("Bertington"); this.fullName = ko.computed(function() { return this.firstName() + " " + this.lastName(); }, this); } ko.applyBindings(new AppViewModel());
<p>First name: <strong data-bind="text: firstName"></strong></p> <p>Last name: <strong data-bind="text: lastName"></strong></p> <p>First name: <input data-bind="value: firstName" /></p> <p>Last name: <input data-bind="value: lastName" /></p> <p>Full name: <strong data-bind="text: fullName"></strong></p>
上述代碼示例中,fullName依賴於firstName和lastName,改變firstName和lastName任意值,fullName的顯示也相應改變
2.5 綁定數組
可以為屬性綁定集合
// Class to represent a row in the seat reservations grid function SeatReservation(name, initialMeal) { var self = this; self.name = name; self.meal = ko.observable(initialMeal); } // Overall viewmodel for this screen, along with initial state function ReservationsViewModel() { var self = this; // Non-editable catalog data - would come from the server self.availableMeals = [ { mealName: "Standard (sandwich)", price: 0 }, { mealName: "Premium (lobster)", price: 34.95 }, { mealName: "Ultimate (whole zebra)", price: 290 } ]; // Editable data self.seats = ko.observableArray([ new SeatReservation("Steve", self.availableMeals[0]), new SeatReservation("Bert", self.availableMeals[0]) ]); } ko.applyBindings(new ReservationsViewModel());
<h2>Your seat reservations</h2> <table> <thead><tr> <th>Passenger name</th><th>Meal</th><th>Surcharge</th><th></th> </tr></thead> <tbody data-bind="foreach: seats"> <tr> <td data-bind="text: name"></td> <td data-bind="text: meal().mealName"></td> <td data-bind="text: meal().price"></td> </tr> </tbody> </table>
上述代碼將seats對象綁定了一個集合對象,在html view中,通過foreach指令渲染視圖,效果如下下圖
2.6 增加添加和刪除元素功能
// Class to represent a row in the seat reservations grid function SeatReservation(name, initialMeal) { var self = this; self.name = name; self.meal = ko.observable(initialMeal); self.formattedPrice = ko.computed(function() { var price = self.meal().price; return price; }); } // Overall viewmodel for this screen, along with initial state function ReservationsViewModel() { var self = this; // Non-editable catalog data - would come from the server self.availableMeals = [ { mealName: "Standard (sandwich)", price: 0 }, { mealName: "Premium (lobster)", price: 34.95 }, { mealName: "Ultimate (whole zebra)", price: 290 } ]; // Editable data self.seats = ko.observableArray([ new SeatReservation("Steve", self.availableMeals[0]), new SeatReservation("Bert", self.availableMeals[0]) ]); // Operations self.addSeat = function() { self.seats.push(new SeatReservation("", self.availableMeals[0])); } self.removeSeat = function(seat) { self.seats.remove(seat) } } ko.applyBindings(new ReservationsViewModel());
<h2>Your seat reservations</h2> <table> <thead><tr> <th>Passenger name</th><th>Meal</th><th>Surcharge</th><th></th> </tr></thead> <tbody data-bind="foreach: seats"> <tr> <td><input data-bind="value: name" /></td> <td><select data-bind="options: $root.availableMeals, value: meal, optionsText: 'mealName'"></select></td> <td data-bind="text: formattedPrice"></td> <td><a href="#" data-bind="click: $root.removeSeat">Remove</a></td> </tr> </tbody> </table> <button data-bind="click: addSeat">Reserve another seat</button>
- 上述代碼中,為viewmodel添加了addSeat和removeSeat方法。
- 調用addSeat方法時,為seats集合添加一個初始化SeatReservation對象
- 調用removeSeat方法時,knockout將當前dom元素綁定的seat對象作為參賽傳入到方法中
效果如圖
3. Knockout進階
3.1 Custom bindings
-
Binding連接view和viewmodel,除了內置bindings,你可以創建自己的binding
- 將待注冊的綁定,添加為ko.bindingHandlers的屬性,然后可以在任意dom元素中使用它
ko.bindingHandlers.yourBindingName = { init: function(element, valueAccessor, allBindings, viewModel, bindingContext) { // This will be called when the binding is first applied to an element // Set up any initial state, event handlers, etc. here }, update: function(element, valueAccessor, allBindings, viewModel, bindingContext) { // This will be called once when the binding is first applied to an element, // and again whenever any observables/computeds that are accessed change // Update the DOM element based on the supplied values here. } };
<div data-bind="yourBindingName: someValue"> </div>
custom binding示例
// ---------------------------------------------------------------------------- // Reusable bindings - ideally kept in a separate file ko.bindingHandlers.fadeVisible = { init: function(element, valueAccessor) { // Start visible/invisible according to initial value var shouldDisplay = valueAccessor(); $(element).toggle(shouldDisplay); }, update: function(element, valueAccessor) { // On update, fade in/out var shouldDisplay = valueAccessor(); shouldDisplay ? $(element).fadeIn() : $(element).fadeOut(); } }; // ---------------------------------------------------------------------------- // Page viewmodel function Answer(text) { this.answerText = text; this.points = ko.observable(1); } function SurveyViewModel(question, pointsBudget, answers) { this.question = question; this.pointsBudget = pointsBudget; this.answers = $.map(answers, function(text) { return new Answer(text) }); this.save = function() { alert('To do') }; this.pointsUsed = ko.computed(function() { var total = 0; for (var i = 0; i < this.answers.length; i++) total += this.answers[i].points(); return total; }, this); } ko.applyBindings(new SurveyViewModel("Which factors affect your technology choices?", 10, [ "Functionality, compatibility, pricing - all that boring stuff", "How often it is mentioned on Hacker News", "Number of gradients/dropshadows on project homepage", "Totally believable testimonials on project homepage" ]));
<h3 data-bind="text: question"></h3> <p>Please distribute <b data-bind="text: pointsBudget"></b> points between the following options.</p> <table> <thead><tr><th>Option</th><th>Importance</th></tr></thead> <tbody data-bind="foreach: answers"> <tr> <td data-bind="text: answerText"></td> <td><select data-bind="options: [1,2,3,4,5], value: points"></select></td> </tr> </tbody> </table> <h3 data-bind="fadeVisible: pointsUsed() > pointsBudget">You've used too many points! Please remove some.</h3> <p>You've got <b data-bind="text: pointsBudget - pointsUsed()"></b> points left to use.</p> <button data-bind="enable: pointsUsed() <= pointsBudget, click: save">Finished</button>
上述代碼定義了一個fadeVisible綁定,用來控制元素顯示動畫效果。init方法根據dom元素傳入參數當前狀態設置初始顯示效果;update方法在pointsUsed 每次發生更新時觸發,更新元素顯示效果
3.2 Template binding
- Native templating:內置,用於加強控制流程的綁定
- String-based templating:集成第三方模板引擎的方式,原理是將model value傳遞給第三方模板引擎,將結果字符串注入到當前document
Native templating示例
<h2>Participants</h2> Here are the participants: <div data-bind="template: { name: 'person-template', data: buyer }"></div> <div data-bind="template: { name: 'person-template', data: seller }"></div> <script type="text/html" id="person-template"> <h3 data-bind="text: name"></h3> <p>Credits: <span data-bind="text: credits"></span></p> </script> <script type="text/javascript"> function MyViewModel() { this.buyer = { name: 'Franklin', credits: 250 }; this.seller = { name: 'Mario', credits: 5800 }; } ko.applyBindings(new MyViewModel()); </script>
3.3 Components and Custom Elements
組件是將UI代碼組織成可復用模塊的方法
使用ko.components.register方法注冊組件,組件定義包含viewModel和template
ko.components.register('some-component-name', { viewModel: <see below>, template: <see below> });
一個like/dislike組件示例
ko.components.register('like-widget', { viewModel: function(params) { // Data: value is either null, 'like', or 'dislike' this.chosenValue = params.value; // Behaviors this.like = function() { this.chosenValue('like'); }.bind(this); this.dislike = function() { this.chosenValue('dislike'); }.bind(this); }, template: '<div class="like-or-dislike" data-bind="visible: !chosenValue()">\ <button data-bind="click: like">Like it</button>\ <button data-bind="click: dislike">Dislike it</button>\ </div>\ <div class="result" data-bind="visible: chosenValue">\ You <strong data-bind="text: chosenValue"></strong> it\ </div>' }); function Product(name, rating) { this.name = name; this.userRating = ko.observable(rating || null); } function MyViewModel() { this.products = [ new Product('Garlic bread'), new Product('Pain au chocolat'), new Product('Seagull spaghetti', 'like') // This one was already 'liked' ]; } ko.applyBindings(new MyViewModel());
<ul data-bind="foreach: products"> <li class="product"> <strong data-bind="text: name"></strong> <like-widget params="value: userRating"></like-widget> </li> </ul>
viewModel中,為products單項綁定了一個Product集合,並為第三個Product對象userRating屬性設置為like
html view中,使用like-widget指令使用上述定義的組件
效果如下圖
4. Knockout實戰
4.1 knockout版todo app

- 需要一個todo對象作為 Model
- 需要一個todos 的集合用來存儲各個todo對象
- 需要filterTodos對象,根據All,Active,Completed過濾todos集合
- 需要添加、刪除、編輯、清除等各種事件方法

4.2 todo app主要代碼分析
- Todo Model,包含3 個屬性分別是title,completed,editing
// 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); };
- todos Array、filteredTodos
// 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));
- Events binding

- Custom binding
提供了對鍵盤回車鍵ENTER_KEY、取消鍵ESCAPE_KEY的事件綁定
當為dom元素綁定enter_key、escape_key事件時,會以當前dom元素作用域執行賦予的valueAccessor函數
在selectAndFocus自定義綁定中,同時定義了init方法和update方法
在init中為dom元素注冊了foucs方法,在update方法中來觸發元素的focus,其目的是為了在選中todo元素,可以立即進入可編輯的狀態
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
<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>
5. Knockout源碼解析
5.1 ko.observable是什么
this.firstName=ko.observable(“Bert”); this.firstName(); this.firstName(“test”);
調用上面代碼發生了什么
$.observable = function(value){ var v = value;//將上一次的傳參保存到v中,ret與它構成閉包 function ret(neo){ if(arguments.length){ //setter if(v !== neo ){ v = neo; } return ret; }else{ //getter return v; } } return ret }
5.2 ko.computed是什么
this.fullName = ko.computed(function() { return this.firstName() + " " + this.lastName(); }, this);
$.computed = function(obj, scope){ //computed是由多個$.observable組成 var getter, setter if(typeof obj == "function"){ getter = obj }else if(obj && typeof obj == "object"){ getter = obj.getter; setter = obj.setter; scope = obj.scope; } var v var ret = function(neo){ if(arguments.length ){ if(typeof setter == "function"){//setter不一定存在的 if(v !== neo ){ setter.call(scope, neo); v = neo; } } return ret; }else{ v = getter.call(scope); return v; } } return ret; }
5.3 屬性依賴如何實現
調用observable中getter方法時,ret函數對象收集所有對自身的依賴對象
調用observable中setter方法時,ret函數對象想依賴對象發生通知
調用computed中getter方法時,ret函數對象將自身傳遞給依賴探測的begin方法
然后通過call()方法獲取函數值,這時,會觸發observable中相對應的getter的調用,從而收集到computed中的ret函數對象
在調用完成后,再將自身移除
$.dependencyDetection = (function () { var _frames = []; return { begin: function (ret) { _frames.push(ret); }, end: function () { _frames.pop(); }, collect: function (self) { if (_frames.length > 0) { self.list = self.list || []; var fn = _frames[_frames.length - 1]; if ( self.list.indexOf( fn ) >= 0) return; self.list.push(fn); } } }; })(); $.valueWillMutate = function(observable){ var list = observable.list if($.type(list,"Array")){ for(var i = 0, el; el = list[i++];){ el(); } } }
5.4 雙向綁定如何實現
The name is <span data-bind="text: fullName" id="node"></span>
$.buildEvalWithinScopeFunction = function (expression, scopeLevels) { var functionBody = "return (" + expression + ")"; for (var i = 0; i < scopeLevels; i++) { functionBody = "with(sc[" + i + "]) { " + functionBody + " } "; } return new Function("sc", functionBody); } $.applyBindings = function(model, node){ var nodeBind = $.computed(function (){ var str = "{" + node.getAttribute("data-bind")+"}" var fn = $.buildEvalWithinScopeFunction(str,2); var bindings = fn([node,model]); for(var key in bindings){ if(bindings.hasOwnProperty(key)){ var fn = $.bindingHandlers["text"]["update"]; var observable = bindings[key] $.dependencyDetection.collect(observable);//綁定viewModel與UI fn(node, observable) } } },node); return nodeBind } $.bindingHandlers = {} $.bindingHandlers["text"] = { 'update': function (node, observable) { var val = observable() if("textContent" in node){ node.textContent = val; } } } window.onload = function(){ var model = new MyViewModel(); var node = document.getElementById("node"); $.applyBindings(model, node); }
上述代碼中,$.buildEvalWithinScopeFunction(str,2)返回一個匿名函數
function anonymous(sc/**/) {with(sc[1]) { with(sc[0]) { return ({text: fullName}) } } }
通過var bindings = fn([node,model]),bindings得到一個{text:fullName函數對象}的對象,其中,fullName是一個組合依賴屬性,即fullName是一個computed中ret函數對象
6. 總結
6.1 優點
- 專注於data-binding,UI自動刷新,model依賴跟蹤
- 簡單易上手,學習成本低
- 輕量,方便與其他第三方JS框架集成
- 可擴展,支持自定義定制
- 瀏覽器兼容度高,幾乎支持所有現代瀏覽器
6.2 不足
- 是一個MVVM library,不是一個前端解決方案
- 缺少Router等重要模塊支持
- 缺少可測試性支持