Темная сторона TypeScript — @декораторы на примерах

Темная сторона TypeScript — @декораторы на примерах

Декораторы — это невероятно круто. Они позволяют описывать мета информацию прямо в объявлении класса, группируя все в одном месте и избегая дублирования. Ужасно удобно. Однажды попробовав, вы уже никогда не согласитесь писать по-старому.

Однако, несмотря на всю полезность, декораторы в TypeScript (заявлены также на стандарт) не так просты, как хотелось бы. Работа с ними требует навыков джедая, так как необходимо разбираться в объектной модели JavaScript (ну, вы поняли, о чем я), API несколько запутанный и, к тому же, еще не стабильный. В этой статье я немного расскажу об устройстве декораторов и покажу несколько конкретных приемов, как поставить эту темную силу на благо front-end разработки.

Помимо TypeScript, декораторы доступны в Babel. В этой статье рассматривается только реализация в TypeScript.

Основы

Декорировать в TypeScript можно классы, методы, параметры метода, методы доступа свойства (accessors) и поля.

В TypeScript термин "поле" обычно не используется, и поля называют также свойствами (property). Это создает большую путаницу, т.к. разница есть. Если мы объявляем свойство с методами доступа get/set, то в объявлении класса появляется вызов Object.defineProperty и в декораторе доступен дескриптор, а если объявляем просто поле (в терминах C# и Java) — то не появляется ничего, и, соответственно, дескриптор не передается в декоратор. Это определяет сигнатуру декораторов, поэтому я использую термин "поле", чтобы отличать их от свойств с методами доступа.

В общем случае, декоратор — это выражение, предваренное символом "@", которое возвращает функцию определенного вида (разного в каждом случае). Собственно, можно просто объявить такую функцию и использовать ее имя в качестве выражения декоратора:

Однако можно использовать любое другое выражение, которое вернет такую функцию. Например, можно объявить другую функцию, которая будет принимать параметрами дополнительную информацию, и возвращать соответствующую лямбду. Тогда в качестве декоратора будем использовать выражение "вызов функции MyAdvancedDecorator".

Здесь самый обычный вызов функции, поэтому, даже если мы не передаем параметры, все равно нужно писать скобки "@MyAdvancedDecorator()". Собственно, это два основных способа объявления декораторов.

В процессе компиляции объявление декоратора приводит к появлению вызова нашей функции в определении класса. То есть там, где вызываются Object.defineProperty , заполняется прототип класса и все такое. Как именно это происходит — важно знать, т.к. это объясняет, когда вызывается декоратор, что представляют собой параметры нашей функции, почему они именно такие, а также что и как в декораторе можно сделать. Ниже приведен упрощенный код, в который компилируется наш класс с декоратором:

В таблице ниже приведено описание функции для каждого вида декораторов, а также ссылки на примеры в TypeScript Playground, где можно посмотреть, во что точно компилируются декораторы и попробовать их в действии.

  • target — конструктор класса
  • returns — конструктор класса или null. Если вернуть конструктор, то он заменит оригинальный. При этом необходимо также настроить прототип в новом конструкторе.
  • target — прототип класса
  • propertyKey — имя метода (сохраняется при минификации); в текущей реализации тип — string
  • descriptor — дескриптор метода*
  • returns — дескриптор метода* или null
  • target — конструктор класса
  • propertyKey — имя метода (сохраняется при минификации); в текущей реализации тип — string
  • descriptor — дескриптор метода*
  • returns — дескриптор метода* или null
  • target — прототип класса
  • propertyKey — имя метода (сохраняется при минификации); в текущей реализации тип — string
  • index — индекс параметра в списке параметров
  • returns — void
  • target — прототип класса
  • propertyKey — имя поля (сохраняется при минификации); в текущей реализации тип — string
  • returns — null или дескриптор свойства; если вернуть дескриптор, то он будет использован для вызова Object.defineProperty; однако, при подключении библиотеки reflect-metadata этого не происходит (это баг в reflect-metadata)
  • target — конструктор класса
  • propertyKey — имя поля (сохраняется при минификации); в текущей реализации тип — string
  • returns — null или дескриптор свойства; если вернуть дескриптор, то он будет использован для вызова Object.defineProperty; однако при подключении библиотеки reflect-metadata этого не происходит (это баг в reflect-metadata)

Интерфейс TypedPropertyDescriptor<T>, фигурирующий в сигнатуре декораторов методов и свойств объявлен следующим образом:

Если указать в объявлении декоратора конкретный тип T для TypedPropertyDescriptor, то можно ограничить тип свойств, к которым декоратор применим. Что означают члены этого интерфейса — можно посмотреть здесь. Если коротко, для метода value содержит собственно сам метод, для поля — значение, для свойства — get и set содержат соответствующие методы доступа.

Настройка среды

Поддержка декораторов экспериментальная и может измениться в будущих релизах (в TypeScript 2.0 не изменилась). Поэтому необходимо добавить experimentalDecorators: true в tsconfig.json. Кроме того, декораторы доступны только если target: es5 или выше.

Важно.о target: ES3 и JSFiddle

Важно не забыть указать опцию target — ES5 при работе с декораторами. Если этого не сделать, то код скомпилируется без ошибок, но работать будет по-другому (это баг в компиляторе TypeScript). В частности, декораторам методов и свойств не будет передаваться третий параметр, а их возвращаемое значение будет игнорироваться.

Эти феномены можно наблюдать в JSFiddle (это уже баг в JSFiddle), поэтому в данной статье я не размещаю примеры в JSFiddle.

Тем не менее, есть обходное решение для этих багов. Нужно просто самим получать дескриптор, и самим же его обновлять. Например, вот реализация декоратора @safe, которая работает как с target ES3, так и с ES5.

Для использования информации о типах необходимо также добавить emitDecoratorMetadata: true.

Для использования класса Reflect необходимо установить дополнительный пакет reflect-metadata:

Однако если вы используете Angular 2, то ваша система сборки уже может содержать в себе реализацию Reflect, и после установки пакета reflect-metadata вы можете получить runtime ошибку Unexpected value 'YourComponent' exported by the module 'YourModule' . В этом случае лучше установить только typings.

Итак, перейдем к практике. Рассмотрим несколько примеров, демонстрирующих возможности декораторов.

safeавтоматическая обработка ошибок внутри функции

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

@OnChangeзадание обработчика изменения значения поля

Допустим, при изменении значения поля нужно выполнить какую-то логику. Можно, конечно, определить свойство с get/set методами, и в set поместить нужный код. А можно сократить объем кода, объявив декоратор:

Обратите внимание, мы вызываем defineProperty и возвращаем дескриптор из декоратора. Это связано с багом в reflect-metadata, из-за которого для декоратора полей возвращаемое значение игнорируется.

Injectвнедрение зависимостей

Одной из интересных особенностей декораторов является возможность получать информацию о типе декорируемого свойства или параметра (скажем "спасибо" Angular, т.к. сделано было специально для него). Чтобы это заработало, нужно подключить библиотеку reflect-metadata, и включить опцию emitDecoratorMetadata (см. выше). После этого для свойств, которые имеют хотя бы один декоратор, можно вызвать Reflect.getMetadata с ключем "design:type", и получить конструктор соответствующего типа. Ниже простая реализация декоратора @Inject , который использует этот прием для внедрения зависимостей:

Обратите внимание, мы вызываем defineProperty и возвращаем дескриптор из декоратора. Это связано с багом в reflect-metadata, из-за которого для декоратора полей возвращаемое значение игнорируется.

Как видно, мы просто объявляем поле logService, а декоратор уже самостоятельно определяет его тип, и задает метод доступа, который получает соответствующий экземпляр сервиса. Красиво и удобно. Результат выполнения:

@JsonNameсериализация моделей c преобразованием

Допустим, по каким-то причинам необходимо переименовать некоторые поля объекта при сериализации в JSON. С помощью декоратора мы сможем объявить JSON-имя поля, а после, при сериализации, его прочитать. Технически данный декоратор иллюстрирует работу библиотеки reflect-metadata, а, в частности, функций Reflect.defineMetadata и Reflect.getMetadata.

Приведенный декоратор обладает тем недостатком, что, если модель содержит в качестве полей объекты других классов, то поля этих классов никак не обрабатываются методом serialize (то есть к ним нельзя применить декоратор @JsonName). Кроме того, здесь не реализовано обратное преобразование — из JSON в клиентскую модель. Оба этих недостатка исправлены в несколько более сложной реализации конвертера серверных моделей, в спойлере ниже.

@ServerModelField — конвертер серверных моделей на декораторах

Постановка задачи следующая. С сервера к нам прилетают некоторые JSON-данные примерно такого вида (похожий JSON шлет один BaaS сервис):

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

Разберем, как это реализовано.

Во-первых, нам необходимо определить декоратор поля ServerModelField, который будет принимать строковый параметр и сохранять его в метаданных. Кроме того, для разбора JSON нам еще нужно знать, какие поля с нашим декоратором есть в классе вообще. Для этого объявим еще один экземпляр метаданных, общий для всех полей класса, в котором и сохраним имена всех декорированных членов. Здесь мы уже будем не только сохранять метаданные через Relect.defineMetadata, но и получать через Reflect.getMetadata.

Ну и осталось написать функцию convertFromServer. В ней почти нет ничего особенного, она просто вызывает Reflect.getMetadata и использует полученные метаданные для разбора JSON. Одна особенность — эта функция должна создать экземпляр UserInfo через new, поэтому мы передаем ей помимо JSON-данных еще и класс: convertFromServer(JSON.parse(data), UserInfo) . Чтобы понять, как это работает, посмотрите спойлер ниже.

Вторая особенность — это использование данных о типе поля, генерируемых благодаря настройке "emitDecoratorMetadata": true в tsconfig.json. Прием заключается в вызове Reflect.getMetadata с ключом "design:type", который возвращает конструктор соответствующего типа. Например, вызов Reflect.getMetadata("design:type", target, "mAdditionalInfo") вернет конструктор UserAdditionalInfo . Мы будем использовать эту информацию для того, чтобы правильно обрабатывать поля пользовательских типов. Например, класс UserAdditionalInfo также использует декоратор @ServerModelField, поэтому мы должны также использовать эти метаданные для анализа JSON.

Третья особенность заключается в получении соответствующего target, откуда мы будем брать метаданные. Мы используем декораторы полей, поэтому метаданные нужно брать из прототипа класса. Для декораторов статических членов нужно использовать конструктор класса. Получить прототип можно, вызвав Object.getPrototypeOf или же обратившись к свойству prototype конструктора.

Все остальные комментарии в коде:

Аналогичный вид имеет обратная функция — convertToServer.

Работу декоратора @ServerModelField в действии можно посмотреть в plunker.

Controller, Actionсервисы для взаимодействия с сервером

В ASP.NET сервер, как правило, состоит из контроллеров, которые содержат методы. Соответственно, url методов выглядит обычно, как /ControllerName/ActionName. В клиентском коде хорошей практикой будет сделать единую точку, через которую будут происходить все запросы к серверу вообще, и к каждому контроллеру в частности. Это позволит упросить рефакторинг, облегчит внедрение общей логики обработки ошибок и т.п.

С помощью декораторов можно красиво объявлять классы TypeScript, которые будут соответствовать контроллерам на сервере. Объявление методов при этом мы постараемся максимально упростить, так чтобы они содержали только одну строчку, а url будем формировать на основе информации из декораторов.

Можно также добавить декораторы параметров так, чтобы сигнатура метода в TypeScript полностью повторяла сигнатуру серверного метода. С помощью декораторов можно сохранять имя каждого параметра и при выполнении запроса формировать на основе этих данных соответствующий JSON. К сожалению, получить имя параметра в коде декораторы не позволяют, поэтому придется передавать имя в декоратор вручную (так же, как в декоратор Controller).

Заключение

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

Как было продемонстрировано в статье, в нашем распоряжении следующий ряд приемов:

Модификация дескриптора метода или свойства. В частности, можно подменить метод оберткой, задать дескриптор для поля, с объявлением методов доступа и т.д. В целом, из декоратора можно произвести любую трансформацию прототипа класса.

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

Использование этих приемов может быть самым разнообразным, в зависимости от конкретных нужд. Например, в Легком Клиенте 8 мы активно используем декораторы для объявления сервисов взаимодействия с сервером. Наша реализация чуть сложнее представленной в статье (мы используем декораторы параметров), но в целом построена по тому же принципу. Кроме того, мы думаем еще задействовать несколько декораторов для объявления публичного API наших ReactJS компонентов, а также автоматизировать привязку обработчиков событий к this.

На этом пока все. Пишите впечатления в комментариях, делитесь своим опытом использования декораторов.

UPD. Как заметил whileTrue, в ES7 декораторы не вошли. Будем надеяться, что хотя бы в ES8 попадут.

📎📎📎📎📎📎📎📎📎📎