AngularJS: 使用Scope时的6个陷阱
发布在用Angular开发web应用2015年8月7日view:38951Angularjs
在文章任何区域双击击即可给文章添加【评注】!浮到评注点上可以查看详情。

AngularJS: 使用Scope时的6个陷阱

enter image description here

在使用AngularJS中的scope时,会有6个主要陷阱。如果你理解AngularJS背后的概念的话,这6个点其实非常的简单。但是在具体讲述这6个陷阱之前我们先要讲两个其它的概念。

概念1: 双向数据绑定

双向数据绑定是AngularJS中非常重要的一个部分。一般的绑定对于我们来说已经非常熟悉了。即使你没有听说过双向数据绑定,你一定使用过它。

普通的绑定一般是用来数据数据的,它实际上是模板引擎的一个基本概念:

Hello {{username}}

如果将变量username设置为John Doe,上面的例子会被渲染为:

Hello John Doe!

这是双向数据绑定的第一个方向。你可以在文档中查看ng-bind的详细内容。

在模板中这个功能已经足够用了,因为模板本来就是用来输出内容的。然而,在使用HTML构建用户界面时你依然可以使用双向数据绑定来处理用户输入。下面是一个例子:

<input ng-model='username'>
<p>Hello {{username}}</p>

只有当框架本身支持逆向绑定时,上面的例子才可以在不需要施加任何额外魔法的前提下正常运行(网络onkeyup或者onchang事件吧!)。

这是双向绑定的第二个方向。你可以在文档中查看ng-model的详细内容。

如果你将两个方向一起使用,你就已经使用了AngularJS中的双向数据绑定,它将能够整合从视图到模型之间的数据。

而绑定中数据的来源,我们叫做作用域(scope)。

和其他的数据绑定框架不同,AngularJS并没有将对象包装在存取器中,正是因为如此,你不需要定义一个包含特定getter和setter的对象。出去其中的一些功能(像是$broadcast,$apply,$digest,$emit以及$watch)和引用(像是$parentScope),作用域基本上就是一个包含一些属性和值的普通对象。你可以像对待一个普通对象一样在scope中存取数据,同时这些发生在作用域中的变化并不会被作用域本身所识别。任何变化都需要使用$apply方法来调用一个digest循环。然而,如果没有特别指明的话你并不需要去关注这件事。

有时,每发生一次变化就去调用一个digest循环并不是很合适的做法因为这势必会影响应用的性能。比如一个聊天客户端,它每秒都会在scope中添加一些特定的信息。为了让你的应用不至于变得慢吞吞,你最好限制digest循环的数量。简而言之,通过使用$scope.$apply()方法隐式调用AngularJS中的digest循环将会运行模板中的所有表达式和监视器。

概念2: 声明式UI

在AngularJS中,你需要遵循的一条规则就是“创建可充用的组件指令来扩展你的HTML”,因为它可以保持你的代码的可重用性。

因为你很可能是一位jQuery开发者,你可能已经非常熟悉了“jQuery”式的开发方式,例如添加CSS样式的方式(addClass()函数)和隐藏元素(hide()函数)的方式。这样的方法被称为是命令式的:

你显式的告诉计算机你想要在特定的环境下运行代码,例如将代码包裹在一个if语句中。
AngularJS使用的方法是声明式的:

你需要在视图中声明如何显示一个特定的环境。

假设你现在有一个导航列表,其中包含一些项目。如果一个项目被选中了,这个项目应该添加一个叫做active的类。

在下面的例子中,第一个项目被标记为active:

<ul class="navigation">
    <li class="item item1 active">Item 1</li>
    <li class="item item2">Item 2</li>
    <li class="item item3">Item 3</li>
<ul>   

jQuery式的编程方式会首先移除所有的active类,并在其中一个项目上添加active类。但是究竟应该在哪一个项目上添加active类呢?你必须在JavaScript中提供一个额外的绑定来决定添加类的项目,可能是一个额外的类或者一些data-属性。

我们来看看AngularJS应该怎么做:

<ul class="navigation">
    <li ng-repeat="item in items"
        class="item"
        ng-class="{'active': item.id == activeItem}">{{item.title}}</li>
</ul>   

为了代码能够正常运行,我们需要在作用域中添加以下内容:

$scope.activeItem = 'item1';
$scope.items = [{
    id: 'item1', title: 'Item 1'
}, {
    id: 'item2', title: 'Item 2'
}, {
    id: 'item3', title: 'Item 3'
}];  

