AngularJS and scope.$apply

JavaScrip世界中(clien端t)这几个知名的所谓MVC框架,对照他们的tutorial,做个练习,都挺简单有趣,可一旦真实的应用这些框架去实际实现一些场景,立刻就发现没那么简单了。

AngularJS也一样,只要我们编写一些“非尝试”性质的代码,就无法避免使用到$scope.$apply()方法。表面看来,这只是一个让我们的页面上有数据绑定属性的空间得到更新,但是为什么有这个方法存在、我们何时需要这个方法,这是个问题,先看AngularJS的文档,我没有搞得很清楚,看了http://jimhoskins.com/2012/12/17/angularjs-and-apply.html 这篇文档后,我才真正的搞明白怎么回事(当然,搞明白后,再去看AngularJS文档,发现文档其实也说了…… 只是不那么利于理解)。

想知道何时(when)用到$apply, 最好的途径就是先搞清楚我们为什么(why)要用到$apply。

JavaScript is Turn Based

我们书写的JavaScript代码,不是作为一个整体,一下子就运行完成的,JavaScript代码是安顺序(turn)运行。每一个顺序中运行的代码是不可中断,当一个代码片段正在属于自己的顺序(turn)中运行时,我们的浏览器中将不会有任何变化,其他的JavaScript代码段没有机会运行,浏览器中的界面也完全冻结。这也正是为什么效率低下的JavaScript代码会“冰冻”整个web page。

正因为此,当我们执行的任务预期会消耗很多时间才能执行完——如Ajax请求,等待click事件,timeout延时调用,我们都会设置一个回调(callback)函数,然后立即结束当前的turn。稍后,当Ajax请求完成,click事件被探测到,timeout延时时间到,一个新的JavaScript turn会被创建,我们之前设置的回调函数在其中运行。

让我们来看一个例子,一个js文件,内容如下:

var button = document.getElementById('clickMe');

function buttonClicked() {

alert('the button was clicked');

}

button.addEventListener('click', buttonClicked);

function timerComplete() {

alert('timer complete');

}

setTimeout(timerComplete, 2000);

当这个js文件被装载,会被分配一个turn,获取一个button,添加 click 事件监听,设置一个延时器。然后,这个turn就结束,浏览器可以完成其他的相关页面更新操作,等待获取用户输入。

如果浏览器探测到在#clickMe按钮上有点击操作,它会创建一个新的turn,在其中执行buttonClicked函数。这个函数返回,turn也就终结。

过了2000毫秒后,浏览器创建一个新的turn来调用timerComplete。

我们的JavaScript程序是在一个个turn中运行的,在turn之间隙,页面完成重绘、用户输入被接收。

我们如何更新使用数据绑定的页面空间?

AngularJS让我们将一些页面组建绑定到JavaScript代码中的数据上,但是组件是如何知道数据发生了变化,需要对页面进行重绘的呢?

要解决这个问题(数据绑定)有多种解决方案。代码需要知道数据的值被修改,我们有两种可行的策略将数据修改通知到绑定的组件。

第一个策略,所有数据修改通过method来操作,避免属性直接赋值。这样数据被改变就可以被代码捕捉,相应的页面绑定就可以被更新。这一策略的缺点是,我们必须使用特定的对象,其中所有的赋值操作,得通过诸如obj.set('key','value)的方式来操作,而不是更直观的obj.key = 'value'方式。EmberJS和KnockoutJS框架都使用这一策略。

AngularJS使用的是第二种策略:允许任何对象值作为页面对象的绑定对象。然后,在任何一个JavaScript的turn(前文说了半天的turn)结束时,检查这些对象的数值是否被修改。这个策略的描述,给人的第一感觉是觉得会比较低效,但是这里头有一些非常聪明的做法解决其对性能的影响。这一策略的优点就是,我们可以使用普通的对象(object),用任意我们喜欢的方式给对象赋值,对象的任意变化都会自动在数据绑定中体现。

为了让这一策略工作,我们需要知道何时对象的值可能被修改了,这也就是$scope.$apply介入运行的点。

$apply 和 $digest

检查绑定的值是否已经修改,正宗对应的方法是$scope.$digest()。那里是“神奇”发生的地方,但其实我们基本上从不会直接调用$digest方法,我们一般会调用$scope.$apply(),间接调用到$scope.$digest()。

$scope.$apply() 接收一个函数或者Angular表达式字符串作为参数,调用参数传递的函数或者Angular表达式,然后间接调用$scope.$digest,更新绑定和wather(Angular有$watch()方法)。

说了这些了,那么到底什么时候需要我们手工调用$apply()方法?机会很少很少,只在一些特定环境下,才需要我们手工调用$apply。像AngularJS中的事件处理ng-click,Controller的初始化操作,$http的回调函数,都已经被$scope.$apply封装(wrap)。在这些场景中,都不需要我们自己调用$apply(),实际上在这些操作中,我们也不能再调用$apply(),在$apply中再次调用$apply会抛出异常。

我们只在新的turn——这个turn不是被AngularJS库中的函数创建的——中,需要手工使用$apply()方法。在这个新的turn中,你需要将你的代码封装到$scope.$apply()中。举个例子,我们使用setTimeout,如前文所述,定时器超时触发后会在一个新的turn中执行其回调函数。由于Angular并不知道这个新的turn(setTimeout不是AngularJS库函数),这个回调函数中发生的数据变更,不会自动更新数据绑定设置。

如果,我们将此类型代码封装到$scope.$apply()中,发生的数据变更,就会触发页面上的数据绑定更新。

为了便利期间,AngularJS提供了$timeout函数,提供类似setTimeout功能,但是默认就已经将回调代码封装在$apply中,可以避免手工调用$apply。

所以,如果你的代码中,使用Ajax调用,但是没有使用$http,或者针对不是AngularJS 的 ng-*事件监听,或者延时调用没有使用$timeout,你就需要将代码封装到$scope.$apply中。我第一次碰到这个问题,其实是在websocket中,根据websocket server推送的消息,更新数据后,发现页面上的数据绑定没有做相应更新。

$scope.$apply() vs $scope.$apply(fn)

有些时候,在一些例子中,我们看到数据变更后,$scope.$apply()被以无参数的方式调用。这种方式,可以达到我们需要的结果,但是失去很多选项(opportunities)。

如果不适用传递函数的方式使用$apply,如果代码中抛出异常,异常处理代码就会运行在一个新的turn中,导致数据更新不能update数据绑定。如果使用传递函数的方式调用$apply,你的代码会被try/catch包围,而$digest会在finally子句中调用——及时抛出异常,依然完美运行。

《完》

Leave Comment