AngularJS 之折腾记

前几天 Angular2 正式发布了,虽然他也在我的学习计划里面,但我并没有把他应用在我最近开展的一个项目中。最近在写一个 Rss 订阅器,基于 Angular1 和 Koa2(总感觉两个有点不搭 =.= ),主要是不想在这个项目花太长时间,再者我还想集我目前掌握的所有技术之大成写一个能拿的出手的项目,所以就没有选择 Angular2 了。至于 Koa2,其实很早就想学了,只是之前一直在忙别的。

angular-resource 介绍

今天捣鼓 Angular 的 Resource 功能,前端后端都掌握在自己的手上时去用 Angular 的 Resource 特别舒服,大大减少了代码量,特么强大。

(function() {
    angular
        .module('app')
        .factory('Post', $resource => {
            return $resource('/api/feed/:feed_id/post/:id', {id: '@_id'}, {
                update: {method: 'PUT'},
                get: {method: 'GET', cache: true}
            });
        })
}());

在上面我定义了一个 Post 资源,一旦创建完成后,他就自动拥有了以下方法。

{
	'get': {method: 'GET'},
	'save': {method: 'POST'},
	'query': {method: 'GET', isArray: true},
	'remove': {method: 'DELETE'},
	'delete': {method: 'DELETE'}
}

有些 IE 浏览器可能不支持 delete,这时候可以使用 remove
我们还可以自定义或者修改里面的方法,比如我上面中就自定义了一个 update 方法以及给 get 方法开启了缓存。

那怎么使用呢?也很简单,如果对 restfulAPI 比较熟悉应该很容易理解。

$stateProvider
	.state('feed.post', {
	      url: '/post/:post_id',
	      templateUrl: 'post/post_tpl.html',
	      controller: 'PostController as vm',
	      resolve: {
	          post: function(Post, $stateParams, $state) {
	              return Post.get({feed_id: $stateParams.id, id: $stateParams.post_id}).$promise;
	          }
	      }
	  })

这里就调用了 get 方法,同时把参数传入,这样就好了。其他方法其实一样的。
resource 也有很多功能。

$resource(url, [paramDefaults], [actions], options);

第二个参数设置默认参数用,比如我们发起了一个 get 请求得到数据,这条数据有一个 _id 属性,我们可以把他绑定为默认的 id 参数,这样在之后执行该资源的其他方法时我们可以不指定 id
第三个参数就是自定义方法的地方了,前面我给 get 方法升级了下,是这样的,这个也就是第三个参数了。

$resource('/api/feed/:feed_id/post/:id', {id: '@_id'}, {
    update: {method: 'PUT'},
    get: {method: 'GET', cache: true}
});

格式是这样的:

{action1: {method:?, params:?, isArray:?, headers:?, ...},
 action2: {method:?, params:?, isArray:?, headers:?, ...},
 ...}

更多说明参考官方文档咯。
第四个参数没看明白,可以自己看下官方文档。

angular-resource 缓存问题

上一个项目没有用 angular-resource 的时候,我就在 factory 里面缓存 response,下次请求时直接返回该 response。由于用户所操作的和所缓存的都是同一个对象,因此在进行一些对该 response 的修改时比如,用户进行点赞操作,那么我除了发出一个请求之外,我还要将 response 里的是否点赞的值修改过来,这样视图才能反映出来,由于和缓存是同一份东西,因此实现了缓存的同步变化。
但不知道是不是这种方式容易导致缓存被破坏还是怎么样,我看了下 angular 以及 angular-resource 的部分源码,发现 angular 在处理 http 缓存时对数据进行了 serialize 操作,而且第一次返回给用户的并不是缓存的结果,而是自己 resource 里面的东西,下次访问时才从缓存取出来后 deserialize 后返回。
关于这个问题其实老早就有人发出 issue 了,但官方并没有回应,目前比较好的解决方案就是在修改资源时,删除缓存。可以这样操作:

var $httpDefaultCache = $cacheFactory.get('$http');
$httpDefaultCache.remove(key);
// The cache key is the request URL including search parameters;

那有没有更好的办法呢,其实我也想过,有想到用缓存数据替代返回到控制器的数据,然而从这个尝试开始就发现了很多很坑的地方。
我在 httpInjector 里面拦截 response 加入了这么一段代码

setTimeout(() => {
     // 只对 resource 的 cache 进行处理
     if(config.config && config.config.cache === true) {
         let url = config.config.url;
         $cacheFactory.get('$http').get(url)[1] = angular.fromJson($cacheFactory.get('$http').get(url)[1]);
         config.resource.data = $cacheFactory.get('$http').get(url)[1].data;
         config.config.data = $cacheFactory.get('$http').get(url)[1].data;
         config.data = $cacheFactory.get('$http').get(url)[1].data;
         console.log($cacheFactory.get('$http').get(url));
     }
 });

