摆脱DOM操作,从TodoMVC看angularJS
取代jQuery?
我很久之前便听说了angularJS的大名,之前的leader也经常感叹angularJS的设计如何如何精妙,可叹一直没有机会深入了解,国庆长假因为没钱出游,倒是可以对他做一个了解......
根据之前的经验,就现有的前端项目,如果最初没有良好的设计,做到一定阶段一定会变得难以维护,就算最初有设计,变化无常的PM也会让你的项目BUG丛生。
一个页面的复杂程度不断的增加,依赖模块也会变得混乱,而其中最为头疼的就是页面级随心所欲的DOM操作了!
MVC类的框架可以很好的解决以上问题,而号称MVVM的angularJS在处理这种情况似乎更有话语权,所以我们今天便来好好研究其一番。
angular适合做具有复杂数据交互的前端应用,他旨在让我们摆脱繁琐的DOM操作,而将注意力集中在业务逻辑上,这里摆脱繁琐的DOM操作是个非常关键的愿景,也是很多人不太理解,甚至会将jQuery这种库与Backbone或者angularJS这种框架做对比的原因。
jQuery是非常优秀的DOM操作工具库,在DOM操作上,基本没有库能超越他了
但Backbone&angularJS这种MVC是框架提供的是完整的解决方案,甚至会依赖jQuery&zepto,他们是两个东西,不能互相比较,所以完全没有angularJS要取代jQuery的可能,而当DOM操作过于杂乱一定是你的项目出了问题。
这里举个jQuery不依赖MVC骨架的例子,我们的订单填写页,需要在商品数量变化后导致金额变化,并且没有选商品时,支付按钮不可点击:
对于一个有些经验的菜鸟来说,可能会这样写代码:
$('#reduceNum').click(function() { $('#payBar #num').text($('#curNum').html() - 1); });
对于一些有一定经验的老鸟来说,可能会这样写代码:
1 events: { 2 'click #reduceNum': reduceNumAction 3 }, 4 5 reduceNumAction: function() { 6 $('#payBar #num').text($('#curNum').html() - 1); 7 }
第一段代码可能会导致你年底加薪无望,并且在团队中没有话语权;而第二段代码积累到一定量后会让这个项目变得不可维护:
① 支付工具栏初始化状态如何显示,如果数字组件按需做异步加载,这个显示将变得更加负责。
② 哪些操作将导致支付栏变化,你如何组织这些变化的代码,是让他四散到各处,还是集合在一起,集合后导致函数过大怎么办?
③ 新增的导致工具栏变化的操作会不会对原来的操作造成影响,新增的代码放在何处?
④ 如果有地方要使用工具栏处的信息,取的信息会不会是无效的(取的时候可能正在变化),应该通过DOM取还是内存取?
⑤ 如果支付栏DOM结构如果变化,对你的程序影响有多大,如何主流程的影响,比较支付点击后只需要操作数据,不需要关注DOM?
⑥ ......
这个就是仅仅依赖jQuery要面临的问题,并且这种问题是无解的,因为这里的专注点是DOM操作而不是数据,如果将关注点变成了数据,代码就不是这样写的,DOM操作仅仅是过程而不是目的,我们代码的目的,往往是展示数据、获取数据,这点一定要清晰。
所以让我们带着这些问题:angular的优势在何处,他如何改善我们的编程体验,进入今天的学习吧。
初探angularJS
Hello World
学习任何一门语言,Hello world是必不可少的,他是我们迈向精通的唯一路径:
1 <!doctype html> 2 <html ng-app> 3 <head> 4 <script src="angular.js" type="text/javascript"></script> 5 </head> 6 <body> 7 Hello {{'World'}}! 8 </body> 9 </html>
被{{}}包裹的便是angularJS变量,上述程序稍作改变的话:
1 <!doctype html> 2 <html ng-app> 3 <head> 4 <script src="angular.js" type="text/javascript"></script> 5 </head> 6 <body> 7 <input ng-model="name" type="text" /> 8 Hello {{name}}! 9 </body> 10 </html>
便会同步显示文本框输入内容,这里通信的基础是model对应着ng-model,只要被ng-app包裹就会受angularJS控制,用angularJS自己的话说:HTML标签增强
作用域
为什么文本框中的变化会体现在外层,这个涉及到了ng-model的双向绑定知识,我们暂时不予理睬,但是外层又是从哪里读取name这个变量的呢?
在angular中,属性会存储在一个@scope(作用域)的对象上,每次我们对文本框的更新皆会通知$scope上的name属性,在angular中,$scope是连接controllers(控制器)与template(视图)的主要胶合器。
上述代码完全不涉及js代码,真实的场景中每个代码段会对controller做依赖,我们这里对代码做一些更改:
1 <!doctype html> 2 <html> 3 <head> 4 <script src="angular.js" type="text/javascript"></script> 5 </head> 6 <body ng-app="app" ng-controller="MainCtrl"> 7 <h1 ng-click="click()"> 8 Hello {{name}}! 9 </h1> 10 <script> 11 var app = angular.module('app', []); 12 app.controller('MainCtrl', function ($scope) { 13 $scope.name = 'World'; 14 $scope.click = function () { 15 $scope.name = '霹雳布袋戏'; 16 }; 17 }); 18 </script> 19 </body> 20 </html>
这里首先定义了一个application模块,后续会看见,我们每次代码一定会新建一个application,相当于命名空间的意思,后面还可以做依赖用。
接着,我们创建了一个controller模块,这里已经有点MVC的味道了,controller接受$scope属性,这个时候模板上所有子标签对这个控制器中的属性便有了访问权限,这里用到了一些angular指令
ng-app:告诉html标签已经处于angular的控制了,可以使用angular的特性
ng-controller:一个module下面可以包括多个控制器,每一个标签所属的控制器由该指令指定
上述代码是将控制器中的数据读出来,我们同样也可以将View中的数据读入到控制器:
1 <!doctype html> 2 <html> 3 <head> 4 <script src="angular.js" type="text/javascript"></script> 5 </head> 6 <body ng-app="app" ng-controller="MainCtrl"> 7 <input type="text" ng-model="message" /> 8 <h1 ng-click="click()"> 9 Hello {{name}}! 10 </h1> 11 <script> 12 var app = angular.module('app', []); 13 app.controller('MainCtrl', function ($scope) { 14 $scope.name = 'World'; 15 $scope.click = function () { 16 $scope.name = $scope.message; 17 }; 18 }); 19 </script> 20 </body> 21 </html>
PS:看到这里,老夫虎躯为之一振,对该特性的实现产生了兴趣,后续值得深入
指令
指令让我们有能力使用angular规定的方式为HTML标签增加新特性,angular内置了很多有用的指令,这里仍然举一个简单的例子说明问题:
1 <!doctype html> 2 <html> 3 <head> 4 <script src="angular.js" type="text/javascript"></script> 5 </head> 6 <body ng-app="app"> 7 <ul ng-controller="MainCtrl"> 8 <li ng-repeat="v in arr">{{v}}</li> 9 </ul> 10 <script> 11 var app = angular.module('app', []); 12 app.controller('MainCtrl', function ($scope) { 13 $scope.arr = ['素还真', '一页书', '叶小钗'] 14 }); 15 </script> 16 </body> 17 </html>
我们除了使用angular的内置指令外,还可以自定义指令,比如这里的让文本框自动获取焦点的指令:
1 <!doctype html> 2 <html> 3 <head> 4 <script src="angular.js" type="text/javascript"></script> 5 </head> 6 <body ng-app="app" ng-controller="MainCtrl"> 7 <input type="text" focus ng-model="user.name" /> 8 <button ng-click="greet()"> 9 Click here!</button> 10 <h3> 11 {{ message }}</h3> 12 <script> 13 var app = angular.module('app', []); 14 app.controller('MainCtrl', function ($scope) { 15 $scope.greet = function () { 16 $scope.message = "Hello, " + $scope.user.name; 17 } 18 }); 19 app.directive('focus', function () { 20 return { 21 link: function (scope, element, attrs) { 22 element[0].focus(); 23 } 24 }; 25 }); 26 </script> 27 </body> 28 </html>
指令的使用可以很复杂,后续我们会更加深入,这里再举一个单独使用的例子:
1 <!doctype html> 2 <html> 3 <head> 4 <script src="angular.js" type="text/javascript"></script> 5 </head> 6 <body ng-app="app"> 7 <hello></hello> 8 <script> 9 var app = angular.module('app', []); 10 app.directive('hello', function () { 11 return { 12 restrict: "E", 13 replace: true, 14 template: "<div>显示固定数据,类似自定义标签</div>" 15 } 16 }); 17 </script> 18 </body> 19 </html>
指令的定义有很多参数,可以指定该指令作为属性还是作为标签,这个我们后续再深入了解。
过滤器
感觉过滤器是参考的smarty的语法,一般而言是用作显示的增强,angular本身也提供了很多内置过滤器,比如:
1 {{ "aaaa" | uppercase }} // AAAA 2 {{ "BBBB" | lowercase }} // bbbb
感觉比较有用的是日期操作过滤器:
{{ 1427345339072 | date:'yyyy' }} // 2015 {{ 1427345339072 |date:'MM' }} // 03 {{ 1427345339072 | date:'d' }} // 26,一月中第多少天 ......
数字格式化:
{{12.13534|number:2}} // 12.14 四舍五入保留两位小数 {{10000000|number}} // 10,000,000
当然,我们可以使用自定义过滤器,比如这里我想对超出某一区间的数字加...
1 <!doctype html> 2 <html> 3 <head> 4 <script src="angular.js" type="text/javascript"></script> 5 </head> 6 <body ng-app="app" ng-controller="MainCtrl"> 7 <input type="text" ng-model="message" /> 8 <h3> 9 {{ message |myFilter }}</h3> 10 <script> 11 var app = angular.module('app', []); 12 app.controller('MainCtrl', function ($scope) { 13 $scope.message = ''; 14 }); 15 16 app.filter('myFilter', function () { 17 return function (input, param) { 18 return input.length < 5 ? input : input.substring(0, 5) + '...' 19 } 20 }); 21 </script> 22 </body> 23 </html>
具备了以上知识,我们尝试进入To都MVC看看
参考:http://www.cnblogs.com/whitewolf/p/angularjs-start.html
TodoMVC
我们由最新的TodoMVC下载代码:http://todomvc.com/,首先查看js引用情况:
1 <script src="node_modules/angular/angular.js"></script> 2 <script src="node_modules/angular-route/angular-route.js"></script> 3 <script src="js/app.js"></script> 4 <script src="js/controllers/todoCtrl.js"></script> 5 <script src="js/services/todoStorage.js"></script> 6 <script src="js/directives/todoFocus.js"></script> 7 <script src="js/directives/todoEscape.js"></script>
除了angular本体文件外,还多了个angular的扩展,做单页应用的路由功能的,这个路由代码量不大,使用和Backbone的路由比较类似;app.js为入口文件,配置路由的地方;余下是控制器文件文件以及一个localstorage的操作服务,余下就是指令了。
代码首先定义了一个模块作为本次程序的命名空间:
1 angular.module('todomvc', ['ngRoute'])
ngRoute为其依赖项,可以从route的定义看出:
1 var ngRouteModule = angular.module('ngRoute', ['ng']). 2 provider('$route', $RouteProvider), 3 $routeMinErr = angular.$$minErr('ngRoute');
这里来看看其router的配置,以及index.html的写法:
1 <!doctype html> 2 <html lang="en" data-framework="angularjs"> 3 <head> 4 <meta charset="utf-8"> 5 <title>AngularJS • TodoMVC</title> 6 <link rel="stylesheet" href="node_modules/todomvc-common/base.css"> 7 <link rel="stylesheet" href="node_modules/todomvc-app-css/index.css"> 8 <style>[ng-cloak] { display: none; }</style> 9 </head> 10 <body ng-app="todomvc"> 11 <ng-view /> 12 13 <script type="text/ng-template" id="todomvc-index.html"> 14 <section id="todoapp"> 15 <header id="header"> 16 <h1>todos</h1> 17 <form id="todo-form" ng-submit="addTodo()"> 18 <input id="new-todo" placeholder="What needs to be done?" ng-model="newTodo" ng-disabled="saving" autofocus> 19 </form> 20 </header> 21 <section id="main" ng-show="todos.length" ng-cloak> 22 <input id="toggle-all" type="checkbox" ng-model="allChecked" ng-click="markAll(allChecked)"> 23 <label for="toggle-all">Mark all as complete</label> 24 <ul id="todo-list"> 25 <li ng-repeat="todo in todos | filter:statusFilter track by $index" ng-class="{completed: todo.completed, editing: todo == editedTodo}"> 26 <div class="view"> 27 <input class="toggle" type="checkbox" ng-model="todo.completed" ng-change="toggleCompleted(todo)"> 28 <label ng-dblclick="editTodo(todo)">{{todo.title}}</label> 29 <button class="destroy" ng-click="removeTodo(todo)"></button> 30 </div> 31 <form ng-submit="saveEdits(todo, 'submit')"> 32 <input class="edit" ng-trim="false" ng-model="todo.title" todo-escape="revertEdits(todo)" ng-blur="saveEdits(todo, 'blur')" todo-focus="todo == editedTodo"> 33 </form> 34 </li> 35 </ul> 36 </section> 37 <footer id="footer" ng-show="todos.length" ng-cloak> 38 <span id="todo-count"><strong>{{remainingCount}}</strong> 39 <ng-pluralize count="remainingCount" when="{ one: 'item left', other: 'items left' }"></ng-pluralize> 40 </span> 41 <ul id="filters"> 42 <li> 43 <a ng-class="{selected: status == ''} " href="#/">All</a> 44 </li> 45 <li> 46 <a ng-class="{selected: status == 'active'}" href="#/active">Active</a> 47 </li> 48 <li> 49 <a ng-class="{selected: status == 'completed'}" href="#/completed">Completed</a> 50 </li> 51 </ul> 52 <button id="clear-completed" ng-click="clearCompletedTodos()" ng-show="completedCount">Clear completed</button> 53 </footer> 54 </section> 55 <footer id="info"> 56 <p>Double-click to edit a todo</p> 57 <p>Credits: 58 <a href="http://twitter.com/cburgdorf">Christoph Burgdorf</a>, 59 <a href="http://ericbidelman.com">Eric Bidelman</a>, 60 <a href="http://jacobmumm.com">Jacob Mumm</a> and 61 <a href="http://igorminar.com">Igor Minar</a> 62 </p> 63 <p>Part of <a href="http://todomvc.com">TodoMVC</a></p> 64 </footer> 65 </script> 66 <script src="node_modules/angular/angular.js"></script> 67 <script src="node_modules/angular-route/angular-route.js"></script> 68 <script src="js/app.js"></script> 69 <script src="js/controllers/todoCtrl.js"></script> 70 <script src="js/services/todoStorage.js"></script> 71 <script src="js/directives/todoFocus.js"></script> 72 <script src="js/directives/todoEscape.js"></script> 73 </body> 74 </html>
1 var routeConfig = { 2 controller: 'TodoCtrl', 3 templateUrl: 'todomvc-index.html', 4 resolve: { 5 store: function (todoStorage) { 6 // Get the correct module (API or localStorage). 7 return todoStorage.then(function (module) { 8 module.get(); // Fetch the todo records in the background. 9 return module; 10 }); 11 } 12 } 13 }; 14 15 $routeProvider 16 .when('/', routeConfig) 17 .when('/:status', routeConfig) 18 .otherwise({ 19 redirectTo: '/' 20 });
这个代码现在基本看不懂,大概意思应该就是根据路由执行config中的逻辑,将模板展示在页面上,其中index.html有一段代码应该是用于替换模板的:
<ng-view />
我们先抛开那段看不懂的,直奔主流程,目光聚焦到控制器controller:
1 angular.module('todomvc') 2 .controller('TodoCtrl', function TodoCtrl($scope, $routeParams, $filter, store) { 3 'use strict'; 4 5 var todos = $scope.todos = store.todos; 6 7 $scope.newTodo = ''; 8 $scope.editedTodo = null; 9 10 $scope.$watch('todos', function () { 11 $scope.remainingCount = $filter('filter')(todos, { completed: false }).length; 12 $scope.completedCount = todos.length - $scope.remainingCount; 13 $scope.allChecked = !$scope.remainingCount; 14 }, true); 15 16 // Monitor the current route for changes and adjust the filter accordingly. 17 $scope.$on('$routeChangeSuccess', function () { 18 var status = $scope.status = $routeParams.status || ''; 19 $scope.statusFilter = (status === 'active') ? 20 { completed: false } : (status === 'completed') ? 21 { completed: true } : {}; 22 }); 23 24 $scope.addTodo = function () { 25 var newTodo = { 26 title: $scope.newTodo.trim(), 27 completed: false 28 }; 29 30 if (!newTodo.title) { 31 return; 32 } 33 34 $scope.saving = true; 35 store.insert(newTodo) 36 .then(function success() { 37 $scope.newTodo = ''; 38 }) 39 .finally(function () { 40 $scope.saving = false; 41 }); 42 }; 43 44 $scope.editTodo = function (todo) { 45 $scope.editedTodo = todo; 46 // Clone the original todo to restore it on demand. 47 $scope.originalTodo = angular.extend({}, todo); 48 }; 49 50 $scope.saveEdits = function (todo, event) { 51 // Blur events are automatically triggered after the form submit event. 52 // This does some unfortunate logic handling to prevent saving twice. 53 if (event === 'blur' && $scope.saveEvent === 'submit') { 54 $scope.saveEvent = null; 55 return; 56 } 57 58 $scope.saveEvent = event; 59 60 if ($scope.reverted) { 61 // Todo edits were reverted-- don't save. 62 $scope.reverted = null; 63 return; 64 } 65 66 todo.title = todo.title.trim(); 67 68 if (todo.title === $scope.originalTodo.title) { 69 $scope.editedTodo = null; 70 return; 71 } 72 73 store[todo.title ? 'put' : 'delete'](todo) 74 .then(function success() {}, function error() { 75 todo.title = $scope.originalTodo.title; 76 }) 77 .finally(function () { 78 $scope.editedTodo = null; 79 }); 80 }; 81 82 $scope.revertEdits = function (todo) { 83 todos[todos.indexOf(todo)] = $scope.originalTodo; 84 $scope.editedTodo = null; 85 $scope.originalTodo = null; 86 $scope.reverted = true; 87 }; 88 89 $scope.removeTodo = function (todo) { 90 store.delete(todo); 91 }; 92 93 $scope.saveTodo = function (todo) { 94 store.put(todo); 95 }; 96 97 $scope.toggleCompleted = function (todo, completed) { 98 if (angular.isDefined(completed)) { 99 todo.completed = completed; 100 } 101 store.put(todo, todos.indexOf(todo)) 102 .then(function success() {}, function error() { 103 todo.completed = !todo.completed; 104 }); 105 }; 106 107 $scope.clearCompletedTodos = function () { 108 store.clearCompleted(); 109 }; 110 111 $scope.markAll = function (completed) { 112 todos.forEach(function (todo) { 113 if (todo.completed !== completed) { 114 $scope.toggleCompleted(todo, completed); 115 } 116 }); 117 }; 118 });
这段代码130行不到,让我体会到了深深的神奇,首先我们在app中返回了读取到localstorage的对象:
1 resolve: { 2 store: function (todoStorage) { 3 // Get the correct module (API or localStorage). 4 return todoStorage.then(function (module) { 5 module.get(); // Fetch the todo records in the background. 6 return module; 7 }); 8 } 9 }
然后就在controller的依赖项中读到了被注入的对象:
var todos = $scope.todos = store.todos;
此时,模板也被插到了页面上,等待controller的执行:
首先这里有一个$watch方法,监控着todos的变化,每次变化都会体现到这里,导致view的变化:
1 $scope.$watch('todos', function () { 2 $scope.remainingCount = $filter('filter')(todos, { completed: false }).length; 3 $scope.completedCount = todos.length - $scope.remainingCount; 4 $scope.allChecked = !$scope.remainingCount; 5 }, true);
然后我们将关注点放在新增项目上:
1 $scope.addTodo = function () { 2 var newTodo = { 3 title: $scope.newTodo.trim(), 4 completed: false 5 }; 6 7 if (!newTodo.title) { 8 return; 9 } 10 11 $scope.saving = true; 12 store.insert(newTodo) 13 .then(function success() { 14 $scope.newTodo = ''; 15 }) 16 .finally(function () { 17 $scope.saving = false; 18 }); 19 };
View上的调用点是:
1 <header id="header"> 2 <h1>todos</h1> 3 <form id="todo-form" ng-submit="addTodo()"> 4 <input id="new-todo" placeholder="What needs to be done?" ng-model="newTodo" ng-disabled="saving" autofocus> 5 </form> 6 </header>
首先这段代码中有一个autofocus的指令,没有什么卵用:
1 angular.module('todomvc') 2 .directive('todoFocus', function todoFocus($timeout) { 3 'use strict'; 4 5 return function (scope, elem, attrs) { 6 scope.$watch(attrs.todoFocus, function (newVal) { 7 if (newVal) { 8 $timeout(function () { 9 elem[0].focus(); 10 }, 0, false); 11 } 12 }); 13 }; 14 });
可以看到model直接绑定到了该文本框上,所以addTodo方法可以直接根据$scope获取文本框的属性,完了调用单例store提供的静态方法存储数据,saving参数可以暂时将文本框变成不可编辑状态,而后todo数据更新,会自动引发View变化,于是流程结束!!!
我们如果将$scope放到全局上对其数据造成变化:
window.sss = $scope; //控制台中造成变化 sss.todos.pop()
每次返回操作视图时候,该变化会马上反应到View上,于是我发现了以下不同:
① 因为所有与业务相关的数据全部做了双向绑定,我根本没有必要由dom获取数据了,我自然而然的到$scope中获取数据,不知道为什么,这个特性让我有点愉悦!
② 我要做的事情其实就是约定好数据对象,然后将该对象放到要用到的所有视图上即可,每次内存中数据变化Dom会同步更新
于是通过以上两点,我似乎得到了一个惊人的结论:
似乎我一旦配置好ng-model后,我要做的事情仅仅是操作$scope上的数据!!!
因为,前端要做的事情只不过是正确的展示服务器端的数据,每次DOM事件造成的改变也往往是数据引起的,如果我们能做到数据变化自动更新到DOM变化的话,那么DOM操作的必要似乎没有了,而angular干的事情正是如此!!!
思考
到此为止,TodoMVC的代码我虽然没有完全看懂,但是他带给我的震撼是全方位的,之前使用MVC类框架可以规范数据到DOM的操作,很大程度上解除DOM和JavaScript的耦合关系,而angular似乎完全抛开了业务数据导致的DOM变化操作!!!
我们现在团队有一mis后台系统,我在考虑是否要把它接过来,使用angular+bootstrap重构,可能别有一番风味吧!
最后,今天初步调研了一下angularJS,就已经感受到他的魅力了,后面时间需要将之用于实践,并且对其设计思想作深入研究!!!