最近使用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上监听的事件,将会返回一个销毁函数。