本文译自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)
  • onEnteronExit回调

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本不应该关心currentUsercurrentTimeframe,它应当只关心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就更好了。