[ng1]路由和解决项的高级技巧
本文译自Advanced routing and resolves(原文副标题:避免AngularJS控制器中的回调陷阱)。
URL路由是Web应用中不可或缺的一部分。AngularJS自带的标准路由是ngRoute
。在开发Angular应用的过程中你会首先熟悉ngRoute
,并且它会陪伴你很久,但最终你会发现它无法满足你的需求。
默认的路由有许多限制,使得它并不太适合复杂的应用。实际上,它只能用在最小规模的项目上。显然AngularJS团队也注意到了这个问题,已把ngRoute
移出了核心模块。从AngularJS 1.2开始,使用ngRoute
就必须单独加载了。
本文简介:通过AngularUI Router和嵌套的解决项(resolve)将数据加载操作移出控制器(controller),避免控制器之间的耦合和代码重复。
挖掘AngularUI Router
AngularUI团队认为默认的路由模块缺乏很多功能,于是他们开发了AngularUI Router。这个模块几乎可以无缝替换ngRoute
,但提供了更多的功能:
- 嵌套状态(state),嵌套视图(view)(即一个view中嵌套另一个view)
- 多视图,命名视图(两个视图并列显示,通过名字引用)
- 嵌套解决项(一个解决项等待另一个解决项)
uiSref
指令(构建URL)onEnter
和onExit
回调
ngRoute
的一大缺点是它仅支持一个ngView
指令(用于加载路由模板的DOM元素)。这就是说,路由只能是单层列表,嵌套路由必须手工实现(比如通过ngInclude
和模板名变量的方式)。嵌套视图很容易出错,因此AngularUI Router自带该功能实在是个福音。定义嵌套结构很容易,只需给每个路由(在UI Router中称为状态(state)),用点来表示父子关系。例如:
// 定义顶层状态
$stateProvider.state('users', {
url: '/users',
templateUrl: 'views/users.html'
});
// 为'users'定义子状态
$stateProvider.state('users.new', {
url: '/new',
templateUrl: 'views/users.new.html'
});
与ngRoute
相比,UI Router最显著的特点就是每个路由都有名字。状态的“主键”是状态名而不是URL,URL成了属性。可以利用这个正则表达式搜索替换来从ngRoute
迁移到UI Router。
解析项
ngRoute
有个很不错的功能就是它可以为路由加载数据(“解析”),即resolve
属性(文档)。解析过的属性会被注入到路由的控制器中。UI Router也提供了这个功能,而且还能很好地支持嵌套属性,也就是说它支持嵌套解析。我认为这是UI Router最好的功能。嵌套解析项就是将一个解析想的结果注入到另一个解析项中,只有当所有注入的解析项全部解析完毕,被注入的解析项才会执行。
$stateProvider.state('users.profile', {
url: '/:id',
templateUrl: 'views/users.profile.html',
controller: 'UsersController',
resolve: {
user: function($stateParams, UserService) {
return UserService.find($stateParams.id);
},
tasks: function(TaskService, user) {
return user.canHaveTasks() ?
TaskService.find(user.id) : [];
}
}
});
上面的例子演示了解析项之间的依赖关系。我们要先加载user对象,如果该user可以拥有task,则加载task列表。而整个路由只有在所有解析项全部成功时才会加载。这就是说,控制器不需要验证user
属性是不是真正的user对象,因为如果第一个解析项失败,第二个解析项就不会执行了。
解析项有什么好处?
由于只有控制器才能直接访问$scope
,解析项似乎只是帮我们加载数据而已。但需要刷新数据时就不同了。CRUD模式经常需要进行加载记录列表、添加记录最后刷新列表的操作。最终的代码可能会变成这样:
angular.module('myApp')
.controller('UsersController', function($scope, UserService) {
UserService.all().then(function(users) {
$scope.users = users;
};
$scope.addUser = function(userData) {
UserService.add(userData).then(function() {
UserService.all().then(function(users) {
$scope.users = users;
};
};
};
});
不难发现,上面代码有许多重复,并且造成了回调陷阱。当刷新操作涉及到多个请求时尤为如此。一个简单的解决方案就是写一个loadUsers
函数,并在初始化和新记录添加后调用。这样虽然能解决代码重复的问题,但只是治标不治本。比如下面的情况:
- 一个user拥有多个task
- 一个project拥有多个timeframe
- 一个task属于一个timeframe
- timeframe不会被直接引用,但某些逻辑(如当前日期或当前时间)会通过当前timeframe来引用它
假设用/projects/:id/tasks
列出登录用户在当前timeframe下给定project中的所有task。此路由的控制器(UsersController)及其容器(
ProjectxController`)如下所示:
angular.module('myApp')
.controller('ProjectController',
function($scope, $routeParams, ProjectService) {
ProjectService.get($routeParams.id).then(function(project) {
$scope.project = project;
$scope.currentTimeframe = project.getCurrentTimeframe();
};
})
.controller('TasksController', function($scope, TaskService) {
$scope.$watch('currentTimeframe', function(timeframe) {
TaskService.list($scope.currentUser.id, timeframe.id)
.then(function(tasks) {
$scope.tasks = tasks;
});
});
});
这种代码看一眼就头疼。currentTimeframe
上的$watch
是必须的,因为需要在当前timeframe由于某种原因变化时重新加载task列表。这种写法最大的缺点就是,任何加载task之后的操作都不得不塞进$watch
中,导致最让人头疼的作用域问题,而且会让单元测试变得极其困难。此外,TasksController
本不应该关心currentUser
和currentTimeframe
,它应当只关心task。这样写出的控制器都是强耦合的。
我们可以通过嵌套解析项来去掉$watch
。下面的代码中的控制器就很干净:
angular.module('myApp')
.config(function($stateProvider) {
$stateProvider.state('project', {
url: '/project/:id',
controller: 'ProjectController',
resolve: {
project: function($stateParams, ProjectService) {
return ProjectService.get($stateParams.id);
},
currentTimeframe: function(project) {
return project.getCurrentTimeframe();
}
}
});
$stateProvider.state('project.tasks', {
url: '/tasks',
controller: 'TasksController',
resolve: {
tasks: function(TaskService, SessionService,
currentTimeframe) {
return TaskService.list(SessionService.currentUserId,
currentTimeframe.id);
}
}
});
})
.controller('ProjectController', function($scope, project) {
$scope.project = project;
})
.controller('TasksController', function($scope, tasks) {
$scope.tasks = tasks;
});
回到刷新的话题,在控制器中应该如何刷新解析后的数据呢?我们不应该在控制器中调用同一服务来更新$scope
,这样会造成代码重复(同一服务在不同的地方被调用了两次)。解决方法是利用UI Router提供的$state.reload()
方法。该方法是为了解决某些问题而引入的,但目前的实现还不会重新初始化控制器,也就是说虽然解析项会重新加载,但scope属性不会被更新。虽然需要用临时措施通过watch $state.$current.locals.globals
解决,但如果能不用$watch
就更好了。