AngularJS开发避坑指南:十个常见错误与最佳实践

作者:桌面漫游

时间:2026-10-30

浏览:310

本文总结了AngularJS开发中十个高频错误,涵盖MVC目录结构、模块划分、依赖注入、控制器膨胀、Service与Factory区别、性能优化(Watcher数量)、$scope作用域限制、测试工具使用以及jQuery的误用。通过解析源码与示例,帮助开发者规避陷阱,提升应用的可维护性与性能。

文章目录

前言

前言

AngularJS因其开发便捷、功能丰富且表现优异,被广泛应用于各类应用中。然而,随之而来的使用陷阱也不容忽视。本文将梳理AngularJS开发中一些常见的易错点,并逐一进行分析。

MVC目录结构优化

一、MVC目录结构

从直观层面看,AngularJS是一个MVC框架。虽然其Model的定义不如Backbone.js那样严格明确,但其整体架构设计是合理的。在基于MVC框架开发时,一种普遍做法是按照文件类型对代码进行分类组织:


templates/
 _login.html
 _feed.html
app/
 app.js
 controllers/
 LoginController.js
 FeedController.js
 directives/
 FeedEntryDirective.js
 services/
 LoginService.js
 FeedService.js
 filters/
 CapatalizeFilter.js

这种结构看似直观,且与Rails框架的做法相似。然而,随着应用规模的扩大,这种按类型分类的方式会导致你需要频繁切换多个目录。无论使用Sublime、Visual Studio还是Vim配合Nerd Tree插件,你都会花费大量时间在目录树中上下滑动查找文件,效率低下。

与其按文件类型划分,不如尝试按功能特性(Feature)来组织文件:


app/
 app.js
 Feed/
 _feed.html
 FeedController.js
 FeedEntryDirective.js
 FeedService.js
 Login/
 _login.html
 LoginController.js
 LoginService.js
 Shared/
 CapatalizeFilter.js

这种按特性划分的目录结构能让我们更轻松地找到与特定功能相关的所有文件,从而加快开发速度。尽管将.html模板和.js逻辑文件放在同一目录下可能存在争议,但由此节省的时间成本更具价值。

模块划分策略

二、模块

将所有代码都堆砌在主模块下是常见的做法。对于小型应用,初期可能没有问题,但随着项目推进,问题会逐渐显现。


var app = angular.module('app',[]);
app.service('MyService', function(){
 //service code
});
app.controller('MyCtrl', function($scope, MyService){
 //controller code
});

随后,一种常见的策略是将相同类型的对象进行归类:


var services = angular.module('services',[]);
services.service('MyService', function(){
 //service code
});
 
var controllers = angular.module('controllers',['services']);
controllers.controller('MyCtrl', function($scope, MyService){
 //controller code
});
 
var app = angular.module('app',['controllers', 'services']);

这种方式与第一部分讨论的按类型分类目录结构类似,同样不够理想。遵循相同的理念,按特性进行模块归类能带来更好的可扩展性。


var sharedServicesModule = angular.module('sharedServices',[]);
sharedServices.service('NetworkService', function($http){});
 
var loginModule = angular.module('login',['sharedServices']);
loginModule.service('loginService', function(NetworkService){});
loginModule.controller('loginCtrl', function($scope, loginService){});
 
var app = angular.module('app', ['sharedServices', 'login']);

在开发大型应用时,并非所有功能都会包含在单一页面中。将同一类特性封装在一个模块内,能更容易地实现模块在不同应用间的复用。

依赖注入与全局依赖处理

三、依赖注入

依赖注入是AngularJS中最优秀的模式之一,它简化了测试过程,并使对特定对象的依赖关系变得明确。AngularJS的注入方式非常灵活,最简单的方式只需将依赖的名字传入模块的function中即可:


var app = angular.module('app',[]);
 
app.controller('MainCtrl', function($scope, $timeout){
 $timeout(function(){
 console.log($scope);
 }, 1000);
});

在此示例中,MainCtrl显然依赖于$scope和$timeout。

直到你准备将应用部署到生产环境并希望精简代码时,问题才会出现。如果使用UglifyJS进行压缩,之前的代码示例会变成如下形式:


var app=angular.module("app",[]);
app.controller("MainCtrl",function(e,t){t(function(){console.log(e)},1e3)})

