angular的eval与watch

angular 给开发人员提供了很多内部使用的强大的工具函数, $parse服务和$observe就是其中两个比较有用的函数.

$parse

$parse作为一个服务, 在使用的时候需要先注入到指令中, 它的作用是把一个字符串编译成一个解释器函数(也可传入一个函数, 不过和直接声明函数没区别), 再对指定的scope操作即可. 生成的函数有两个参数, 一个是context, 另一个是locals(可选), context即表达式里面的属性所在的对象, locals如果提供, 可以覆盖context里面重名的属性.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$scope.context = {
add: function (a, b) {
return a + b;
},
mul: function (a, b) {
return a * b;
},
a: 1,
b: 2,
};
$scope.locals = {
a: 3,
c: 9,
};
var mulParsed = $parse('mul(a, add(b, c))');
$scope.value = mulParsed($scope.context, $scope.locals);
//得到值是33. 3 * (2 + 9)

$parse最常见的用法即解析一个字符串变量到scope上. angular 的模版页中变量的解析也是由$parse提供的.
如果$parse解析出来的是一个对象属性(a.b || [a]), 那么返回的函数会提供一个assign方法, assign是对这个对象设置值的方法.

1
2
3
4
5
6
7
8
9
10
$scope.a = {
b: {
c: 5,
},
};
var cGetter = $parse('a["b"].c');
var cSetter = cGetter.assign;
console.log(cGetter($scope)); //5
cSetter($scope, 10);
console.log(cGetter($scope)); //10

不过实际使用的时候, $eval$parse更常用一些, 可以不需要注入直接由$scope调用.

$eval

$eval的源码也是十分简单的.

1
2
3
$eval: function(expr, locals) {
return $parse(expr)(this, locals);
},

使用起来和$parse差不多, 省略了一步生成解释函数的过程. 下面看看什么情况下来使用$eval.

1
2
3
4
5
6
7
8
9
10
<div my-app>
<div ng-if='!loading'>
<div other-directive>
<div content-directive content-text='text'></div>
</div>
</div>
</div>
//myApp {scope.text = 'eval内容'}
//otherDirective {scope: true,}
//contentDirective {scope: {contentText: '='}}

在以上代码中, 最内层的指令contentDirective可以获取到myApp中 text 的值, 但是如果在contentDirective里面对contentText修改, myApp是拿不到改变以后的值的. 这是因为otherDirectivescope: true是对scope进行原型继承, 在当前取不到值可以顺原型链继续读取, 可是修改值的时候是不能设置原型链上的值的. 解决办法可以是传入一个对象, 那么修改只是修改这个对象的属性, 所以可以读取到值, 这也是推荐的做法. 但是如果你确实不想传入对象, 那么就可以使用$eval来解决这个问题.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<div my-app>
<div ng-if="!loading">
<div other-directive>
<div content-directive content-text="text" scope-level="3"></div>
</div>
</div>
</div>
//contentDirective
scope: {scopeLevel: '=', contentText: '='},
link: function($scope, $element, attrs, ctrls) {
$scope.scopeLevel = $scope.scopeLevel || 1;
var $parent = $scope;
var levelInt = parseInt($scope.scopeLevel);
while(levelInt > 0 && levelInt != Infinity){
$parent = $parent.$parent;
levelInt--;
}
$scope.contentText = 'newContentText';
//设置值, 如果不使用$eval, 当attrs['contentText']为多层属性时不能设置值.
$parent.$eval(attrs['contentText'] + ' = contentText', $scope);
}

$watch

$scope.$watch(key, func, isDeep)可以监听一个$scope上给定key的值的变化, 如果有变化则调用 func, 返回值是一个注册函数, 再次执行注册函数可以取消该监听. 第三个参数为是否深度比较, 不建议设置为 true, 太占用性能.

$observe

1
2
3
4
5
6
7
8
9
10
11
12
13
$observe: function(key, fn) {
var attrs = this,
$$observers = (attrs.$$observers || (attrs.$$observers = {})),
listeners = ($$observers[key] || ($$observers[key] = []));
listeners.push(fn);
$rootScope.$evalAsync(function() {
if (!listeners.$$inter) {
// no one registered attribute interpolation function, so lets call it manually
fn(attrs[key]);
}
});
return fn;
}

如果说$watch基本可以满足传值到指令里面再次改变以后的事件触发, 那么attrs.$observe(key, func)则是监听属性的变化以及触发事件. 这两个的区别是: 当你的传值方法为key="{{someone}}"时, 使用$scope.$watch(attrs.key)是无效的, 因为{{}}这种值无法解析, 这时候就需要用attrs.$observe, 在源码里面可以看到调用的是$evalAsync这个方法, 这个方法和$eval的区别是该方法为延迟执行, 但是会在本轮$digest循环期间执行, 在$digest里面可以看到是在所有$scope上的watch执行完成以后调用, 所以能够监听到括号表达式的值.

