在AngularJS框架中处理数据建模的方式解析
我们知道,AngularJS并没有自带立等可用的数据建模方案。而是以相当抽象的方式,让我们在controller中使用JSON数据作为模型。但是随着时间的推移和项目的成长,我意识到这种建模的方式不再能满足我们项目的需求。在这篇文章中我会介绍在我的AngularJS应用中处理数据建模的方式。
为Controller定义模型
让我们从一个简单的例子开始。我想要显示一个书本(book)的页面。下面是控制器(Controller):
BookController
app.controller('BookController',['$scope',function($scope){
$scope.book={
id:1,
name:'HarryPotter',
author:'J.K.Rowling',
stores:[
{id:1,name:'Barnes&Noble',quantity:3},
{id:2,name:'Waterstones',quantity:2},
{id:3,name:'BookDepository',quantity:5}
]
};
}]);
这个控制器创建了一个书本的模型,我们可以在后面的模板中(templage)中使用它。
templatefordisplayingabook
<divng-controller="BookController"> Id:<spanng-bind="book.id"></span> Name:<inputtype="text"ng-model="book.name"/> Author:<inputtype="text"ng-model="book.author"/> </div>
假如我们需要从后台的api获取书本的数据,我们需要使用$http:
BookControllerwith$http
app.controller('BookController',['$scope','$http',function($scope,$http){
varbookId=1;
$http.get('ourserver/books/'+bookId).success(function(bookData){
$scope.book=bookData;
});
}]);
注意到这里的bookData仍然是一个JSON对象。接下来我们想要使用这些数据做一些事情。比如,更新书本信息,删除书本,甚至其他的一些不涉及到后台的操作,比如根据请求的图片大小生成一个书本图片的url,或者判断书本是否有效。这些方法都可以被定义在控制器中。
BookControllerwithseveralbookactions
app.controller('BookController',['$scope','$http',function($scope,$http){
varbookId=1;
$http.get('ourserver/books/'+bookId).success(function(bookData){
$scope.book=bookData;
});
$scope.deleteBook=function(){
$http.delete('ourserver/books/'+bookId);
};
$scope.updateBook=function(){
$http.put('ourserver/books/'+bookId,$scope.book);
};
$scope.getBookImageUrl=function(width,height){
return'our/image/service/'+bookId+'/width/height';
};
$scope.isAvailable=function(){
if(!$scope.book.stores||$scope.book.stores.length===0){
returnfalse;
}
return$scope.book.stores.some(function(store){
returnstore.quantity>0;
});
};
}]);
然后在我们的模板中:
templatefordisplayingacompletebook
<divng-controller="BookController">
<divng-style="{backgroundImage:'url('+getBookImageUrl(100,100)+')'}"></div>
Id:<spanng-bind="book.id"></span>
Name:<inputtype="text"ng-model="book.name"/>
Author:<inputtype="text"ng-model="book.author"/>
IsAvailable:<spanng-bind="isAvailable()?'Yes':'No'"></span>
<buttonng-click="deleteBook()">Delete</button>
<buttonng-click="updateBook()">Update</button>
</div>
在controllers之间共享Model
如果书本的结构和方法只和一个控制器有关,那我们现在的工作已经可以应付。但是随着应用的增长,会有其他的控制器也需要和书本打交道。那些控制器很多时候也需要获取书本,更新它,删除它,或者获得它的图片url以及看它是否有效。因此,我们需要在控制器之间共享这些书本的行为。我们需要使用一个返回书本行为的factory来实现这个目的。在动手写一个factory之前,我想在这里先提一下,我们创建一个factory来返回带有这些book辅助方法的对象,但我更倾向于使用prototype来构造一个Book类,我觉得这是更正确的选择:
Bookmodelservice
app.factory('Book',['$http',function($http){
functionBook(bookData){
if(bookData){
this.setData(bookData):
}
//Someotherinitializationsrelatedtobook
};
Book.prototype={
setData:function(bookData){
angular.extend(this,bookData);
},
load:function(id){
varscope=this;
$http.get('ourserver/books/'+bookId).success(function(bookData){
scope.setData(bookData);
});
},
delete:function(){
$http.delete('ourserver/books/'+bookId);
},
update:function(){
$http.put('ourserver/books/'+bookId,this);
},
getImageUrl:function(width,height){
return'our/image/service/'+this.book.id+'/width/height';
},
isAvailable:function(){
if(!this.book.stores||this.book.stores.length===0){
returnfalse;
}
returnthis.book.stores.some(function(store){
returnstore.quantity>0;
});
}
};
returnBook;
}]);
这种方式下,书本相关的所有行为都被封装在Book服务内。现在,我们在BookController中来使用这个亮眼的Book服务。
BookControllerthatusesBookmodel
app.controller('BookController',['$scope','Book',function($scope,Book){
$scope.book=newBook();
$scope.book.load(1);
}]);
正如你看到的,控制器变得非常简单。它创建一个Book实例,指派给scope,并从后台加载。当书本被加载成功时,它的属性会被改变,模板也随着被更新。记住其他的控制器想要使用书本功能,只要简单地注入Book服务即可。此外,我们还要改变template使用book的方法。
templatethatusesbookinstance
<divng-controller="BookController">
<divng-style="{backgroundImage:'url('+book.getImageUrl(100,100)+')'}"></div>
Id:<spanng-bind="book.id"></span>
Name:<inputtype="text"ng-model="book.name"/>
Author:<inputtype="text"ng-model="book.author"/>
IsAvailable:<spanng-bind="book.isAvailable()?'Yes':'No'"></span>
<buttonng-click="book.delete()">Delete</button>
<buttonng-click="book.update()">Update</button>
</div>
到这里,我们知道了如何建模一个数据,把他的方法封装到一个类中,并且在多个控制器中共享它,而不需要写重复代码。
在多个控制器中使用相同的书本模型
我们定义了一个书本模型,并且在多个控制器中使用了它。在使用了这种建模架构之后你会注意到有一个严重的问题。到目前为止,我们假设多个控制器对书本进行操作,但如果有两个控制器同时处理同一本书会是什么情况呢?
假设我们页面的一块区域我们所有书本的名称,另一块区域可以更新某一本书。对应这两块区域,我们有两个不同的控制器。第一个加载书本列表,第二个加载特定的一本书。我们的用户在第二块区域中修改了书本的名称并且点击“更新”按钮。更新操作成功后,书本的名称会被改变。但是在书本列表中,这个用户始终看到的是修改之前的名称!真实的情况是我们对同一本书创建了两个不同的书本实例——一个在书本列表中使用,而另一个在修改书本时使用。当用户修改书本名称的时候,它实际上只修改了后一个实例中的属性。然而书本列表中的书本实例并未得到改变。
解决这个问题的办法是在所有的控制器中使用相同的书本实例。在这种方式下,书本列表和书本修改的页面和控制器都持有相同的书本实例,一旦这个实例发生变化,就会被立刻反映到所有的视图中。那么按这种方式行动起来,我们需要创建一个booksManager服务(我们没有大写开头的b字母,是因为这是一个对象而不是一个类)来管理所有的书本实例池,并且富足返回这些书本实例。如果被请求的书本实例不在实例池中,这个服务会创建它。如果已经在池中,那么就直接返回它。请牢记,所有的加载书本的方法最终都会被定义在booksManager服务中,因为它是唯一的提供书本实例的组件。
booksManagerservice
app.factory('booksManager',['$http','$q','Book',function($http,$q,Book){
varbooksManager={
_pool:{},
_retrieveInstance:function(bookId,bookData){
varinstance=this._pool[bookId];
if(instance){
instance.setData(bookData);
}else{
instance=newBook(bookData);
this._pool[bookId]=instance;
}
returninstance;
},
_search:function(bookId){
returnthis._pool[bookId];
},
_load:function(bookId,deferred){
varscope=this;
$http.get('ourserver/books/'+bookId)
.success(function(bookData){
varbook=scope._retrieveInstance(bookData.id,bookData);
deferred.resolve(book);
})
.error(function(){
deferred.reject();
});
},
/*PublicMethods*/
/*Usethisfunctioninordertogetabookinstancebyit'sid*/
getBook:function(bookId){
vardeferred=$q.defer();
varbook=this._search(bookId);
if(book){
deferred.resolve(book);
}else{
this._load(bookId,deferred);
}
returndeferred.promise;
},
/*Usethisfunctioninordertogetinstancesofallthebooks*/
loadAllBooks:function(){
vardeferred=$q.defer();
varscope=this;
$http.get('ourserver/books)
.success(function(booksArray){
varbooks=[];
booksArray.forEach(function(bookData){
varbook=scope._retrieveInstance(bookData.id,bookData);
books.push(book);
});
deferred.resolve(books);
})
.error(function(){
deferred.reject();
});
returndeferred.promise;
},
/*Thisfunctionisusefulwhenwegotsomehowthebookdataandwewishtostoreitorupdatethepoolandgetabookinstanceinreturn*/
setBook:function(bookData){
varscope=this;
varbook=this._search(bookData.id);
if(book){
book.setData(bookData);
}else{
book=scope._retrieveInstance(bookData);
}
returnbook;
},
};
returnbooksManager;
}]);
下面是我们的EditableBookController和BooksListController两个控制器的代码:
EditableBookControllerandBooksListControllerthatusesbooksManager
app.factory('Book',['$http',function($http){
functionBook(bookData){
if(bookData){
this.setData(bookData):
}
//Someotherinitializationsrelatedtobook
};
Book.prototype={
setData:function(bookData){
angular.extend(this,bookData);
},
delete:function(){
$http.delete('ourserver/books/'+bookId);
},
update:function(){
$http.put('ourserver/books/'+bookId,this);
},
getImageUrl:function(width,height){
return'our/image/service/'+this.book.id+'/width/height';
},
isAvailable:function(){
if(!this.book.stores||this.book.stores.length===0){
returnfalse;
}
returnthis.book.stores.some(function(store){
returnstore.quantity>0;
});
}
};
returnBook;
}]);
需要注意的是,模块(template)中还是保持原来使用book实例的方式。现在应用中只持有一个id为1的book实例,它发生的所有改变都会被反映到使用它的各个页面上。
AngularJS中的一些坑
UI的闪烁
Angular的自动数据绑定功能是亮点,然而,他的另一面是:在Angular初始化之前,页面中可能会给用户呈现出没有解析的表达式。当DOM准备就绪,Angular计算并替换相应的值。这样就会导致出现一个丑陋的闪烁效果。
上述情形就是在Angular教程中渲染示例代码的样子:
<bodyng-controller="PhoneListCtrl">
<ul>
<ling-repeat="phoneinphones">
{{phone.name}}
<p>{{phone.snippet}}</p>
</li>
</ul>
</body>
如果你做的是SPA(SinglePageApplication),这个问题只会在第一次加载页面的时候出现,幸运的是,可以很容易杜绝这种情形发生:放弃{{}}表达式,改用ng-bind指令
<bodyng-controller="PhoneListCtrl"> <ul> <ling-repeat="phoneinphones"> <spanng-bind="phone.name"></span> <png-bind="phone.snippet">Optional:visuallypleasingplaceholder</p> </li> </ul> </body>
你需要一个tag来包含这个指令,所以我添加了一个<span>给phonename.
那么初始化的时候会发生什么呢,这个tag里的值会显示(但是你可以选择设置空值).然后,当Angular初始化并用表达式结果替换tag内部值,注意你不需要在ng-bind内部添加大括号。更简洁了!如果你需要符合表达式,那就用ng-bind-template吧,
如果用这个指令,为了区分字符串字面量和表达式,你需要使用大括号
另外一种方法就是完全隐藏元素,甚至可以隐藏整个应用,直到Angular就绪。
Angular为此还提供了ng-cloak指令,工作原理就是在初始化阶段inject了css规则,或者你可以包含这个css隐藏规则到你自己的stylesheet。Angular就绪后就会移除这个cloak样式,让我们的应用(或者元素)立刻渲染。
Angular并不依赖jQuery。事实上,Angular源码里包含了一个内嵌的轻量级的jquery:jqLite.当Angular检测到你的页面里有jQuery出现,他就会用这个jQuery而不再用jqLite,直接证据就是Angular里的元素抽象层。比如,在directive中访问你要应用到的元素。
angular.module('jqdependency',[])
.directive('failswithoutjquery',function(){
return{
restrict:'A',
link:function(scope,element,attrs){
element.hide(4000)
}
}
});
但是这个元素jqLite还是jQuery元素呢?取决于,手册上这么写的:
Angular中所有的元素引用都会被jQuery或者jqLite包装;他们永远不是纯DOM引用
所以Angular如果没有检测到jQuery,那么就会使用jqLite元素,hide()方法值能用于jQuery元素,所以说这个示例代码只能当检测到jQuery时才可以使用。如果你(不小心)修改了AngularJS和jQuery的出现顺序,这个代码就会失效!虽说没事挪脚本的顺序的事情不经常发生,但是在我开始模块化代码的时候确实给我造成了困扰。尤其是当你开始使用模块加载器(比如RequireJS),我的解决办法是在配置里显示的声明Angular确实依赖jQuery
另外一种方法就是你不要通过Angular元素的包装来调用jQuery特定的方法,而是使用$(element).hide(4000)来表明自己的意图。这样依赖,即使修改了script加载顺序也没事。
压缩
特别需要注意的是Angular应用压缩问题。否则错误信息比如‘Unknownprovider:aProvider <-a'会让你摸不到头脑。跟其他很多东西一样,这个错误在官方文档里也是无从查起的。简而言之,Angular依赖参数名来进行依赖注入。压缩器压根意识不到这个这跟Angular里普通的参数名有啥不同,尽可能的把脚本变短是他们职责。咋办?用“友好压缩法”来进行方法注入。看这里:
module.service('myservice',function($http,$q){
//Thisbreakswhenminified
});
tothis:
module.service('myservice',['$http','$q',function($http,$q){
//Usingthearraysyntaxtodeclaredependenciesworkswithminification<b>!</b>
}]);
这个数组语法很好的解决了这个问题。我的建议是从现在开始照这个方法写,如果你决定压缩JavaScript,这个方法可以让你少走很多弯路。好像是一个automaticrewriter机制,我也不太清楚这里面是怎么工作的。
最终一点建议:如果你想用数组语法复写你的functions,在所有Angular依赖注入的地方应用之。包括directives,还有directive里的controllers。别忘了逗号(经验之谈)
//thedirectiveitselfneedsarrayinjectionsyntax:
module.directive('directive-with-controller',['myservice',function(myservice){
return{
controller:['$timeout',function($timeout){
//butthiscontrollerneedsarrayinjectionsyntax,too!
}],
link:function(scope,element,attrs,ctrl){
}
}
}]);
注意:linkfunction不需要数组语法,因为他并没有真正的注入。这是被Angular直接调用的函数。Directive级别的依赖注入在linkfunction里也是使用的。
Directive永远不会‘完成'
在directive中,一个令人掉头发的事就是directive已经‘完成'但你永远不会知道。当把jQuery插件整合到directive里时,这个通知尤为重要。假设你想用ng-repeat把动态数据以jQuerydatatable的形式显示出来。当所有的数据在页面中加载完成后,你只需要调用$(‘.mytable).dataTable()就可以了。但是,臣妾做不到啊!
为啥呢?Angular的数据绑定是通过持续的digest循环实现的。基于此,Angular框架里根本没有一个时间是‘休息'的。一个解决方法就是将jQuerydataTable的调用放在当前digest循环外,用timeout方法就可以做到。
angular.module('table',[]).directive('mytable',['$timeout',function($timeout){
return{
restrict:'E',
template:'<tableclass="mytable">'+
'<thead><tr><th>counting</th></tr></thead>'+
'<trng-repeat="dataindatas"><td></td></tr>'+
'</table>',
link:function(scope,element,attrs,ctrl){
scope.datas=["one","two","three"]
//Doesn'twork,showsanemptytable:
//$('.mytable',element).dataTable()
//Butthisdoes:
$timeout(function(){
$('.mytable',element).dataTable();
},0)
}
}
}]);