AngularJS 控制器,DRY 代码的设计模式

AngularJS controllers, design pattern for a DRY code

为了描述这个问题,我创建了一个完整的示例。我的实际应用程序比展示的演示还要大,每个控制器操作的服务和指令更多。这会导致更多的代码重复。我试图添加一些代码注释以进行澄清, PLUNKERhttp://plnkr.co/edit/781Phn?p=preview

重复部分:

routerApp.controller('page1Ctrl', function(pageFactory) {
  var vm = this;

  // page dependent
  vm.name = 'theOne';
  vm.service = 'oneService';
  vm.seriesLabels = ['One1', 'Two1', 'Three1'];

  // these variables are declared in all pages
  // directive variables,
  vm.date = {
    date: new Date(),
    dateOptions: {
      formatYear: 'yy',
      startingDay: 1
    },
    format: 'dd-MMMM-yyyy',
    opened: false
  };

  vm.open = function($event) {
    vm.date.opened = true;
  };

  // dataservice
  vm.data = []; // the structure can be different but still similar enough
  vm.update = function() {
      vm.data = pageFactory.get(vm.service);
    }

  //default call
  vm.update();   
})

基本上,我将所有可能的逻辑都移到了工厂和指令中。但是现在在每个使用特定指令的控制器中,我需要一个字段,例如,一个保持值的字段指令正在修改。它的设置。后来我需要类似的字段来保存来自dataservice的数据,调用本身(方法)也是一样的。

这会导致很多重复。


在图形上,我看到当前示例如下所示:

虽然我认为正确的设计应该看起来更像这样:


我试图在这里找到一些解决方案,但 none 似乎得到了证实。我发现了什么:

  1. AngularJS DRY controller structure,建议我传递 $scope 或 vm 并用额外的方法和字段装饰它。但许多消息来源称这是肮脏的解决方案。
  2. What's the recommended way to extend AngularJS controllers? 使用 angular.extend,但在使用 controller as 语法时会出现问题。
  3. 然后我也找到了答案(在上面的 link 中):

You don't extend controllers. If they perform the same basic functions then those functions need to be moved to a service. That service can be injected into your controllers.

