3 minute read

Сегодня хочу поделиться информацией о конструкции «controller as» в AngularJS, для чего его понадобилось добавлять и разобрать внутренее устройство его работы.

Что это за конструкция «controller as»

Как уже было выше сказано,- это специализированный синтаксис описания контроллера (например, в директиве ng-controller), который позволяет результат вызова конструктора неявно присвоить в поле сопутствующего (локального) $scope.

Напомню, что при создании каждого контроллера, в функцию $controller передаётся объект locals, который содержит в себе поля $scope, $element, $attrs и $transclude, присущие экземпляру создаваемого контроллера.

Если ранее, чтобы отобразить данные, предоставляемые контроллером, нам необходимо было явно инжектить $scope и инициализировать его поле:

app.controller("ProductCtrl", [
  "$scope",
  function ($scope) {
    $scope.products = ["Apple", "Banana", "Milk", "Coffee"];
  },
]);
<div ng-controller="ProductCtrl">
  <ul>
    <li ng-repeat="product in products">{{ product }}</li>
  </ul>
</div>
Apple
Banana
Milk
Coffee

То теперь, благодаря новому синтаксису, мы можем внутри контроллера оперировать указателем this, а результат будет автоматически присвоен полю локального $scope:

app.controller("ProductCtrlAs", [
  function () {
    this.products = ["Apple", "Banana", "Milk", "Coffee"];
  },
]);
<div ng-controller="ProductCtrlAs as ctrl">
  <ul>
    <li ng-repeat="product in ctrl.products">{{ product }}</li>
  </ul>
</div>
Apple
Banana
Milk
Coffee

Важно: В случае ручного вызова $controller, вторым параметром необходимо явно передавать объект, содержащий поле $scope (см. тест 'should throw an error if $scope is not provided').

Для чего потребовался новый синтаксис

Необходимость данной возможности обусловлена несколькими причинами:

Общая область видимости $scope’ов вложенных контроллеров при пересечении наименований:

<div ng-controller="TopCtrl">
  {{ title || 'undefined;' }}

  <div ng-controller="MiddleCtrl">
    {{ title || 'undefined;' }}

    <div ng-controller="BottomCtrl">{{ title || 'undefined;' }}</div>
  </div>
</div>

В том случае, когда одна из переменных не инициализирована, то на её замену ищется одноимённая из родительского $scope ($parent), что может приводить к следующему результату:

I am top ctrl!
I am top ctrl! ($scope MiddleCtrl не содержит значение title, поэтому оно берётся из родительского $scope)
I am bottom ctrl!

Таким образом, в коде появляется неоднозначность и сложность отслеживания источника данных. Для этого, мы можем переписать код представления с использованием синтаксиса «controller as», явно указывая какая переменная из какого $scope должна браться:

<div ng-controller="TopCtrlAs as top">
  {{ top.title || 'undefined;' }}

  <div ng-controller="MiddleCtrlAs as middle">
    {{ middle.title || 'undefined;' }}

    <div ng-controller="BottomCtrlAs as bottom">
      {{ bottom.title || 'undefined;' }}
    </div>
  </div>
</div>

Как итог, переменная будет искаться исключительно в $scope связанного контроллера:

I am top ctrl!
undefined
I am bottom ctrl!

Внутреннее устройство и реализация

На этапе создания экземпляра контроллера с помощью $controllerProvider, в том случае, когда первый параметр является строкой (строковое выражение контроллера), проводится поиск вхождения в ней частей регулярного выражения:

var CNTRL_REG = /^(\S+)(\s+as\s+([\w$]+))?$/;

[0] TopCtrlAs as top
[1] TopCtrlAs
[2] as top
[3] top

С помощью первого ([1] TopCtrlAs) элемента (наименование контроллера) ищется зарегистрированный ранее в объекте controllers конструктор контроллера и создаётся его экземпляр.

При наличии третьего ([3] top) элемента (наименование поля в $scope, которому присвоить результат вызова конструктора), вызывается функция addIdentifier, которая делает простое присвоение:

locals.$scope[identifier] = instance;

Тестовое покрытие

Описанное выше поведение успешно покрыто следующими тестами:

Поиск зарегистрированного ранее контроллера, результат вызова конструктора присваивается в поле foo объекта $scope.

it("should publish controller instance into scope", function () {
  var scope = {};

  $controllerProvider.register("FooCtrl", function () {
    this.mark = "foo";
  });

  var foo = $controller("FooCtrl as foo", { $scope: scope });
  expect(scope.foo).toBe(foo);
  expect(scope.foo.mark).toBe("foo");
});

Обязательность явной передачи локального объекта $scope в случае использования синтаксиса «controller as»:

it("should throw an error if $scope is not provided", function () {
  $controllerProvider.register("a.b.FooCtrl", function () {
    this.mark = "foo";
  });

  expect(function () {
    $controller("a.b.FooCtrl as foo");
  }).toThrowMinErr(
    "$controller",
    "noscp",
    "Cannot export controller 'a.b.FooCtrl' as 'foo'! No $scope object provided via `locals`."
  );
});

Бонусы

Как можно заметить из вышесказанного, если не возвращать из конструктора контроллера явно результат, то в локальную переменную $scope будет присвоен this. Однако, ничто не мешает нам сделать следующим образом и, всё же, вернуть результат:

app.controller("MainCtrl", function () {
  this.message = "I should be in scope";

  return { message: "Wrong!" };
});

В таком случае, на экран будет выведено именно ‘Wrong!’.

Так же, в процессе разбора исходников, наткнулся на довольно интересный тест, о котором не стоит забывать имея дело с объектами javascript:

it('should throw an exception if a controller is called "hasOwnProperty"', function () {
  expect(function () {
    $controllerProvider.register("hasOwnProperty", function ($scope) {});
  }).toThrowMinErr(
    "ng",
    "badname",
    "hasOwnProperty is not a valid controller name"
  );
});

Т.к. регистрация и выявление зарегистрированных ранее контроллеров происходит с помощью объекта controllers, то запрещено иметь наименование контроллера hasOwnProperty, дабы в последствии при выявлении controllers[constructor] не вернуть встроенную функцию языка (или не перегрузить её конструктором контроллера),- весьма предусмотрительно.

См. также:

  1. Digging into Angular’s “Controller as” syntax