此时,AngularJS如何识别MainCtrl的依赖关系呢?AngularJS提供了一种简洁的解决方案:将依赖关系作为一个数组传入,数组的最后一个元素是一个函数,所有的依赖项作为该函数的参数。


app.controller('MainCtrl', ['$scope', '$timeout', function($scope, $timeout){
 $timeout(function(){
 console.log($scope);
 }, 1000);
}]);

这样做既能精简代码,又能让AngularJS明确解释这些显式的依赖关系:


app.controller("MainCtrl",["$scope","$timeout",function(e,t){t(function(){console.log(e)},1e3)}])

3.1 全局依赖

在编写AngularJS程序时,常会遇到这种情况:某个对象有一个依赖,而该对象又将其自身绑定在全局scope上。这意味着该依赖在任何AngularJS代码中都是可用的,但这破坏了依赖注入模型,并可能引发问题,尤其在测试过程中表现明显。

利用AngularJS,可以轻松将这些全局依赖封装进模块中,使其像标准AngularJS模块一样被注入。

Underscore.js是一个优秀的库,能以函数式风格简化JavaScript代码。通过以下方式,你可以将其转化为一个模块:


var underscore = angular.module('underscore', []);
underscore.factory('_', function() {
 return window._; //Underscore must already be loaded on the page
});
var app = angular.module('app', ['underscore']);
 
app.controller('MainCtrl', ['$scope', '_', function($scope, _) {
 init = function() {
  _.keys($scope);
 }
 
 init();
}]);

这种做法允许应用程序继续以AngularJS依赖注入的风格进行开发,同时在测试阶段也能方便地将Underscore替换掉。

这看起来可能琐碎且必要,但如果你的代码中启用了'use strict'(且必须启用),这种做法就是必要的。

控制器膨胀问题

四、控制器膨胀

控制器是AngularJS的核心部分,初学者容易在其中加入过多逻辑。控制器永远不应操作DOM或持有DOM选择器,那是指令和ng-model的职责。同样,业务逻辑应存在于服务中,而非控制器。

数据也应存储在服务中,除非它们已绑定到$scope上。服务是单例的,贯穿整个应用生命周期;而控制器在各状态间是瞬态的。如果数据保存在控制器中,每次重新实例化时都需要从某处重新获取数据。即使将数据存储于localStorage中,其检索速度也比JavaScript变量慢一个数量级。

AngularJS在遵循单一职责原则(SRP)时运行良好。如果控制器仅作为视图和模型间的协调者,其包含的逻辑应尽可能少,这同样有利于测试。

Service与Factory的区别

五、Service vs Factory

几乎每一位AngularJS初学者都会被这些名词困扰,这其实大可不必,因为它们只是针对几乎相同事物的语法糖而已!

以下是它们在AngularJS源代码中的定义:


function factory(name, factoryFn) { 
 return provider(name, { $get: factoryFn }); 
}
 
function service(name, constructor) {
 return factory(name, ['$injector', function($injector) {
 return $injector.instantiate(constructor);
 }]);
}

从源代码中可以看到,service仅仅是调用了factory函数,而后者又调用了provider函数。事实上,AngularJS也为一些值、常量和装饰提供额外的provider封装,这些并未导致类似的困惑,且文档清晰。

既然service仅仅是调用了factory函数,区别何在?线索在于$injector.instantiate:在这个函数中,$injector在service的构造函数中创建了一个新的实例。

以下是一个例子,展示了一个service和一个factory如何完成相同的事情:


var app = angular.module('app',[]);
 
app.service('helloWorldService', function(){
 this.hello = function() {
 return "Hello World";
 };
});
 
app.factory('helloWorldFactory', function(){
 return {
 hello: function() {
  return "Hello World";
 }
 }
});

当helloWorldService或helloWorldFactory被注入到控制器中,它们都有一个hello方法,返回“hello world”。service的构造函数在声明时被实例化了一次,同时factory对象在每一次被注入时传递,但始终只有一个factory实例。所有的providers都是单例。

既然功能相同,为何需要两种不同的风格?相对于service,factory提供了更多的灵活性,因为它可以返回函数,这些函数之后可以被新建出来。这迎合了面向对象编程中工厂模式的概念,即工厂是一个能够创建其他对象的对象。