即使我这样做了,仍然有很多重复。或者它只是必须的方式?像 John Papa sais (http://www.johnpapa.net/angular-app-structuring-guidelines/):

Try to stay DRY (Don't Repeat Yourself) or T-DRY

您遇到过类似的问题吗?有哪些选择?

我无法在评论中回复,但在这里我会做什么:

我将有一个 ConfigFactory 保存页面因变量的映射:

{
  theOne:{
      name: 'theOne',
      service: 'oneService',
      seriesLabels: ['One1', 'Two1', 'Three1']
  },
  ...
}

然后我将拥有一个带有 newInstance() 方法的 LogicFactory,以便在我每次需要它时获取适当的对象。 logicFactory 将获取控制器之间共享的所有数据/方法。 对于这个 LogicFactory,我将提供特定于视图的数据。视图必须绑定到这个工厂。

为了检索特定于视图的数据,我将在路由器中传递配置映射的密钥。

假设路由器给你#current=theOne,我会在控制器中做:

var specificData = ServiceConfig.get($location.search().current);
this.logic = LogicFactory.newInstance(specificData);

希望对您有所帮助

我修改了你的例子,这是结果:http://plnkr.co/edit/ORzbSka8YXZUV6JNtexk?p=preview

Edit: Just to say this way, you can load the specific configuration from a remote server serving you the specific-view data

您可以使用指令减少大量样板文件。我创建了一个简单的控制器来替换您的所有控制器。您只需通过属性传递特定于页面的数据,它们就会绑定到您的范围。

routerApp.directive('pageDir', function() {
  return {
    restrict: 'E',
    scope: {},
    controller: function(pageFactory) {
      vm = this;
      vm.date = {
        date: moment().startOf('month').valueOf(),
        dateOptions: {
          formatYear: 'yy',
          startingDay: 1
        },
        format: 'dd-MMMM-yyyy',
        opened: false
      };

      vm.open = function($event) {
        vm.date.opened = true;
      };

      // dataservice
      vm.data = []; // the structure can be different but still similar enough
      vm.update = function() {
        vm.data = pageFactory.get(vm.service);
      };

      vm.update();
    },
    controllerAs: 'vm',
    bindToController: {
      name: '@',
      service: '@',
      seriesLabels: '='
    },
    templateUrl: 'page.html',
    replace: true
  }
});

如您所见,它与您的控制器没有太大区别。不同之处在于,要使用它们,您将使用路由 template 属性 中的指令来初始化它。像这样:

    .state('state1', {
        url: '/state1',
        template: '<page-dir ' +
          'name="theOne" ' +
          'service="oneService" ' +
          'series-labels="[\'One1\', \'Two1\', \'Three1\']"' +
          '></page-dir>'
    })

差不多就这些了。我分叉了你的 Plunk 来演示。 http://plnkr.co/edit/NEqXeD?p=preview

编辑:忘了补充一点,您也可以根据需要设置指令的样式。我在删除冗余代码时忘记将其添加到 Plunk。

从整体设计的角度来看,我认为装饰控制器和扩展控制器之间没有太大区别。最后,这些都是混合的一种形式,而不是继承。所以这真的取决于你最喜欢的工作方式。其中一项重要的设计决策不仅涉及如何将功能传递给所有控制器,还涉及如何传递功能以将 3 个控制器中的 2 个也传递给控制器​​。

工厂装潢师

正如您提到的那样,一种方法是将您的 $scope 或 vm 传递给工厂,工厂用额外的方法和字段装饰您的控制器。我不认为这是一个肮脏的解决方案,但我可以理解为什么有些人想要将工厂与他们的 $scope 分开,以便将他们的代码关注点分开。如果您需要向 2 out of 3 场景添加额外的功能,您可以传入额外的工厂。我做了一个plunker example of this

dataservice.js

routerApp.factory('pageFactory', function() {

    return {
      setup: setup
    }

    function setup(vm, name, service, seriesLabels) {
      // page dependent
      vm.name = name;
      vm.service = service;
      vm.seriesLabels = seriesLabels;

      // these variables are declared in all pages
      // directive variables,
      vm.date = {
        date: moment().startOf('month').valueOf(),
        dateOptions: {
          formatYear: 'yy',
          startingDay: 1
        },
        format: 'dd-MMMM-yyyy',
        opened: false
      };

      vm.open = function($event) {
        vm.date.opened = true;
      };

      // dataservice
      vm.data = []; // the structure can be different but still similar enough
      vm.update = function() {
        vm.data = get(vm.service);
      }

      //default call
      vm.update();
    }

});

page1.js

routerApp.controller('page1Ctrl', function(pageFactory) {
    var vm = this;
    pageFactory.setup(vm, 'theOne', 'oneService', ['One1', 'Two1', 'Three1']);
})

扩展控制器

您提到的另一种解决方案是扩展控制器。这可以通过创建一个混合到正在使用的控制器中的超级控制器来实现。如果您需要为特定控制器添加额外的功能,您可以混合使用其他具有特定功能的超级控制器。这里有一个plunker example

父页面

routerApp.controller('parentPageCtrl', function(vm, pageFactory) {

    setup()

    function setup() {

      // these variables are declared in all pages
      // directive variables,
      vm.date = {
        date: moment().startOf('month').valueOf(),
        dateOptions: {
          formatYear: 'yy',
          startingDay: 1
        },
        format: 'dd-MMMM-yyyy',
        opened: false
      };

      vm.open = function($event) {
        vm.date.opened = true;
      };

      // dataservice
      vm.data = []; // the structure can be different but still similar enough
      vm.update = function() {
        vm.data = pageFactory.get(vm.service);
      }

      //default call
      vm.update();
    }

})

page1.js

routerApp.controller('page1Ctrl', function($controller) {
    var vm = this;
    // page dependent
    vm.name = 'theOne';
    vm.service = 'oneService';
    vm.seriesLabels = ['One1', 'Two1', 'Three1'];
    angular.extend(this, $controller('parentPageCtrl', {vm: vm}));
})

嵌套状态UI-路由器

由于你使用的是ui-router,你也可以通过嵌套状态达到类似的效果。对此的一个警告是 $scope 不会从父控制器传递到子控制器。因此,您必须在 $rootScope 中添加重复代码。当我想在整个程序中传递一些功能时,我会使用它,例如测试我们是否在移动 phone 上的功能,它不依赖于任何控制器。这里有一个plunker example.

我遇到了与您描述的完全相同的问题。我非常支持保持干燥。当我开始使用 Angular 时,没有规定或推荐的方法来执行此操作,所以我只是在使用过程中重构了我的代码。与许多事情一样,我不认为他们是做这些事情的正确或错误的方法,所以使用你觉得舒服的方法。所以下面是我最终使用的,它对我很有帮助。

在我的应用程序中,我通常有三种类型的页面:

  1. 列表页 - Table 特定资源列表。你可以 search/filter/sort 你的数据。
  2. 表单页面 - 创建或编辑资源。
  3. 显示页面 - resource/data.
  4. 的详细只读显示页面

我发现 (1) 和 (2) 中通常有很多重复代码,我指的不是应该提取到服务中的功能。因此,为了解决我使用以下继承层次结构的问题:

  1. 列出页面

    • 基础列表控制器
      • loadNotification()
      • 搜索()
      • 高级搜索()
      • 等....
    • 资源列表控制器
      • 任何资源特定的东西
  2. 表单页面

    • BaseFormController
      • setServerErrors()
      • clearServerErrors()
      • 诸如警告用户在保存表单之前离开此页面之类的内容,以及任何其他常规功能。
    • 抽象形式控制器
      • 保存()
      • processUpdateSuccess()
      • processCreateSuccess()
      • processServerErrors()
      • 设置任何其他共享选项
    • ResourceFormController
      • 任何资源特定的东西

要启用此功能,您需要制定一些约定。对于表单页面,我通常每个资源只有一个视图模板。使用路由器 resolve 功能,我传入一个变量以指示表单是否用于创建或编辑目的,并将其发布到我的 vm 上。然后可以在您的 AbstractFormController 中使用它来调用保存或更新您的数据服务。

为了实现控制器继承,我使用 Angulars $injector.invoke 函数传入 this 作为实例。由于 $injector.invoke 是 Angulars DI 基础设施的一部分,它工作得很好,因为它将处理基本控制器 classes 需要的任何依赖关系,并且我可以根据需要提供任何特定的实例变量.

这里是一个小片段,展示了它是如何实现的:

Common.BaseFormController = function (dependencies....) {
    var self = this;
    this.setServerErrors = function () {
    };
    /* .... */
};

Common.BaseFormController['$inject'] = [dependencies....];

Common.AbstractFormController = function ($injector, other dependencies....) {
    $scope.vm = {};
    var vm = $scope.vm;
    $injector.invoke(Common.BaseFormController, this, { $scope: $scope, $log: $log, $window: $window, alertService: alertService, any other variables.... });
   /* ...... */
}

Common.AbstractFormController['$inject'] = ['$injector', other dependencies....];

CustomerFormController = function ($injector, other dependencies....) {
    $injector.invoke(Common.AbstractFormController, this, {
            $scope: $scope,
            $log: $log,
            $window: $window,
            /* other services and local variable to be injected .... */
        });

    var vm = $scope.vm;
    /* resource specific controller stuff */
}

CustomerFormController['$inject'] = ['$injector', other dependencies....];

更进一步,我发现通过我的数据访问服务实施,重复代码大量减少。对于数据层约定为王。我发现,如果您在服务器 API 上保持通用约定,您可以使用基数 factory/repository/class 或任何您想调用它的方式走很长的路。我在 AngularJs 中实现这一点的方法是使用 AngularJs 工厂,returns 一个基础存储库 class,即工厂 returns a javascript class 具有原型定义而不是对象实例的函数,我称之为 abstractRepository。然后,对于每个资源,我为该特定资源创建了一个具体的存储库,该资源典型地继承自 abstractRepository,因此我从 abstractRepository 继承了所有 shared/base 特性,并为具体存储库定义了任何特定于资源的特性。

我想举个例子会更清楚。假设您的服务器 API 使用以下 URL 约定(我不是最纯粹的 REST,因此我们将约定留给您想要实现的任何内容):

GET  -> /{resource}?listQueryString     // Return resource list
GET  -> /{resource}/{id}                // Return single resource
GET  -> /{resource}/{id}/{resource}view // Return display representation of resource
PUT  -> /{resource}/{id}                // Update existing resource
POST -> /{resource}/                    // Create new resource
etc.

我个人使用 Restangular,所以下面的例子是基于它的,但是你应该能够很容易地将它改编成 $http 或 $resource 或者你正在使用的任何库。

抽象资料库

app.factory('abstractRepository', [function () {

    function abstractRepository(restangular, route) {
        this.restangular = restangular;
        this.route = route;
    }

    abstractRepository.prototype = {
        getList: function (params) {
            return this.restangular.all(this.route).getList(params);
        },
        get: function (id) {
            return this.restangular.one(this.route, id).get();
        },
        getView: function (id) {
            return this.restangular.one(this.route, id).one(this.route + 'view').get();
        },
        update: function (updatedResource) {
            return updatedResource.put();
        },
        create: function (newResource) {
            return this.restangular.all(this.route).post(newResource);
        }
        // etc.
    };

    abstractRepository.extend = function (repository) {
        repository.prototype = Object.create(abstractRepository.prototype);
        repository.prototype.constructor = repository;
    };

    return abstractRepository;
}]);

具体的仓库,我们以customer为例:

app.factory('customerRepository', ['Restangular', 'abstractRepository', function (restangular, abstractRepository) {

    function customerRepository() {
        abstractRepository.call(this, restangular, 'customers');
    }

    abstractRepository.extend(customerRepository);
    return new customerRepository();
}]);

所以现在我们有了数据服务的通用方法,可以在 Form 和 List 控制器库中轻松使用这些方法 classes.

总结之前的答案:

  1. 装饰控制器:正如你所说,这是一个肮脏的解决方案;想象一下,让不同的工厂装饰同一个控制器,将很难(尤其是对于其他开发人员)防止属性冲突,同样也很难追踪哪个工厂添加了哪些属性。这实际上就像在 OOP 中拥有多重继承一样,出于同样的原因,大多数现代语言在设计上都避免了这种情况。

  2. 使用指令:如果您的所有控制器都将具有相同的 html 视图,这可能是一个很好的解决方案,但除此之外,您必须在其中包含相当复杂的逻辑您的视图可能难以调试。


我建议的方法是使用组合(而不是使用装饰器继承)。把工厂里所有重复的逻辑分离出来,只把工厂的创建留在controller里。

routerApp.controller('page1Ctrl', function (Page, DateConfig, DataService) {
    var vm = this;

    // page dependent
    vm.page = new Page('theOne', 'oneService', ['One1', 'Two1', 'Three1']);

    // these variables are declared in all pages
    // directive variables,
    vm.date = new DateConfig()

    // dataservice
    vm.dataService = new DataService(vm.page.service);

    //default call
    vm.dataService.update();

})

.factory('Page', function () {

    //constructor function
    var Page = function (name, service, seriesLabels) {
        this.name = name;
        this.service = service;
        this.seriesLabels = seriesLabels;
    };

    return Page;

})


.factory('DateConfig', function () {

    //constructor function
    var DateConfig = function () {
        this.date = new Date();
        this.dateOptions = {
            formatYear: 'yy',
            startingDay: 1
        };
        this.format = 'dd-MMMM-yyyy';
        this.opened = false;
        this.open = function ($event) {
            this.opened = true;
        };
    };

    return DateConfig;

})

此代码未经测试,但我只是想提供一个想法。这里的关键是分离工厂中的代码,并将它们作为属性添加到控制器中。这样实现就不会重复(DRY),控制器代码中的一切都是显而易见的。

您可以通过将所有工厂包装在一个更大的工厂(外观)中来使您的控制器更小,但这可能会使它们更紧密地耦合。