Познаем дзен рефлексии и декораторов в TypeScript и Angular

Artur Androsovych
Sep 7, 2018 · 7 min read

В этой статье мы подробно поговорим о рефлексии, декораторах и что происходит когда мы используем декораторы Injectable, Component, Input, Output, ViewChild и другие в Angular.

Что такое декоратор в TypeScript?

Декораторы берут свое начало еще с других языков программирования, также часто декоратор называют шаблоном проектирования, я не буду останавливаться на истории этого шаблона, в интернете достаточно информации.

Важно знать и понимать, что декоратор в TypeScript это не шаблон проектирования, а всего лишь возможность (feature) самого языка. Есть разные виды декораторов — декораторы классов, декораторы свойств классов, декораторы методов и фабричные декораторы, и еще проще говоря декоратор — это всего лишь функция. Механизм инъекции зависимостей (dependency injection) в Angular работает только благодаря декораторам и самому языку TypeScript.

Рефлексия и декораторы

Здесь нужно остановиться на том как Microsoft решили использовать рефлексию вместе с декораторами. Что такое рефлексия? Рефлексия, в общей сложности, это механизм, который дает возможность получить информацию о типах в рантайме. В других языках программирования, более низкоуровневых, таких как C# (C#— высокоуровневый язык, но я говорю в контексте сравнения с JavaScript) есть статическая рефлексия. Благодаря статической рефлексии компилятор может сгенерировать байткод (C# компилятор генерирует байткод для CLR) еще на этапе компиляции для получения информации в рантайме. Эту информацию о типах принято называть metadata (метаданные). В JavaScript также есть рефлексия, уже давно являющаяся стандартом ecma-262. Имя ей Reflect API (обязательно к прочтению для дальнейшего понимания). Конечно это API очень тяжело назвать рефлексией, так как объект Reflect всего лишь предоставляет обертку для уже существующих методов. Как пример мы хотим вручную задать указатель контекста внутри функции через call или apply. Внизу приведен код как бы мы это сделали без Reflect и с:

Я хочу настоящую рефлексию, как этого добиться? Есть такая возможность, благодаря разработчику из Microsoft Ron Buckton, который создал полифил reflect-metadata.

reflect-metadata

Нельзя пропустить этот момент, так как рефлексия используется в Angular, многие этого не замечают, но когда вы делаете ng new, то angular-cli создает для вас скелетон с нужными файлами такими как main.ts, polyfills.ts и папка с приложением app. В polyfills.ts есть импорт полифила import ‘core-js/es7/reflect’; В принципе команда Angular решила не использовать отдельный npm пакет reflect-metadata, а core-js, так как там уже есть все полифилы для IE. Пакет reflect-metadata можно использовать вообще отдельно от Angular в других проектах, например вы захотите написать свой простенький механизм DI. Также для понимая того, что делает этот пакет нужно знать новые коллекции ES6Map и WeakMap.

Коллекция Map — это простой ассоциативный массив, где ключами может быть, что угодно.

Мы создали коллекцию Map, где ключом является строка, а значением объект типа Student. Вы спросите — можно же использовать обычный объект вместо Map, да, можно, но Map предоставляет больше возможностей, а именно методы такие как values, size, entries и так далее. Это более декларативный подход для работы с ассоциативным массивом в отличии от объекта, где чтобы узнать его размер можно сделать Object.keys(object).length, в случае с Map — есть метод size и любому разработчику будет понятно, что он делает.

Не забываем про коллекцию WeakMap, эта коллекция похожа на Map, но разница в том, что ключами в WeakMap могут быть только ссылки на объекты! То есть const students = new WeakMap<string, Student>(); — не скомпилируется, будет ошибка, что ключ не удовлетворяет ограничению, потому что ключ универсальный и он наследует тип object, взглянем на определение типа:

Реальный пример использования WeakMap — представьте, что у вас есть 5 ссылок на сайте и вы хотите повесить на них обработчики событий и также где-то хранить эти обработчики для удаления в будущем:

