Наследование в AngularJS

Привет!

Сегодня мы поговорим о наследовании в AngularJS. Поскольку в нами любимом фрэймворке нет встроенного механизма наследования, в этой статье мы с вами посмотрим, как при помощи паттернов наследования JavaScript организовать наследование в компонентах AngularJS.

Наследование контроллеров

Для начала давайте рассмотрим, как это работает в контроллерах. На самом деле, наследование одного контроллера от другого (за исключением использования «controller as» синтаксиса) — достаточно маловероятно. Всё потому, что scope дочерних контроллеров наследуется через прототип от родительского scope. Поэтому, если вам нужно переиспользовать функциональность родительского контроллера, всё что вам нужно — добавить требуемые методы в родительский scope. Сделав это, дочерний контроллер будет иметь доступ ко всем методам через прототип его scope. Вот вам пример:

myApp.controller('MyParentController', function ($scope) {
  $scope.parentMethod = function () {
    console.log('function body...');
  };
});

myApp.controller('MyChildController', function ($scope) {
  $scope.parentMethod();
});

Конечно, наследование создает сильную связанность, но только в одном направлении (т.е. дочерний контроллер зависит от родительского), поэтому вы не можете напрямую вызвать дочерний метод из вашего родительского контроллера. Если у вас всё-таки появилась такая необходимость, вы можете использовать механизм событий:

myApp.controller('MyParentController', function ($scope) {
  $scope.$broadcast('event', args);
  $scope.$on('event-response', function (result) {
      console.log('function body...');
    });
});

myApp.controller('MyChildController', function ($scope) {
  $scope.$on('parent-event', function (args) {
    var result;
    console.log('result calculation...');
    $scope.$emit('parent-event-response', result);
  });
});

Этот пример приведён исключительно с информативной целью, т.к. если вам требуется вызывать методы дочернего контроллера из родительского, задумайтесь — скорее всего, вы что-то делаете не так.

А теперь давайте представим, что у вас есть две страницы с практически одинаковым функционалом. Предположим, что у них пересечение в общей части более 50%. Несмотря на то, что у них может быть абсолютно разные представления (view), логика, стоящая за ними, может быть похожа, даже очень. В таком случае вы можете создать базовый контроллер, который будет содержать в себе общую логику, и два дочерних контроллера, расширяющих базовый. Базовый контроллер необязательно должен быть реализован как AngularJS-контроллер. Вы можете использовать обычную функцию-конструктор. Вот пример:

function MyBaseController($scope, $location, ...) {
  $scope.commonMethodInScope = function () {
    console.log('function body...');
  };
  $scope.commonVar = 12;
}

MyBaseController.prototype.commonMethod1 = function () {
  console.log('function body...');
};

MyBaseController.prototype.commonMethod2 = function () {
  console.log('function body...');
};

После этого дочерний контроллер может легко наследоваться от базового вот таким вот образом:

function MyChildController1($scope, $location, ...) {
  MyBaseController.call(this, $scope, $location, ...);
  $scope.childMethodInScope = function () {
    this.commonMethod2();
  };
}

MyChildController1.prototype = Object.create(MyBaseController.prototype);

MyChildController1.prototype.childMethod1 = function () {
  this.commonMethod1();
};

myApp.controller('MyChildController1', MyChildController1);

В коде выше видно, что применяется JS-паттерн наследования на классах. Так же нужно сделать и со вторым контроллером.

Синтаксис «controller as»

Начиная с AngularJS 1.2 в фрэймворк был добавлен «controller as» синтаксис. Что он даёт? Он позволяет нам создавать псевдонимы для наших контроллеров. К примеру, используя директиву ng-controller мы можем сделать так:

<div ng-controller="BossController as boss">
  <button ng-click="boss.onClick()">Click</button>
</div>

со следующим контроллером:

function BossController() {
  this.name = 'myName';
}

BossController.prototype.onClick = function () {
  alert('Oooooh! You\'ve clicked me!');
};

Ещё один пример, показывающий преимущества данного подхода:

function MyBaseController() {
  this.name = 'myName';
}

MyBaseController.prototype.parentMethod = function () {
  console.log('function body...');
};

function MyChildController() {
  MyBaseController.call(this);
  this.name = 'myChildName';
}

MyChildController.prototype = Object.create(MyBaseController.prototype);

MyChildController.prototype.childMethod = function () {
  this.parentMethod();
  console.log('function body...');
};

app.controller('MyBaseController', MyBaseController);
app.controller('MyChildController', MyChildController);

Разметка для кода выше:

<div ng-controller="MyBaseController as base">
  <button ng-click="base.parentMethod()">Предок</button>
  <div ng-controller="MyChildController as child">
    <button ng-click="child.childMethod()">Потомок</button>
  </div>
</div>

Когда пользователь жмёт кнопку с подписью «Предок», вызовется parentMethod, определённый в MyBaseController. Если же пользователь нажмёт «Потомок», будет вызван childMethod. Заметим, что в своём теле он также вызывает parentMethod.