app.factory('helloFactory', function() {
 return function(name) {
 this.name = name;
 
 this.hello = function() {
  return "Hello " + this.name;
 };
 };
});

这里是一个控制器示例,使用了service和两个factory,helloFactory返回了一个函数,当新建对象时会设置name的值。


app.controller('helloCtrl', function($scope, helloWorldService, helloWorldFactory, helloFactory) {
 init = function() {
 helloWorldService.hello(); //'Hello World'
 helloWorldFactory.hello(); //'Hello World'
 new helloFactory('Readers').hello() //'Hello Readers'
 }
 
 init();
});

在初学阶段,最好只使用service。

Factory在设计一个包含很多私有方法的类时也很有用:


app.factory('privateFactory', function(){
 var privateFunc = function(name) {
 return name.split("").reverse().join(""); //reverses the name
 };
 
 return {
 hello: function(name){
  return "Hello " + privateFunc(name);
 }
 };
});

通过这个例子,我们可以让privateFactory的公有API无法访问到privateFunc方法。这种模式在service中是可以做到的,但在factory中更容易实现。

使用Batarang进行调试与性能分析

六、没有使用Batarang

Batarang是一个出色的Chrome插件,用于开发和测试AngularJS应用。

Batarang提供了浏览模型的能力,使我们能够观察AngularJS内部是如何确定绑定到作用域上的模型的。这在处理指令以及隔离一定范围观察绑定值时非常有用。

Batarang还提供了一个依赖图。如果我们正在接触一个未经测试的代码库,这个依赖图就很有用,它能帮助我们决定哪些服务应该被重点关照。

最后,Batarang提供了性能分析功能。AngularJS开箱即用性能良好,但对于充满自定义指令和复杂逻辑的应用,有时就不那么流畅了。使用Batarang性能工具,可以直接观察到在一个digest周期中哪个函数运行了最长时间。性能工具还能展示一棵完整的watch树,在我们拥有很多watcher时,这很有用。

避免过多的watcher

七、过多的watcher

在上一点中我们提到,AngularJS能做到开箱即用,性能良好。由于需要在一个digest周期中完成脏数据检查,一旦watcher的数量增长到大约2000时,这个周期就会产生显著的性能问题。(2000这个数字不能说一定会造成性能大幅下降,但这是一个不错的经验数值。在AngularJS 1.3 release版本中,已经有一些允许严格控制digest周期的改动了。)

以下这个“立即执行的函数表达式(IIFE)”会打印出当前页面上所有的watcher的个数,你可以简单地将其粘贴到控制台中,观察结果。这段IIFE来源于Jared在StackOverflow上的回答:


(function () { 
 var root = $(document.getElementsByTagName('body'));
 var watchers = [];
 
 var f = function (element) {
 if (element.data().hasOwnProperty('$scope')) {
  angular.forEach(element.data().$scope.$$watchers, function (watcher) {
  watchers.push(watcher);
  });
 }
 
 angular.forEach(element.children(), function (childElement) {
  f($(childElement));
 });
 };
 
 f(root);
 
 console.log(watchers.length);
})();

通过这个方式得到watcher的数量,结合Batarang性能板块中的watch树,应该可以看到哪里存在重复代码,或者哪里存在不变数据同时拥有watch。

当存在不变数据,而你又想用AngularJS将其模版化,可以考虑使用bindonce。Bindonce是一个简单的指令,允许你使用AngularJS中的模版,但它并不会加入watch,这就保证了watch数量不会增长。

$scope作用域与原型继承

八、限定$scope的范围

JavaScript基于原型的继承与面向对象中基于类的继承有着微妙的区别,这通常不是什么问题,但这个微妙之处在使用$scope时就会表现出来。在AngularJS中,每个$scope都会继承父$scope,最高层称之为$rootScope。($scope与传统指令有些不同,它们有一定的作用范围i,且只继承显式声明的属性。)

由于原型继承的特点,在父类和子类间共享数据不太重要,不过如果不小心的话,也很容易误用了一个父$scope的属性。

比如说,我们需要在一个导航栏上显示一个用户名,这个用户名是在登录表单中输入的,下面这种尝试应该是能工作的:


<div ng-controller="navCtrl">
 <span>{{user}}</span>
 <div ng-controller="loginCtrl">
 <span>{{user}}</span>
 <input ng-model="user"></input>
 </div>
</div>