首先,这个例子使用了ng-repeat指令,这个指令将会迭代所有的items中的项目并且按照同样的顺序创建HTML元素。在这个例子中创建了三个<li>元素。

ng-class指令声明式的描述了active类应该在什么时候被使用。这个类仅仅只会在item.id==activeItem结果为true时被添加。由于我们有双向数据绑定,因此当你将$scope.activeItem修改为item2时,标签也会自动发生改变。你不需要编写任何代码来修改你的业务逻辑。在AngularJS中,行为应该在模板中被描述。

这意味着,你可以使用声明式的方式来轻松地创建标签栏,滑动按钮,自动滚屏区域,可拖拽窗口或者一个上下文菜单。

在讨论完了AngularJS中的双向数据绑定以及声明式UI之后,我们来看看在使用这些技术时会遇到的问题。

陷阱1: Scope digester和表达式

当在视图或者监视器中使用表达式时,你应该总是记住每当AngularJS认为需要的时候,表达式总是会被调用。因此,可能并不能获得函数的性能,你甚至可能错过一些change事件。

这意味着:

  1. 带有一个ng-repeat的表达式将会分别调用每个项目。另外,AngularJS将会使用repeat指令来决定数据变化。
  2. 一个表达式可能在一次digest循环中被多次估值(evaluation)。当你使用多个指令或者额外的作用于监视器时,这种情况会发生。
  3. 即使在作用域不会改变时依然会被估值。
  4. 如果表达式包含一个函数,在函数的返回值发生变化时,表达式不会被估值。但是在函数的定义发生变化时会被估值。

例如,我们拥有一个表达式: stat === getUserState()。有以下几种可能情况:

  • 函数仅仅返回scope.currentUserState: 此时我们可以抛弃函数,直接使用数据。这种表达式在未来会逐渐被优化。
  • 这个函数会进行一些业务逻辑计算: 每次表达式被估值时,这些逻辑都会运行。更好的方法是在作用域中计算和编写当前用户状态。这种方法将能把逻辑和用户状态、视图进行解耦。一般来说数据就是作用域,作用域就是数据。
  • 函数会从作用域之外的地方获取数据: 这种方法非常非常不好。作用域/AngularJS在发生变化时并不会得到通知。记住只有在AngularJS认为作用域发生了变化时,它才会调用一个digest循环,所有表达式才会受到影响。

有时,第二种、第三种情况会同时发生。

如果你使用了外部的数据(或者数据变化) – 例如,一个外部的jQuery插件会改变状态 – 你必须为作用域提供这些数据。给定一个指令,你可能会有一个能够访问当前作用域的回调函数。你可能会注意到作用域上的任何变化将不会更新任何的UI,因为AngularJS不会注意到作用域发生了变化。

然而,你可以调用AngularJS中的$scope.$apply()函数,它将会调用所有digest循环,监视器和相关数据估值。

尽管如此,你还是应该尽量避免使用$apply()或者它的兄弟$digest()。在真实的外部事件(jQuery回调,浏览器事件回调等等)之外,你可能会实现错误的代码架构。

注意到如果你在一个正在运行的digest循环中调用一个digest/apply,你可能会遇到像是”Digest already in progress”这样的错误。这也是为什么应该在表达式中避免函数。

下面的代码是一种普遍的错误使用函数方法:

<ul>
    <li ng-repeat="item in loadItems()">{{item.title}}</li>
</ul>

这里出现的问题是调用了一个loadItems()函数。这个表达式将不会被正确的估值:这个指令本身会添加一些原数据到模型中以决定列表中的哪些项目应该被添加,移除或者仅仅是移动。建议的做法是在ng-repeat中使用数组。告诉你自己:调用loadItems是命令式的,我们应该声明式的给定数据。

最佳实践:

  • 不要在表达式中使用函数。
  • 不要使用表达式所在作用域以外的数据。
  • 当应用外部数据变化时使用$scope.$apply()。

使用这些最佳实践将能够获取高效的代码,同时也不会错过事件。

陷阱2: 引用一个DOM元素

在指令中使用DOM元素是正确的。可以将它们存放在一个变量中。但是永远不要再作用域中存储DOM元素。

DOM元素是巨大的DOM树的一部分,同时DOM树的本性是它知道自己的父元素,子元素和兄弟元素。如果你旨在作用域中存储了一个DOM元素,作用域digest循环将会查找它本身以及它的父元素和父元素的父元素。这意味着digest将会检查整个DOM树来查找变化的部分。如果你觉得这还不够疯狂,还有更恐怖的事情:因为每个DOM元素都会拥有额外的引用,digest循环将会不止一次的遍历整个DOM树。