作用就时强制 serialize 缓存的数据,然后讲全部数据都替换成缓存中的数据,由于在这一阶段并没有开始缓存,所以要设置 setTimeout 推迟操作。
这样做了之后,对第一次加载仍然没有什么影响,但第二次加载时就开始起作用了,更改会同步变化到缓存,其实就是之后用的就直接是缓存的对象,而不再是 deserialize 化后的数据,这一步应该归功于我修改了缓存中数据的存储形式。
由于第一次返回给控制器的数据并不是从缓存取出来的,而是从 resource 里面取出来的即上面的 config.resource.data,所以我也把他改到缓存中的对象去。为了更好说明问题,我把控制器代码也贴上来。我的目的就是进行 mark 操作后缓存也会自动同步过来。

(function() {
    angular
        .module('app')
        .controller('PostController', PostController);

    function PostController($state, post, Post, $scope, _, $rootScope, $timeout, $cacheFactory) {
        var vm = this;
        vm.post = post;
		
		vm.mark = mark;
		
        vm.currentPost = post.data.result;
        vm.currentPostDetail = post.data.detail;

        function mark() {
            vm.currentPostDetail.mark = !vm.currentPostDetail.mark;
            Post.update({feed_id: vm.currentPost.feed_id[0], id: vm.currentPost._id}, {type: 'mark'});
        }
    }
}());

按理说,控制器初始化的时候 post 是一个 resource 对象,我在 setTimeout 中修改了 resource 内部的数据,指向到缓存中被 serialize 化的数据。而 vm.currentPostvm.currentPostDetail 又是分别指向 post.data.resultpost.data.detail,应该我在 vm 上面的操作可以影响到缓存才对,然而并不能。试试看修改 mark 方法。

function mark() {
    vm.currentPostDetail.mark = !vm.currentPostDetail.mark;
    post.data.detail.mark = !post.data.detail.mark;
    Post.update({feed_id: vm.currentPost.feed_id[0], id: vm.currentPost._id}, {type: 'mark', revert: true});
}

也就是加了一句话,更改 post 中的数据。结果是工作了!这说明 post 确实此时是指向缓存的。那 vm.currentPostDetail 也是指向 post.data.detail 的,为什么它不工作?
其实这不是 angular 的锅,console 试试下面就知道了。

pre = {status: 200, result: {detail: {a:1}, result: {b:2}}};
ctrl = pre.result.detail;		// {a:1}
cache = {status: 200, result: {detail: {a:2}, result: {b:3}}};
pre = cache;
console.log(ctrl);				// {a:1}

应该不止我一个人会认为最后结果应该是 {a: 2} 吧...其实上面还可以再简化成这样

a = {a:1};
b = a;
a = {a:2};
console.log(b);		// {a:1}

画一个图的话是这样子的。
object.png
这样应该就明了了,所以由于控制器第一次取到的 response 一定是 resource 内部的结果,而 httpInjector 是在返回 response 前进行修改,因此我们无法通过 httpInjector 来达到目的。唯一能做到的方法就是让控制器接受到的结果与缓存指向同一内存,假设 $cacheFactory 缓存 http 的结果没有被序列化,那么将 resource 中的数据加入缓存,同时把这个数据返回给控制器,就实现了控制器数据和缓存数据指向一致的目的,所以最终问题还是在 $cacheFactory 把结果给序列化了再存储。
为了验证下我们的说法,可以修改下 angular 源码下面的地方:

function done(status, response, headersString, statusText) {
    if (cache) {
        if (isSuccess(status)) {
        cache.put(url, [status, response, parseHeaders(headersString), statusText]);
        } else {
        // remove promise from the cache
        cache.remove(url);
        }
    }

    function resolveHttpPromise() {
        resolvePromise(response, status, headersString, statusText);
    }

    if (useApplyAsync) {
        $rootScope.$applyAsync(resolveHttpPromise);
    } else {
        resolveHttpPromise();
        if (!$rootScope.$$phase) $rootScope.$apply();
    }
}

把这一行

cache.put(url, [status, response, parseHeaders(headersString), statusText]);

改为

cache.put(url, [status, angular.fromJson(response), parseHeaders(headersString), statusText]);

之后就会发现,从第二次开始缓存的数据就会同步变更了,因为存的是对象,所以下次从缓存取的时候就直接用的对象,所以就可以保持数据的同步了。但第一次还是不行,我也没有再深究下去了,源码有点绕,看不太懂。
另外,过程中曾经有想利用 $stateChangeStart$stateChangeSuccess 着手,但是发现这两个事件没有效果,查了资料才知道,ui-router 的新版本(1.0)已经有较大的变化,不再支持上面的事件,而是转而通过 $transition 来操作。如果你还在用老版本的 ui-router,可以试试看这个新版本的,迁移可以参考这里

花了好几个小时想尝试解决这个问题都没搞定,现在还是搞不懂为什么 angular 要把数据序列化后缓存,是为了数据的稳定吗?如果可以提供一个 option 来设置不是很好吗?不知道有没有什么解决方案,一直没找着,貌似是因为缓存的数据就不应该被修改,但在一些场景它还是有应用用途的吧。