那么问题来了……:在text input中设置了user的ng-model,当用户在其中输入内容时,哪个模版会被更新?navCtrl还是loginCtrl,还是都会?

如果你选择了loginCtrl,那么你可能已经理解了原型继承是如何工作的了。

当你检索字面值时,原型链并不起作用。如果navCtrl也同时被更新的话,检索原型链是必须的;但如果值是一个对象,这就会发生。(记住,在JavaScript中,函数、数组和对象都是对象)

所以为了获得预期的行为,需要在navCtrl中创建一个对象,它可以被loginCtrl引用。


<div ng-controller="navCtrl">
 <span>{{user.name}}</span>
 <div ng-controller="loginCtrl">
 <span>{{user.name}}</span>
 <input ng-model="user.name"></input>
 </div>
</div>

现在,由于user是一个对象,原型链就会起作用,navCtrl模版和$scope和loginCtrl都会被更新。

这看上去是一个很做作的例子,但是当你使用某些指令去创建子$scope,如ngRepeat时,这个问题很容易就会产生。

测试工具:Protractor与Karma

九、手工测试

由于TDD可能不是每个开发人员都喜欢的开发方式,因此当开发人员检查代码是否工作或是否影响了其它东西时,他们会做手工测试。

不去测试AngularJS app,这是没有道理的。AngularJS的设计使得它从头到底都是可测试的,依赖注入和ngMock模块就是明证。AngularJS核心团队已经开发了众多能够使测试更上一层楼的工具。

9.1 Protractor

单元测试是一个测试工作的基础,但考虑到app的日益复杂,集成测试更贴近实际情况。幸运的是,AngularJS的核心团队已经提供了必要的工具。

我们已经建立了Protractor,一个端到端的测试器用以模拟用户交互,这能够帮助你验证你的AngularJS程序的健康状况。

Protractor使用Jasmine测试框架定义测试,Protractor针对不同的页面交互行为有一个非常健壮的API。

我们还有一些其他的端到端测试工具,但是Protractor的优势是它能够理解如何与AngularJS代码协同工作,尤其是在$digest周期中。

9.2 Karma

一旦我们用Protractor完成了集成测试的编写工作,接下去就是执行测试了。等待测试执行,尤其是集成测试,对每个开发人员都是一种淡淡的忧伤。AngularJS的核心团队也感到极为蛋疼,于是他们开发了Karma。

Karma是一个测试器,它有助于关闭反馈回路。Karma之所以能够做到这点,是因为它在指定文件被改变时就运行测试。Karma同时也会在多个浏览器上运行测试,不同的设备也可以指向Karma服务器,这样就能够更好地覆盖真实世界的应用场景。

避免使用jQuery

十、使用jQuery

jQuery是一个酷炫的库,它有标准化的跨平台开发,几乎已经成为了现代化Web开发的必需品。不过尽管JQuery如此多的优秀特性,它的理念和AngularJS并不一致。

AngularJS是一个用来建立app的框架,而JQuery则是一个简化“HTML文档操作、事件处理、动画和Ajax”的库。这是两者最基本的区别,AngularJS致力于程序的体系结构,与HTML页面无关。

为了更好的理解如何建立一个AngularJS程序,请停止使用jQuery。JQuery使开发人员以现存的HTML标准思考问题,但正如文档里所说的,“AngularJS能够让你在应用程序中扩张HTML这个词汇”。

DOM操作应该只在指令中完成,但这并不意味着他们只能用JQuery封装。在你使用JQuery之前,你应该总是去想一下这个功能是不是AngularJS已经提供了。当指令互相依赖时能够创建强大的工具,这确实很强大。

但一个非常棒的JQuery是必需品时,这一天可能会到来,但在一开始就引入它,是一个常见的错误。

总结

总结

AngularJS是一卓越的框架,在社区的帮助下始终在进步。虽说AngularJS仍然是一个不断发展的概念,但我希望人们能够遵循以上谈到的这些约定,避免开发AngularJS应用所遇到的那些问题。希望这篇文章的内容对大家能有有所帮助,如果有问题可以留言交流,谢谢大家对本站的支持。

实用工具推荐

电子包浆

绿化、压缩、噪点和虚拟水印,自由调节做旧程度。

使用 ›
图片压缩

选择画质和输出格式,预览压缩前后的大小。

使用 ›
相关文章