你并不像这样做,因为这很疯狂。

最佳实践:

  • 不要在作用域中存储DOM元素,这回引起内存泄露。

陷阱3: 在指令外面使用DOM元素

不要在指令外面使用DOM元素。很多的服务都会轻易的产生一个DOM树,因为他们通常是单个的,全局的,以及无状态的实例,像是一个REST API的一个实例。

一个控制器中的DOM引用会纸箱一个错过的指令或者一些错过的行为。

真正的情况是,将一个控制器的DOM引用抽取到一个指令中是非常消耗资源的。但是如果你理解了这个问题以及它的影响,但是还是想要这么做,也没关系。但是你很快就要去遇到的事实是控制器会绑定到一个特定的模板,同时由控制器引起的DOM变化将不会体现到AngularJS的作用域和视图中。

最佳实践:

  • 不要在指令外部获取DOM元素因为指令可以将控制器、服务和DOM进行解耦。因此这样我们获得了更大灵活性,代码也更容易去测试和使用。

陷阱4:不使用内建方法

我在前面提到了$apply()和$digest()的用法以及它们的影响。如果许多外部事件需要额外的$apply()调用,它将会引起很多麻烦。因此我建议你深入阅读AngularJS文档,使用一些内建指令,比如使用$timeout()而不是使用window.timeout(),前者会隐式的调用$rootScope.$apply()。

你应该使用内建的$http方法而不是外部的XHR包装,它将返回一个$q promise。执行这个promise的任何回调函数都会调用$rootScope.$apply()。一些返回$q promise的模块将会隐式的调用$rootScope.$apply()。

最佳实践:

  • 使用内建指令,因为它们能够让你写出简单友好的代码。

陷阱5: 令人费解的“当前作用域”

作用域的层级结构式非常聪明的做法,但是如果你理解的不是很深入,你将会很痛苦。在你的根作用域中你可以定义一些全局全局变量,它们将可以在所有的自作用域中使用(除了隔离作用域) – 原型继承将会“找到”这些属性。在DOM中你也可以再一个普通的控制器中定义作用域来分享数据。

但是这里有一个阻塞:它只能在单方面上运行。但是这也不错,因为你不想将本地作用域中的数据暴露给其他作用域。

<span>Outside Controller: Your name is: {{username}}</span>
<div ng-controller="SignupController">
    <span>Inside Controller: Your name is: {{username}}</span>
    <fieldset legend="User details">
        <input ng-model="username">
    </fieldset>
</div>

尝试着改变input中的值,它可以正常运行但是只针对于内部的绑定。在控制器以外的绑定的值将不会变化。这是为什么?答案存在于“什么是我的当前作用域?”中。

例如,我们有两个作用域:总体的rootScope作用域和一个通过控制器(在这里是SignupController)隐式创建的作用域。

当你在input字段中输入一个新值。当前的作用域会被赋上一个叫做username的新属性。因为准确来说input字段所在的控制器的作用域就是当前作用域,这个属性也会被赋予这个作用域。就像JavaScript中的原型继承一样,这意味着这个属性在父作用域中不可用。因为我们知道这件事,所有这很好理解。

你可能会想:我定义了一个初始值!你可以试试,但是它依然不管用,因为数据就像一个字符串一样依然只是停留在当前的作用域中。如果你将$rootScope.username赋值为””,你最终将得到两个叫做username的属性,一个位于根作用域中,另一个存在于我们编写的控制器中。

为了解决这个问题,你应该使用一个包装好的模型。换句话说,你应该在模型中使用'.'。
对上面的例子进行一些修改:使用user.name而不是username。

<span>Outside Controller: Your name is: {{user.name}}</span>
<div ng-controller="SignupController">
    <span>Inside Controller: Your name is: {{user.name}}</span>
    <fieldset legend="User details">
        <input ng-model="user.name">
    </fieldset>
</div>

数据绑定现在被赋值给了user.name。因为如果在当前作用域下找不到user对象,$rootScope.user会被隐式的读取,因此这个问题得以解决。除此之外它也能够帮助你将模型结构化。这确实是一个双赢的方法。
但是你还是会发现你还是很容易犯错误,因为有许多内建的AngularJS指令 -- 或者是你自己创建的指令 -- 会创建自己的子作用域。比如说下面的这些指令:

  1. ng-controller:一个控制器有自己的作用域(因为它会在作用域中赋予行为)。
  2. ng-form:将会使用一个特别的表单控制器,因此会产生一个新的作用域。注意:<form>会创建一个ng-form的实例。
  3. ng-repeat:每一个项目都有自己的子作用域(因为’item’是循环的内容)。
  4. ng-switch:改变了DOM因此它拥有自己的作用域。
  5. ng-view: 或多或少有些不相关,因为你总是会在ng-view下指明一个控制器。