$digest源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
$digest: function() {
var watch, value, last, fn, get,
watchers,
length,
dirty, ttl = TTL,
next, current, target = this,
watchLog = [],
logIdx, logMsg, asyncTask;

beginPhase('$digest');
// Check for changes to browser url that happened in sync before the call to $digest
$browser.$$checkUrlChange();

if (this === $rootScope && applyAsyncId !== null) {
// If this is the root scope, and $applyAsync has scheduled a deferred $apply(), then
// cancel the scheduled $apply and flush the queue of expressions to be evaluated.
$browser.defer.cancel(applyAsyncId);
flushApplyAsync();
}

lastDirtyWatch = null;

do { // "while dirty" loop
dirty = false;
current = target;

while (asyncQueue.length) {
try {
asyncTask = asyncQueue.shift();
asyncTask.scope.$eval(asyncTask.expression, asyncTask.locals);
} catch (e) {
$exceptionHandler(e);
}
lastDirtyWatch = null;
}

traverseScopesLoop:
do { // "traverse the scopes" loop
if ((watchers = current.$$watchers)) {
// process our watches
length = watchers.length;
while (length--) {
try {
watch = watchers[length];
// Most common watches are on primitives, in which case we can short
// circuit it with === operator, only when === fails do we use .equals
if (watch) {
get = watch.get;
if ((value = get(current)) !== (last = watch.last) &&
!(watch.eq
? equals(value, last)
: (typeof value === 'number' && typeof last === 'number'
&& isNaN(value) && isNaN(last)))) {
dirty = true;
lastDirtyWatch = watch;
watch.last = watch.eq ? copy(value, null) : value;
fn = watch.fn;
fn(value, ((last === initWatchVal) ? value : last), current);
if (ttl < 5) {
logIdx = 4 - ttl;
if (!watchLog[logIdx]) watchLog[logIdx] = [];
watchLog[logIdx].push({
msg: isFunction(watch.exp) ? 'fn: ' + (watch.exp.name || watch.exp.toString()) : watch.exp,
newVal: value,
oldVal: last
});
}
} else if (watch === lastDirtyWatch) {
// If the most recently dirty watcher is now clean, short circuit since the remaining watchers
// have already been tested.
dirty = false;
break traverseScopesLoop;
}
}
} catch (e) {
$exceptionHandler(e);
}
}
}

// Insanity Warning: scope depth-first traversal
// yes, this code is a bit crazy, but it works and we have tests to prove it!
// this piece should be kept in sync with the traversal in $broadcast
if (!(next = ((current.$$watchersCount && current.$$childHead) ||
(current !== target && current.$$nextSibling)))) {
while (current !== target && !(next = current.$$nextSibling)) {
current = current.$parent;
}
}
} while ((current = next));

// `break traverseScopesLoop;` takes us to here

if ((dirty || asyncQueue.length) && !(ttl--)) {
clearPhase();
throw $rootScopeMinErr('infdig',
'{0} $digest() iterations reached. Aborting!\n' +
'Watchers fired in the last 5 iterations: {1}',
TTL, watchLog);
}

} while (dirty || asyncQueue.length);

clearPhase();

while (postDigestQueue.length) {
try {
postDigestQueue.shift()();
} catch (e) {
$exceptionHandler(e);
}
}
}

$apply & $digest

$digset循环就是angular的核心循环了, angular就是在这个循环里面执行脏值检测, 更新scope和页面, 进行双向绑定和触发watch事件的. 那么如果我们需要在某些地方触发双向绑定和事件, 只需要进入这个循环就可以了. angular提供了几种进入$digset循环的方法.

  1. $apply
    $scope.$apply()会触发整个应用中的所有$scope上的$digset循环, 即刷新整个应用的双向绑定值等等. 因为执行的是全部, 所以频繁调用还是会导致性能问题, 所以有了下一个方法$digset
  2. $digset
    $scope.$digset()会触发当前scope和子scope中的$digset循环, 那么造成的问题是父节点不会刷新, 优点是对于性能有明显的改善.
  3. $evalAsync
    $scope.$evalAsync()有两种情况, 如果当前正在$digset循环中, 那么会添加到$digset循环尾部执行, 如果不在$digset循环中, 那么它可以触发新一轮的$digset循环.
  4. $timeout
    在没有$evalAsync之前, 在angular上下文之外触发$digset循环的方法比较常用的就是它, 基本等于setTimeoout, 在执行的最后会调用$apply触发$digset循环.
  5. $applyAsync
    也可以触发$digset循环, 和$apply有点区别, 定义一个延迟执行任务, 如果不存在applyAsyncId, 会调用$rootScope.$apply. 在$digset源码里面可以看见, 在$rootScope中才会触发定义的applyAsync方法, 如果你手动调用的是非$rootScope$digset循环, 那么不会触发applyAsync里面定义的方法. 官方说明是方便$http在请求结束的时候合并$apply的调用, 实际项目里面使用到的情况还是$evalAsync比较常用一些.
作者

Mosby

发布于

2016-11-14

许可协议

评论