Ключом в WeakMap listeners есть ссылка на DOM элемент HTMLAnchorElement, это удовлетворяет ограничению, так как все элементы наследуются от Function, HTMLAnchorElement.constructor => Function, а Function.__proto__.__proto__.constructor => Object, также если мы изменим значение ссылки на нулевой указатель — то сборщик мусора автоматически очистит WeakMap по этому ключу, конечно не сразу, а на каком-то этапе цикла “mark and sweep”.

Возвращаемся к полифилу reflect-metadata, этот полифил расширяет возможности объекта Reflect, а именно добавляет следующие методы:

Метод Reflect.defineMetadata позволяет определить любую информацию об объекте в рантайме, например мы хотим по ссылке хранить инстанс функции:

Сам полифил под капотом создает WeakMap:

А теперь после этого лирического отклонения возвращаемся к тому, как работают декораторы в TypeScript :)

reflect-metadata + TypeScript

Сам компилятор TypeScript под капотом использует этот полифил. НО, для того, чтобы генерировать метаданные — есть 2 флага компилятора, это experimentalDecorators (разрешает использовать декораторы в коде) и emitDecoratorMetadata (разрешает компилятору генерировать метаданные о типах, только в связке с experimentalDecorators). Давайте напишем свой декоратор и посмотрим скомпилированный код:

Скомпилированный код:

Нам нужна строчка Module = __decorate([ myFirstDecorator ], Module), теперь я думаю вы догадываетесь, что декоратор — это всего лишь ОБЕРТКА. Метод __decorate генерируется самим компилятором, он принимает массив декораторов, потому что кол-во декораторов неограниченно.Что делает этот метод? Давайте сделаем beautify :)

Метод __decorate — проходится в цикле по нашим декораторам (функциям), копирует в переменную decorator и вызывает ее decorator(reflectedClass), присваивая возвращаемое значение переменной newClass, newClass нужен в тех случаях, если декоратор что-то возвращает, но заметьте, что декоратор принимает аргументом сам конструктор. Подводя итоги по декораторам — декораторы это не магия, у них нет определенной “цели”, это просто синтаксис, декларативный синтаксис.

Возвращаемся к генерации метаданных, если использовать декораторы в связке с объявлениями типов, то компилятор может сгенерировать нужную информацию:

Скомпилированный код:

Я отбросил метод __decorate, нас интересует новая функция __metadata, которая использует Reflect API, о котором мы говорили ранее. Как вы сами видите компилятор поместил вызов этой функции в метод __decorate с нужной нам информацией, функция __metadata — это просто обертка поверх Reflect.defineMetadata, заглянем в исходный код компилятора TypeScript:

Метаданные сгенерируются если включен нужный параметр, как вы видите есть условие if (compilerOptions.emitDecoratorMetadata). Есть 3 ключа, которые определены самой командой Microsoft, по которым хранится информация в рантайме о типах, design:paramtypes — типы в конструкторе (если мы декорируем сам класс), design:type — тип свойства класса (если мы декорируем свойство), design:returntype — тип возвращаемого значения функции(если мы декорируем метод). По дефолту всегда будет Object, если вы не указали никакой тип. Используя Reflect мы можем получить типы в конструкторе:

Стоп! То есть таким образом я могу сделать свой простенький механизм инъекции зависимостей только благодаря возможностям TypeScript? Ну конечно! Давайте сделаем свой декоратор Injectable:

Мы создали коллекцию dependencies, в которой будем хранить наши сервисы, которые нужно внедрить (inject), где ключом является ссылка на конструктор сервиса, а значением будет инстанс сервиса.

Вот и вся магия декораторов в Angular, а точнее всего DI, что делает декоратор Injectable? Да ничего :) Он просто позволяет компилятору генерировать нужную информацию для инжектора в Angular, механизм DI в Angular немного сложнее, но он не настолько громоздок, потому что представьте ситуацию, что класс Service тоже имеет зависимости и так по цепочке… Для этого в Angular под капотом есть метод resolveDep, который рекурсивно инициализирует зависимости. Injector — это всего лишь LazyMap, ассоциативный массив, который инициализирует нужную зависимость при первом доступе, только ключом выступает ссылка на сервис, в концепции Angular — это называет токен (OpaqueToken до Angular 4, InjectionToken в Angular 4+).