最佳实践:

  • 为了避免无结构化的内容和错误的作用域上下文以及使用指令隐式生成的作用域所产生的问题,不要在没有包装的对象上绑定一个未经绑定的数据。

陷阱6: 没有正确使用jQuery

AngularJS实现了一个jQuery的子集jQLite。它的基本操作和jQuery非常相似,然而,它并不是完整的jQuery。如果你需要使用完整的jQuery实现,你需要在AngularJS被载入之前加载jQuery。只有这样,AngularJS才会跳过jQLite而使用jQuery。否则二者都会被载入进去,AngularJS使用jQLite,其他部分使用jQuery。

最佳实践:

  • 在AngularJS之前载入jQuery。

总结

本文为AngularJS的初级开发者提供了6个常常会遇到的陷阱。如果你之前使用的是jQuery, 那么你应该记住在AngularJS中应该使用声明式的方法而非命令式的方法。如果你尝试走jQuery的老路,那么你注定会在AngularJS中失败。

试着理解将作用域作为获取数据的场所,如果你试着从其他地方获取数据,最终将会出现问题。

使用上面提到的最佳实践,并确保你在编写AngularJS应用的过程中也探索了API文档。正确的使用其中的功能。

确保你合适的解耦你的应用:使用指令,控制器,服务和模板。显然你并不需要将代码分散到许多组件中,根据你的需要使用框架。

如果你都遵循了这些规则,你一定能够享受在AngularJS编程。


本文译自AngularJS: 6 Common Pitfalls Using Scopes,原文地址http://thenittygritty.co/angularjs-pitfalls-using-scopes

如果你觉得本文对你有帮助,请为我提供赞助 https://me.alipay.com/jabez128

评论
发表评论
2年前
赞了此文章!
2年前
添加了一枚【评注】:111
2年前

“Most services should be easy to make DOM-free”翻译成了“很多的服务都会轻易的产生一个DOM树”?是free啊不是tree

2年前

翻译错了很多

3年前
赞了此文章!
3年前
赞了此文章!
3年前

@用户1998105225 赞,这个解决了困扰我好久的问题~

3年前

@泽-Eko 对你的module做下配置

_app.config(['$httpProvider', function($httpProvider){ // Use x-www-form-urlencoded Content-Type $httpProvider.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded;charset=utf-8';

// Override $http service's default transformRequest
$httpProvider.defaults.transformRequest = function(data) {
    return angular.isObject(data) && String(data) !== '[object File]' ? $.param(data) : data;
};

} ]); 这样 http.post就是form表单式的提交了

3年前

@张小俊128 $http.post()和默认浏览器post发送数据格式不一样.

举个例子

$http.post('/router', {"username":"admin","password":"123456"})

jQuery.post('/router', {"username":"admin","password":"123456"})

这两种请求如果看http请求包的话就可以看到两者的差异:

angular的是

{"username":"admin","password":"123456"}

jquery则是

username=admin&password=123456

对于某些服务器(比如php的), angular的做法是无法通过默认行为获取的 ($_POST[’username’] 在angular下会抛出一个notice异常, 在jquery中的请求中则是”admin”)

当然问题可以在后端封装一个中间件去做处理, 不过我觉得这本质上还是前端的问题

3年前

我查 我还没学 就这么多陷阱。。。。。。。。。看来只能果断放弃

3年前

@泽-Eko 使用$http.post()方法就可以了啊,问题出在哪?

3年前

看到”陷阱4:不使用内建方法”想借地请教一下

$http服务发送的post请求,原始数据是json格式的, 我一个项目是用php接受post依赖x1=xxx&x2=yyy格式的post.

在不动服务端的前提下 angular默认服务有办法搞定么?

目前我处理方法是用了第三方xhr库,然后手动触发$scope.$apply()

WRITTEN BY
张小俊128
Intern in Baidu mobile search department。认真工作,努力钻研,期待未来更多可能。
TA的新浪微博
PUBLISHED IN
用Angular开发web应用

讲述Angular开发框架的基础知识,帮助读者快速学习并深入理解Angular的开发理念和具体应用方式

我的收藏