Наследование сервисов

Как вы знаете, существует два способа создания внедряемого через Dependency Injection (DI) AngularJS-сервиса:

module.factory(name, factoryFunc);
module.service(name, factoryFunc);

module.factory

В module.factory функция factoryFunc возвращает литерал объекта, который и является сервисом. У себя под капотом AngularJS вызывает функцию фабрики внутри определения injector’а. Если вам нужно наследование в сервисах, которые созданы через module.factory, вам отлично подойдёт паттерн прототипного наследование путём вызова Object.create.

Давайте создадим базовый сервис:

var BaseService = (function () {
  var privateVar = 0;
  return {
    anyDoing: function () {
      if (privateVar === 42) {
        alert('It is answer!!');
      }
      privateVar++;
    };
  };
}());

А теперь дочерний сервис:

var MyChildService = Object.create(BaseService);
MyChildService.anyDoingYet = function () {
  console.log('function body...');
};

module.factory('MyChildService', function () {
  return MyChildService;
});

Теперь вы можете внедрять MyChildService в AngularJS-компоненты и переиспользовать наследованные от BaseService возможности.

function MyController(MyChildService) {
  MyChildService.anyDoing();
}

Внедрение родителя

В сервисах можно применять ещё один паттерн наследования — внедрение родительского сервиса через DI (Dependency Injection) и создание нового объекта, наследующего от родительского через прототип:

module.factory('ParentService', function ($http) {
  return {
    // API, доступное снаружи
  };
});

module.factory('MyChildService', function (ParentService, $sce) {
  var child = Object.create(ParentService);
  child.childMethod = function () {
    // расширение родителя
  };
  return child;
});

Всю прелесть этого подхода мы увидим, когда нам потребуется внедрять какие-нибудь зависимости в родительский сервис перед наследованием от него.

module.service

Обычно я работаю с переменными, находящимися в scope, и специальными сервисами, которые инкапсулируют в себе бизнес-логику — Модели.

Эти сервисы по своей реализации чем-то похожи на паттерн ActiveRecord. Мои модели обычно отвечают за общение с сервером напрямую или через некий шлюз. Для создания таких моделей я предпочитаю использовать метод module.service. Внутри эти сервисы создаются через инстанцирующий метод.

Почему я предпочитаю использовать метод service вместо factory? Ну… возможно, я заблуждающийся разработчик, который не понимает истинной силы прототипного наследование, используемого с литералом объекта, но я предпочитаю использовать для моделей наследование на классах. Используя его, я могу создать набор функций-конструкторов, которые достаточно хорошо представляют мою доменную модель. В один прекрасный день я могу решить использовать MDD (Model Driven Development) и генерировать все мои модели из каких-нибудь UML-диаграммы.

Вот пример того, как вы можете получить преимущество от паттерна наследования на классах для AngularJS-сервисов, созданных через module.service:

function Man(name) {
  this.name = name;
}

Man.prototype.talk = function () {
  return 'Меня зовут ' + this.name;
};

Man.$inject = ['name'];

function Programmer(name, abilities) {
  Man.call(this, name);
  this.abilities = abilities;
}

Programmer.prototype = Object.create(Man.prototype);

Programmer.prototype.writeCode = function () {
  return 'I\'m writing code...';
};

Programmer.$inject = ['name', 'Abilities'];

angular.module('app').service('Man', Man);
angular.module('app').service('Programmer', Programmer);
angular.module('app').value('name', 'Mega Developer');
angular.module('app').value('Abilities', ['Java', 'PHP', 'JavaScript']);

Вот и всё! Надеюсь, тебе моя статья помогла, и ты обязательно организуешь свой JS код с максимальным переиспользованием кода! Наследуйся правильно и будет тебе счастье :)

Делись ссылкой с друзьями и читай другие статьи моего блога.

До новых встреч!

  • Дима Рудык

    Привет есть вопрос, возможно немного не по теме но, вот у меня есть родительский компонент с селект боксом по изменению которого в дочерних компонентах должен вызваться метод, как лучше всего это сделать?

    • Привет!
      Да, немного не по теме наследования, но попробую подсказать)
      Тяжело сказать, как лучше, не зная всех вводных, но могу посоветовать попробовать подписаться на изменение свойства, которое меняет селект бокс в дочернем компоненте. Например:
      $scope.$watch(‘myVar’, function() {
      alert(‘hey, myVar has changed!’);
      });
      В таком случае родительский компонент ничего не знает о дочерних, а дочерние ничего о родительском. И это хорошо!) Подробнее про watch — https://docs.angularjs.org/api/ng/type/$rootScope.Scope#$watch
      Либо можно в селект боксе публиковать событие, а в дочерних элементах слушать эти события через механизмы $scope.$emit, $scope.$broadcast и $scope.$on. Подробнее про них можно почитать по той же ссылке.

      Сходу такие варианты видятся. Я бы порекомендовал первый способ, но решение принимать не мне :)

      • Дима Рудык

        селект находиться в родительком, а по его изменению нада вызвать метод дочернего