javascript

Универсальная функция создания объектов на примере реализации $injector.instantiate в angularjs

  • вторник, 6 июня 2017 г. в 03:14:38
https://habrahabr.ru/post/330214/
  • JavaScript
  • AngularJS


Задумывались ли вы когда-нибудь, как создаются экземпляры используемых вами типов angularJS? Контроллеры, фабрики, сервисы, декораторы, значения- буквально каждый из них в конце концов передаётся на исполнение в функцию instantiate объекта $injector, где их поджидает довольно занимательная конструкция, о которой сегодня и хотелось бы поговорить.



А именно, речь пойдёт о следующей строке:

return new (Function.prototype.bind.apply(ctor, args))();

Очевиден ли для вас сходу принцип её действия? Если ответ положительный, то благодарю за внимание и уделённое вами время :)

Теперь же, когда все съевшие собаку на javascript читатели нас покинули, хотел бы ответить на собственный вопрос: впервые увидев эту строку, я растерялся и абсолютно ничего не понял во всех этих взаимоотношениях и хитросплетениях функций bind, apply, new и (). Давайте разбираться. Начать я предлагаю от обратного, а именно: пускай у нас есть некий параметризованный конструктор, экземпляр которого мы и хотим создать:

function Animal(name, sound) {
  this.name = name;
  this.sound = sound;
}


new


«Что может быть проще»- скажете вы и будете правы: var dog = new Animal('Dog', 'Woof!');. Оператор new — это первое, что нам потребуется, чтобы получить экземпляр вызова конструктора Animal. Небольшое отступление о том, как работает new:

Когда исполняется new Foo(...), происходит следующее:

1. Создается новый объект, наследующий Foo.prototype.
2. Вызывается конструктор — функция Foo с указанными аргументами и this, привязанным к только что созданному объекту. new Foo эквивалентно new Foo(), то есть если аргументы не указаны, Foo вызывается без аргументов.
3. Результатом выражения new становится объект, возвращенный конструктором. Если конструктор не возвращет объект явно, используется объект из п. 1. (Обычно конструкторы не возвращают значение, но они могут делать это, если нужно переопределить обычный процесс создания объектов.)
Подробнее

Отлично, теперь давайте обернём наш вызов конструктора Animal в функцию, чтобы код инициализации был общим для всех требуемых вызовов:

function CreateAnimal(name, sound) {
    return new Animal(name, sound);
}

Со временем мы начинаем хотеть создавать не только животных, но и людей (соглашусь, пример не самый удачный), а значит, у нас есть как минимум 2 варианта:

  1. Реализовать фабрику, которая в зависимости от требуемого нам типа будет сама создавать необходимый экземпляр;
  2. Передавать функцию конструктора в качестве параметра и на её основе создавать новую с уже привязанными к ней передаваемыми аргументами (с чем нам прекрасно помогает функция bind).

И в случае с $injector.instantiate был выбран второй путь:

bind


function Create(ctorFunc, name, sound) {
    return new (ctorFunc.bind(null, name, sound));
}

console.log( Create(Animal, 'Dog', 'Woof') );
console.log( Create(Human, 'Person') );

Небольшое отступление о том, как работает bind:

Метод bind() создаёт новую функцию, которая при вызове устанавливает в качестве контекста выполнения this предоставленное значение. В метод также передаётся набор аргументов, которые будут установлены перед переданными в привязанную функцию аргументами при её вызове.
Подробнее

В нашем случае в качестве контекста мы передаем null, т.к. планируем использовать новую созданную с помощью bind функцию с оператором new, который игнорирует this и создает для него пустой объект. Результатом выполнения функции bind станет новая функция с уже привязанными к ней аргументами (т.е. return new fn;, где fn — результат вызова bind).

Отлично, теперь мы можем использовать нашу функцию, чтобы создавать любых животных и людей, конструкторы которых… принимают параметры name и sound. «Но ведь не все аргументы, которые требуются для животных будут необходимыми и для людей»- скажете вы и будете правы- назревают 2 проблемы:

  1. Аргументы конструкторов могут начать меняться (например порядок или их количество), а значит вносить изменения нам потребуется сразу в нескольких местах: в сигнатуры конструкторов, строки вызова функции Create и строку создания экземпляра return new (ctorFunc.bind(null, name, sound ));
  2. Чем больше у нас появляется конструкторов, тем выше вероятность того, что аргументы для их создания нам потребуются разные, и мы уже не сможем использовать единую функцию (или же нам придётся перечислять все из них, а заполнять лишь необходимые).


apply


Решением этих проблем может стать сквозная передача аргументов из функции создания прямиком в конструктор, другими словами- универсальная функция, принимающая конструктор и необходимый массив аргументов и возвращающая новую функцию, к которой эти аргументы привязаны. Для этого в javascript есть замечательная функция apply (или её аналог call, если количество аргументов известно заранее).

Небольшое отступление о том, как работает apply:

Метод apply() вызывает функцию с указанным значением this и аргументами, предоставленными в виде массива (либо массивоподобного объекта).
Хотя синтаксис этой функции практически полностью идентичен функции call(), фундаментальное различие между ними заключается в том, что функция call() принимает список (перечень) аргументов, в то время, как функция apply() принимает массив аргументов (единым параметром).
Подробнее

Здесь начинается, пожалуй, самая сложная часть, т.к. нам предстоит используя apply установить контекстом для функции bind наш конструктор (аналогично ctorFunc.bind), а в качестве аргументов для функции bind (не забывая о том, что первым аргументом является устанавливаемый контекст) передать смещенный на одну позицию вправо массив параметров конструктора, используя ctorArgs.unshift(null).



Функция bind недоступна в контексте выполнения Create, т.к. им является объект window, зато доступна посредством прототипа функции Function.prototype.

Итоговым результатом станет следующая универсальная функция:

function Create(ctorFunc, ctorArgs) {
  ctorArgs.unshift(null);
  return new (Function.prototype.bind.apply(ctorFunc, ctorArgs ));
}

console.log( Create(Animal, ['Dog', 'Woof']) );
console.log( Create(Human, ['Person', 'John', 'Engineer', 'Moscow']) );

Возвращаясь к angularJS, мы можем заметить, что в качестве Animal и Human, например, выступают конструкторы фабрик или других типов, а в качестве массива аргументов ['Dog', 'Woof'] — найденные (разрезолвленные) по имени зависимости:

angular
    .module('app')
    .factory(function($scope) {
        // constructor
    });

или

angular
    .module('app')
    .factory(['$scope', function($scope) { 
        // constructor 
    }]);

Всё что остаётся сделать для реализации полноценного метода $injector.instantiate, это найти функцию конструктора и получить необходимые аргументы и можно создавать :)