Component, Input, Output, ViewChild

Что такое Component? Это фабрика которая возвращает декоратор, как вы помните декоратор это просто обертка:

Если вы не знали, но можно писать и так, результат будет один и тот же, только вот DI работать не будет, потому что как вы помните генерация метаданных работает только в связке с декораторами :) Сначала мы вызываем функцию Component куда передаем параметром объект, в концепции Angular — это называется RenderType. Эта функция фабричная и она возвращает нужный нам декоратор, куда мы передаем наш AppComponent, под капотом декоратор Component всего лишь создает статическое свойство __annotations__ у компонента, если мы сделаем этот компонент видимым в глобальной области window.AppComponent = AppComponent и выведем в консоль свойство __annotations__, то мы получим массив и по 0 индексу там будет инстанс класса DecoratorFactory, да, команда Angular немного усложнила этот механизм, но сути это не меняет, в инстансе будут свойства:

Шаблон не скомпилированный, changeDetection: 0 — это OnPush стратегия, механизм того, как инициализируется компонент я описывать не буду. Взглянем на другие декораторы.

Что делают декораторы Inputи Output? Это точно также фабрики, они добавляют статическое свойство классу __prop__metadata__, это объект где ключами являются названия переменных:

Если в консоли браузера вы получите доступ по этому ключу AppComponent.__prop__metadata__ — вы увидите объект, у которого свойства a и myCoolEmitter:

Значением здесь является массив, где по 0 индексу инстанс класс PropDecoratorFactory. bindingPropertyName — это необязательный параметр, который принимает Input и Output, в случае когда вы биндите свойство в шаблоне и название этого свойства не совпадает с названием переменной. Когда запускается самый первый ChangeDetection на текущей вьюхе — Angular просто получает доступ к этому свойство и сеттит нужный нам байндинг в методе updateProp, к которому мы позже можем получить доступ в ngOnInit.

Как работает декоратор ViewChild? Это фабрика куда мы передаем строкой (берем самый простой вариант) идентификатор задекларированной шаблонной переменной:

Опять же в объект __prop__metadata__ добавляется словарь, где ключом будет название переменной, а значением массив с инстансом PropDecoratorFactory, но уже с другими свойствами:

После вызова конструктора компонента на этапе первого ChangeDetection, Angular строит первый DOM на основе AST (абстрактное синтаксическое дерево), компилятор сперва парсит ваш template в AST, далее делается проверка на то, что параметр, который мы передали во ViewChild — это строка, потому что там также может быть ссылка на конструктор другого компонента, после ngOnChanges и до ngOnInit Angular начинает строить первое дерево:

Это псевдокод, в реалии все декораторы трансформируются в статическую информацию Angular компилятором и механизм намного сложнее.

Декоратор HostListener работает аналогично:

В объект __prop__metadata__ добавляется свойство documentClick, а значением будет массив, где по 0 индексу инстанс PropDecoratorFactory со свойствами:

На этапе компиляции Angular трансформирует декоратор и парсит eventName, поэтому в рантайме Angular уже знает как и чему добавлять обработчик события через DomEventsPlugin. Эти свойства добавляются декораторами в рантайме если мы используем JIT, это происходит благодаря вызову декораторов, если мы используем AOT, то декораторы не вызываются, компилятор просто берет нужную информацию из AST.


Спасибо за чтение! Теперь вы знаете, что большинство механизмов Angular работает благодаря компилятору TypeScript, не зря в 2015 году Google отказались от plain-ES6 и договорились с Microsoft использовать TypeScript.

Artur Androsovych

Written by

Angular mentalist 😈 One of the core NGXS framework developers

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade