最近使用angularjs完成了一个CURD后台的开发。得益于angular的mvc架构以及强大的指令,依赖注入,双向数据绑定,在开发这类应用的时候,angular的效率很高,这里总结一下angular使用过程中的一些坑
代码压缩导致的依赖丢失
依赖注入是angular最强大的地方, 有三种注入方式
- xxx.$inject = [‘dep1’, ‘dep2’];
- function (dep1, dep2, dep3) { … } 在函数的形参里写上依赖的名字,angular将函数转为字符串后,正则匹配它的形参,拿到依赖名,然后全局查找并注入。
- [‘dep1’, ‘dep2’, ‘dep3’, function (dep1, dep2, dep3) {…} ] 将依赖和构造函数放入一个数组
第二种方式最方便,但是有一个缺点,开启了webpack代码压缩后,函数的形参将会被压缩成a,b,c,d等简单的字符,angular再用这些名字去查找依赖,显然是查找不到的。但是代码压缩不会压缩字符串数组,所以使用第一种和第二种方法注入依赖,不会有依赖丢失的问题
资源按需加载
这个问题见仁见智,如果架构优化得好,angular不会有很大代码量,再加上代码压缩和GZIP,代码大小可以接受。我打包完这个项目的js代码,大小在500k,加上GZIP,就算在首页一次性加载完,实际上性能影响很小,当然也不能排除极端情况,所以按需加载还是有需求场景,这里记录一下按需加载的方案,配置略显繁琐。
模版按需加载 当然,如果不用webpack来处理html模版,就不需要下面这种方式了,把所有的模版放入static文件夹,直接使用templateUrl即可,
templateProvider: ['$q', function($q) {
var deferred = $q.defer();
require.ensure([], function(require) {
var template = require('./views/foo.html');
deferred.resolve(template);
});
return deferred.promise;
}],
js按需加载 我是用的wbpack打包依赖的,所以这里用到了code splitting
resolve: {
loadMyCtrl: ['$ocLazyLoad', function($ocLazyLoad) {
require.ensure([], function() {
var ctrl = require('./page/login/login.js');
return $ocLazyLoad.load(ctrl);
});
}]
}
脏值监测
angular最强大的双向数据绑定特性的原理是脏值监测。 脏值监测的原理是,每当被监控的数据发生了变化,那么就启动脏值监测循环,如果发现它的值相对上次的记录值发生了变化,那么触发相应的毁掉函数,修改DOM。
原理
实现脏值监测需要三个东西: scope(作用域),watchers(监听器),digest(脏值监测循环)。 看一下简单的实现。
function Scope () {
this.$$watchers = []
}
Scope.prototype = {
$watch: function (key, callback) {
this.$$watchers.push({
key: key,
callback: callback,
value: null
})
},
$digest: function () {
var ttl = 10;
var self = this;
var hasChanged = false;
do {
this.$$watchers.forEach(function (watcherObj, index, array) {
var nowValue = self[watcherObj.key],
oldValue = self.last;
if (nowValue !== oldValue) {
watcherObj.callback.call(this, nowValue, oldValue);
hasChanged = true;
self.last = nowValue;
}
ttl--;
})
} while (hasChanged && ttl);
}
}
var a = new Scope();
a.name = 'lily';
a.$watch('name', function (n, o) {
console.log('changed');
console.log(n);
console.log(o);
});
a.$digest();
a.name = 'kike';
a.$digest();
注意 NaN不与任何值相等,为了简介,上述代码没有做这种特殊情况的处理
ttl是digestTtl,用来限制脏值监测循环的次数,如果超过这个次数,那么标记这个model是不稳定的,不会再去管它。
在angular中可以设置这个值,调用$rootScopeProvider的digestTtl()方法。
有观察者模式的影子,但不是。在观察者模式中,一旦事件触发,将会主动通知观察者,让其作出响应。那么angular是如何判断什么时候触发digest循环的呢。
在浏览器环境中,能够异步触发数据修改的只有下面几种情况:
- DOM事件
- AJAX
- location改变
- 定时器
为了整合这几种情况,angular内置了如下几种服务
- DOM指令 ng-click,ng-model…包括其他自定义指令
- $http服务
- $location
- $timeout
当进行以上这几种操作的时候,angular都会在内部触发digest循环。
有时也会遇到不能触发的情况,比如下面这个例子
{% jsfiddle hangbale/k0u343sy html,js,result %}
点击增加1按钮是正常的,但是增加2无效,但是在再点击增加1,可以发现times增加了3,说明在scope中times确实已经增加了,但是视图上没有更新。然后手动触发digest循环的话,使用$apply或者$digest都可以,就能正常更新DOM。因为这里的on方法是JqLite里的方法,并没有触发digest循环。
$digest与$apply的区别
他们都会触发脏值监测循环,不同的是$apply是接受参数的(字符串或者函数),它用来将外部代码带入到angularjs的环境中运行,比如我们在controller使用了jquery插件的时候,就需要使用$apply以触发脏值监测。
依赖注入
依赖注入是大多数面向对象的语言都有的一个特性,可以避免需要实例化太多的对象,它是实现控制反转的一种方式。
那么angular是如何实现的呢
先来看一下angular中依赖注入的几种操作
app.controller('fooCtrl', function ($scope, $http) {})
app.controller('fooCtrl', ['$scope', '$http', function ($scope, $http) {}])
function fooCtrl (){}; fooCtrl.$inject = ['$scope', '$http']
angular的 controller方法会自动解析依赖,一种是数组形式的,为了避免代码压缩导致的依赖丢失,另一种就是函数参数形式的。
在angular内部有一个IOC容器,将我们注册的service保存起来,在注册控制器的时候,根据依赖名找到对应的service,然后注入到控制器中。
下面是一个简单的实现
var DI = {
container: {},
saveDependency: function (key, fn) {
this.container[key] = fn;
},
inject: function (fn) {
var actucalDep = [];
if (Array.isArray(fn)) {
var actucalDep = fn.slice(0, fn.lenght - 1);
fn = fn[fn.lenght-1];
} else {
var reg = /function\s*\(\s*([^\)]+)\s*\)/m;
var argStr = fn.toString().match(reg);
var deps = argStr[1].split(',');
deps.forEach(function (arg, index, arry) {
arry[index] = arg.replace(/^\s*|\s*$/g, '')
})
for (var i = 0; i < deps.length; i++) {
if (this.container.hasOwnProperty(deps[i])) {
actucalDep.push(this.container[deps[i]]);
}
}
}
return function () {
fn.apply(this, actucalDep);
}
}
}
DI.saveDependency('lily', {
name: 'lily',
hi: function () {
alert('hi lily');
}
})
var fn = DI.inject(function ( lily ) {
lily.hi();
})
fn();
先创建一个容器,用来保存所有的可注入对象,通过正则表达式拿到函数的参数,然后在容器中找到对应的对象。
run与config
假设我们的应用有一个auth的service,里面保存着http的token,用来做身份验证,这样就需要在http header里设置token字段,这时可以用httpProvider做全局的token配置,刚开始是这样写的
var app = angular.module('app', []);
app.config(function(auth, $httpProvider) {
$httpProvider.defaults.headers.common.token = auth.getToken();
});
app.factory('auth', function() {
function token() {
return 'tokenstring';
}
return {
getToken: token
}
})
很不幸,报错了
Uncaught Error: [$injector:modulerr] Failed to instantiate module app due to:Error: [$injector:unpr] Unknown provider: auth
换run试试
var app = angular.module('app', []);
app.run(function(auth, $httpProvider) {
$httpProvider.defaults.headers.common.token = auth.getToken();
console.log($http.defaults.headers.common);
});
app.factory('auth', function() {
function token() {
return 'tokenstring';
}
return {
getToken: token
}
})
继续出错
Unknown provider: $httpProviderProvider <- $httpProvider
看起来是解析依赖名的时候多加上了 Provider ,修改一下
app.run(function(auth, $http) {
$http.defaults.headers.common.token = auth.getToken();
console.log($http.defaults.headers.common);
});
这样没有问题。 那么问题来了 那么config与run有何区别呢? 它们可以注入什么样的依赖? ** 结论 **
- config
在angular初始化阶段运行,config函数的依赖只能是provider和constant,这个阶段service和provider还没有还没有被实例化。 - run 在所有的provider被实例化之后运行,只有实例化之后的provider,service能被作为run的依赖。
那么我们把上面的auth服务改成用provider注册:
app.provider('auth', function() {
return {
token: 'tokenstring',
$get: function () {
return {
token: 'tokenstring'
}
}
}
})
app.config(function (authProvider) {
console.log(authProvider);
})
这样没有问题,可以看到这时的authProvider是的我们return的对象,接下来在run里调用一下 这里的auth跟config里的auth已经不一样了,只拿到了$get返回的对象
provider中的$get是必须的,因为provider在实例化以后只能拿到$get返回的东西,比如将provider注入到控制器或者run中的时候,而在config阶段,provider并不会被实例化,所以可以拿到完整的对象
回到我们最开始的问题,配置http token的正确姿势应该是什么呢? 这时需要用到http interceptors和 $injector;
var app = angular.module('app', []);
app.factory('authInterceptor', function() {
return {
'request': function (config) {
config.headers.token = 'tokenstring';
return config;
}
}
})
app.config(function ($httpProvider)
{
$httpProvider.interceptors.push([
'$injector',
function($injector) {
return $injector.get('authInterceptor');
}
])
})
app.run(function ($http) {
$http.get('www.baidu.com');
})
可以看到,http request header里已经带上了token
Provider & Factory & Service
一个web应用,如果所用的逻辑都写在controler里面,没有复用,没有模块化,那就可以直接拜拜了。
在angular中,为了能够方便复用模块,使用了依赖注入。将可以复用的模块注入到控制器中。
那么如何定义这些可复用的模块的呢?
angular提供了provider,factory,service这三个api。
他们的最终目的都是相同的,最终都是返回了一个service,当这个service被注入到其他模块中的时候,会被实例化,然后返回一个全局单例的service对象。
最核心的是provider,它是其余两者的起源,它必须包含一个$get属性,因为在实例化的时候,只能拿到$get返回的对象,provider可以在config阶段,修改属性,进行一些初始化配置,其余两个只是语法糖。如果需要定义的服务能被配置,应该使用provider。
service
service使用面向对象式的语法,他传入的是一个构造函数
app.service('fooservice', function () {
this.name = 'foo';
this.hello = function () {
alert('hello');
}
})
这是provider的简写形式,相当于一个只有$get属性的provider,这里的构造函数返回的对象与$get返回的对象是相同的。
当这个service被调用的时候将会使用**new fooservice()**来实例化service
在config中不能访问由service定义的服务
factory
factory也是provider的简写形式
**factory(name, $getFn)**相当于 $provide.provider(name, {$get: $getFn})
在config中不能访问由factory定义的服务
controller间通信
angular实现组件间通信的方式有很多,从本质上看就是两种
利用全局对象和利用组件上下级关系
利用全局对象
$rootScope 在$rootScope上$broadcast一个事件,它将会把这个事件向下派发到所有的子$scope,然后在对应的$scope上监听这个事件即可
<div ng-controller="foo"></div>
<div ng-controller="bar">
<button ng-click="say()">hi</button>
</div>
app.controller('foo', function ($scope) {
$scope.$on('hi', function (event, arg) {
alert(arg.name);
})
})
app.controller('bar', function ($scope, $rootScope) {
$scope.say = function () {
$rootScope.$broadcast('hi', {name: 'bar'})
}
})
或者更加粗暴一点,直接在$rootScope上读写数据😂😂
service 因为在angular中service是单例模式的,再利用订阅发布模式,可以专门设计一个service用于处理通信。具体的代码就不上了,使用方式上来说跟$rootScope差不多。 相比使用$rootScope,这种方式更加推荐,因为$rootScope会将事件派发到每个子scope,带来不必要的性能浪费,
利用上下级关系
- $parent
- $$$nextSibling 这两种方式原理差不多,缺点很明显,耦合太严重了,不推荐。
这么多方法,还是用service最好,使用订阅发布模式,简洁,性能好。要注意的就是事件名要约定好,避免重复。
one-time binding 单次数据绑定
angular的强大之处在于它的双向数据绑定,它的双向数据绑定基于脏值检测,当一个上有大量的数据绑定的时候,每次有数据变化,都会触发当前scope内的脏值检测循环。但是,页面上有些数据是不需要双向绑定的,一旦渲染是不会再改变的。比如数据列表页面,这时用双向数据绑定未免浪费性能。为此,angular提供了单次数据绑定,形如 { { ::name } }
这个功能使用了一个叫做Value stabilization 的算法。
当一个脏值检测循环开始的时候,如果发现有单次绑定,则检查新的值是否undefined,如果是,则注册一个解绑函数,取消对当前数据的监控。当脏值检测循环完成以后,开始执行解绑函数,并且在执行完成以后继续检查单次绑定的值是否undefined,若否,就把它重新加入到监控队列中
指令中ngTransclude的scope问题
如果某个指令使用了ngTransclude,那么ngTransclude将会创建一个单独的作用域。这一点使用嵌套指令的时候需要特别注意,如果在父级指令上使用了ngTransclude,那么将会创建一个跟父级同级的scope,并且子指令也会存在于这个另外创建的scope中。
如果使用了 $parent
来做嵌套指令间的通信的话,在这种情况下就会产生BUG,所以推荐的方式是使用controller共享来解决之所以ngTransclude被设计成会产生单独的scope,是为了防止嵌套的指令里的数据影响到了父指令。因为理论上任何内容都能被transclude,为了防止scope污染,必须生成一个单独的scope。
动态插入指令
在设计表单创建系统的时候,有这样一个需求,点击某种问题类型,就在下方的页面中插入对应的问题编辑块,为了复用,我将每个问题编辑块都做成了指令,这样就带来了一个问题,如何动态插入指令,并且还要绑定scope。 最初的想法是
var directiveStr = '<directive></directive>';
var ele = angular.element(directiveStr);
ele = $compile(ele)(scope);
angular.element(document.body).append(ele);
但是这样会报错,特别是当指令中有require其他的controller的话,会提示找不到。后来想到在compile的时候,dom还没有被插入到页面中,这样compile函数是无法找到指令所require的控制器的。换个思路,先插入元素,然后再编译。
var directiveStr = '<directive></directive>';
var ele = angular.element(directiveStr);
angular.element(document.body).append(ele);
$compile(ele)(scope);
这样就可以了
指令中绑定父级作用域函数
指令中有三种绑定策略,可以使隔离作用域的指令访问外部指令
本地作用域属性
@
绑定的是DOM属性,这意味着他的值是字符串
双向数据绑定
=
可以使本地作用域上的属性绑定到父级作用域上的属性,并且是双向数据绑定
父级作用域表达式绑定
可以执行父级作用域上的函数,或者表达式,如果绑定的是父级作用域上的函数,并且是代参的函数,那么必须将参数以对象的方式传入,例如
angular.module('app')
.controller('foo', function ($scope) {
$scope.action = function (name, age) {
$scope.name = name;
$scope.age = age;
}
})
.directive('bar', function () {
return {
restrict: 'E',
scope: {
action: '&'
},
link: function (scope, ele, attr){
scope.action({
name: 'jake',
age: 12
})
}
}
})
如上所示,在执行父级作用域的action函数的时候,需要将name和age两个参数以对象的方式传入,否则会报错
ngShow与ngIf
ngshow 使用display属性来设置元素是否可见,元素依然会被渲染
ngIf会将元素从DOM中完全移除或者重新插入,并且它会创建新的scope。
重点是,ngShow中如果包含了别的指令,指令会继续执行,即使ngShow隐藏了元素
销毁动态插入的节点
封装modal,tooltip,messageBox等组件的时候,一般的会将动态创建出一个节点,然后appendChild到body元素中。
然而由于使用ui-router,当业年跳转的时候,变化的只是ui-view中的内容,所以会产生这样一个现象,我们触发显示了一个tooltip,然后,我们去到其他页面,发现这个tooltip并没有消失……。
因为页面中变化的只是ui-view中的内容,tooltip的scope和元素并不会销毁。
解决这个问题,第一种方法,可以将元素appendChild到ui-view之中,缺点是如果是一般页面上有多个ui-view,所以需要先查找。
第二种,在$rootScope上监听locationChangeSuccess
事件,并绑定销毁节点的函数。销毁成功以后,还需要将这个监听函数注销。这点很方便就可以实现,因为在$rootScope
上监听的事件,将会返回一个销毁函数。