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

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


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


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




      Основы


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


    Почему я использую термин 'поле', а не 'свойство' как в официальной документации

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


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


    function MyDecorator(target, propertyKey, descriptor) {
        // ...
    }
    class MyClass {
        @MyDecorator
        myMethod() {
        }
    }

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


    function MyAdvancedDecorator(info?: string) {
       return (target, propertyKey, descriptor) => {
            // ..
       };
    }
    class MyClass {
        @MyAdvancedDecorator("advanced info")
        myMethod() {
        }
    }

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


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


    var __decorateMethod = function (decorators, target, key) {
        var descriptor = Object.getOwnPropertyDescriptor(target, key);
        for (var i = decorators.length - 1; i >= 0; i--) {
            var decorator = decorators[i];
            descriptor = decorator(target, key, descriptor) || descriptor; // Вызов функции декоратора
        }
        Object.defineProperty(target, key, descriptor);
    };
    
    // Объявление класса MyClass
    var MyClass = (function () {
        function MyClass() {} // Конструктор
        MyClass.prototype.myMethod = function () { }; // метод myMethod
    
        // Вызов декораторов
        __decorateMethod([
            MyAdvancedDecorator("advanced info") // Вычисление выражения декоратора, и получение функции 
        ], MyClass.prototype, "myMethod");
        return MyClass;
    }());


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


    Вид декоратора Сигнатура функции
    Декоратор класса
    Пример в playground

    @MyDecorator 
    class MyClass {}
    

    function MyDecorator<TFunction extends Function>(target: TFunction): TFunction {
      return target;
    }
    • target — конструктор класса
    • returns — конструктор класса или null. Если вернуть конструктор, то он заменит оригинальный. При этом необходимо также настроить прототип в новом конструкторе.

    Декоратор метода
    Пример в playground

    class MyClass {
      @MyDecorator
      myMethod(){}
    }
    

    function MyDecorator(target: Object, propertyKey: string | symbol, descriptor: TypedPropertyDescriptor<any>): TypedPropertyDescriptor<any> {
      return descriptor;
    }
    • target — прототип класса
    • propertyKey — имя метода (сохраняется при минификации); в текущей реализации тип — string
    • descriptorдескриптор метода*
    • returns — дескриптор метода* или null

    Декоратор статического метода
    Пример в playground

    class MyClass {
      @MyDecorator
      static myMethod(){}
    }
    

    function MyDecorator(target: Function, propertyKey: string | symbol, descriptor: TypedPropertyDescriptor<any>): TypedPropertyDescriptor<any> {
      return descriptor;
    }
    • target — конструктор класса
    • propertyKey — имя метода (сохраняется при минификации); в текущей реализации тип — string
    • descriptorдескриптор метода*
    • returns — дескриптор метода* или null

    Декоратор методов доступа
    Пример в playground

    class MyClass {
      @MyDecorator
      get myProperty(){}
    }
    

    Аналогично методу. Декоратор следует применять к первому методу доступа (get или set), в порядке объявления в классе.
    Декоратор параметра
    Пример в playground

    class MyClass {
      myMethod(
        @MyDecorator val){
        }
    }
    

    function MyDecorator(target: Object, propertyKey: string | symbol, index: number): void { }
    • target — прототип класса
    • propertyKey — имя метода (сохраняется при минификации); в текущей реализации тип — string
    • index — индекс параметра в списке параметров
    • returns — void


    Декоратор поля (свойства)
    Пример в playground

    class MyClass {
      @MyDecorator
      myField: number;
    }
    

    function MyDecorator(target: Object, propertyKey: string | symbol): TypedPropertyDescriptor<any> {
    return null;
    }
    • target — прототип класса
    • propertyKey — имя поля (сохраняется при минификации); в текущей реализации тип — string
    • returns — null или дескриптор свойства; если вернуть дескриптор, то он будет использован для вызова Object.defineProperty; однако, при подключении библиотеки reflect-metadata этого не происходит (это баг в reflect-metadata)

    Декоратор статического поля (свойства)
    Пример в playground

    class MyClass {
      @MyDecorator
      static myField;
    }
    

    function MyDecorator(target: Function, propertyKey: string | symbol): TypedPropertyDescriptor<any> {
    return null;
    }
    • target — конструктор класса
    • propertyKey — имя поля (сохраняется при минификации); в текущей реализации тип — string
    • returns — null или дескриптор свойства; если вернуть дескриптор, то он будет использован для вызова Object.defineProperty; однако при подключении библиотеки reflect-metadata этого не происходит (это баг в reflect-metadata)

    Интерфейсы Декораторы интерфейсов и их членов не поддерживаются.
    Объявления типов Декораторы в объявлениях типов (ambient declarations) не поддерживаются.
    Функции и переменные вне класса
    Декораторы вне класса не поддерживаются.

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


    interface TypedPropertyDescriptor<T> {
        enumerable?: boolean;
        configurable?: boolean;
        writable?: boolean;
        value?: T;
        get?: () => T;
        set?: (value: T) => void;
    }

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


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


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


    tsconfig.json
    {
        "compilerOptions": {
            "target": "ES5",
            "experimentalDecorators": true
        }
    }


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


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

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

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

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


    tsconfig.json
    {
        "compilerOptions": {
            "target": "ES5",
            "experimentalDecorators": true,
            "emitDecoratorMetadata": true
        }
    }

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


    npm install reflect-metadata --save

    И в коде:


    import "reflect-metadata";

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


    typings install dt~reflect-metadata --global --save

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


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



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


    Реализация декоратора
    function safe(target: Object, propertyKey: string, descriptor: TypedPropertyDescriptor<any>): TypedPropertyDescriptor<any> {
      // Запоминаем исходную функцию
      var originalMethod = descriptor.value;
      // Подменяем ее на нашу обертку
      descriptor.value = function SafeWrapper () {
        try {
          // Вызываем исходный метод
          originalMethod.apply(this, arguments);
        } catch(ex) {
          // Просто выводим в консоль, исполнение кода будет продолжено
          console.error(ex);
        }
      };
      // Обновляем дескриптор
      return descriptor;
    }

    class MyClass {
        @safe public foo(str: string): boolean {
         return str.length > 0; // если str == null, будет ошибка
      }
    }
    var test = new MyClass();
    console.info("Starting...");
    test.foo(null); 
    console.info("Continue execution");

    Результат выполнения:



    Попробовать в действии в Plunker
    Посмотреть в Playground


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



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


    Реализация декоратора
    function OnChange<ClassT, T>(callback: (ClassT, T) => void): any {
        return (target: Object, propertyKey: string | symbol) => {
          // Необходимо задействовать существующий дескриптор, если он есть.
          // Это позволит объявять несколько декораторов на одном свойстве.
          var descriptor = Object.getOwnPropertyDescriptor(target, propertyKey) 
            || {configurable: true, enumerable: true};
          // Подменяем или объявляем get и set
          var value: T;
          var originalGet = descriptor.get || (() => value);
          var originalSet = descriptor.set || (val => value = val);
          descriptor.get = originalGet;
          descriptor.set = function(newVal: T) {
            // Внимание, если определяем set через function, 
            // то this - текущий экземпляр класса,
            // если через лямбду, то this - Window!!!
            var currentVal = originalGet.call(this);
            if (newVal != currentVal) {
              // Вызываем статический метод callback с двумя параметрами
              callback.call(target.constructor, this, newVal);
            }
            originalSet.call(this, newVal);
          };
          // Объявляем новое свойство, либо обновляем дескриптор
          Object.defineProperty(target, propertyKey, descriptor);
          return descriptor;
        }
    }

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


    class MyClass {
        @OnChange(MyClass.onFieldChange)
        public mMyField: number = 42;
    
        static onFieldChange(self: MyClass, newVal: number): void {
          console.info("Changing from " + self.mMyField + " to " + newVal);
        }
    }
    var test = new MyClass();
    test.mMyField = 43;
    test.mMyField = 44;

    Результат выполнения:



    » Попробовать в действии в Plunker
    » Посмотреть в Playground
    Нам пришлось обработчик объявить как static, т.к. трудно сосласться на экземплярный метод. Вот альтернативный вариант со строковым параметром, и другой с использованием лямбды.


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



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


    Реализация декоратора
    // Объявляем декоратор
    function Inject(target: Object, propKey: string): any {
        // Получаем конструктор типа свойства 
        // (в примере ниже это будет конструктор класса ILogService)
        var propType = Reflect.getMetadata("design:type", target, propKey);
        // Переопределяем декорируемое свойство
        var descriptor = {
            get: function () {
              // this - текущий объект класса
              var serviceLocator = this.serviceLocator || globalSericeLocator;
              return serviceLocator.getService(propType);  
    
            }
        };
        Object.defineProperty(target, propKey, descriptor);
        return descriptor;
    }

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


    // Использовать интерфейс, к сожалению, не получится
    abstract class ILogService {
        abstract log(msg: string): void;
    } 
    class Console1LogService extends ILogService {
      log(msg: string) { console.info(msg);  }
    }
    class Console2LogService extends ILogService {
      log(msg: string) { console.warn(msg); }
    }
    var globalSericeLocator = new ServiceLocator();
    globalSericeLocator.registerService(ILogService, new ConsoleLogService1());
    class MyClass {
      @Inject
      private logService: ILogService;
      sayHello() {
        this.logService.log("Hello there");
      }
    }
    var my = new MyClass();
    my.sayHello();
    my.serviceLocator = new ServiceLocator();
    my.serviceLocator.registerService(ILogService, new ConsoleLogService2());
    my.sayHello();

    Реализация класса ServiceLocator
    class ServiceLocator {
      services: [{interfaceType: Function, instance: Object }] = [] as any;
    
      registerService(interfaceType: Function, instance: Object) {
        var record = this.services.find(x => x.interfaceType == interfaceType);
        if (!record) {
          record = { interfaceType: interfaceType, instance: instance};
          this.services.push(record);
        } else {
          record.instance = instance;
        }
      }
      getService(interfaceType: Function) {
        return this.services.find(x => x.interfaceType == interfaceType).instance;
      }
    }

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



    » Попробовать в Plunker
    » Посмотреть в Playground


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




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


    Реализация декоратора
    // Уникальный ключ для наших метаданных
    const JsonNameMetadataKey = "Habrahabr_PFight77_JsonName";
    // Декоратор
    function JsonName(name: string) {
        return (target: Object, propertyKey: string) => {
            // Сохраняем в метаданных переднный name
            Reflect.defineMetadata(JsonNameMetadataKey, name, target, propertyKey);
        }
    }
    // Функция, работающая в паре с декоратором
    function serialize(model: Object): string {
      var result = {};
      var target = Object.getPrototypeOf(model);
      for(var prop in model) {
        // Загружаем сохраненное декоратором значение
        var jsonName = Reflect.getMetadata(JsonNameMetadataKey, target, prop) || prop;    
        result[jsonName] = model[prop];
      }
      return JSON.stringify(result);
    }

    class Model {
      @JsonName("name")
      public title: string;
    }
    
    var model = new Model();
    model.title = "Hello there";
    var json = serialize(model);
    console.info(JSON.stringify(moel));
    console.info(json);

    Результат выполнения:



    » Попробовать в Plunker
    » Посмотреть в Playground


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


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

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


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


    {
        "username":"PFight77",
        "email":"test@gmail.com",
        "doc": {
            "info":"The author of the article"
        }
    }

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


    class UserAdditionalInfo {
        @ServerModelField("info")
        public mRole: string;
    }
    class UserInfo {
        @ServerModelField("username")
        private mUserName: string;
        @ServerModelField("email")
        private mEmail: string;
        @ServerModelField("doc")
        private mAdditionalInfo: UserAdditionalInfo;
    
        public get DisplayName() {
            return mUserName + " " + mAdditionalInfo.mRole;
        }
        public get ID() {
            return mEmail;
        }    
        public static parse(jsonData: string): UserInfo {
            return convertFromServer(JSON.parse(jsonData), UserInfo);
        }
        public serialize(): string {
            var serverData = convertToServer(this);
            return JSON.stringify(serverData);
        }
    }

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


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


    // Объявляем уникальные ключи, по которым будем идентифицировать наши метаданные
    const ServerNameMetadataKey = "Habrahabr_PFight77_ServerName";
    const AvailableFieldsMetadataKey = "Habrahabr_PFight77_AvailableFields";
    // Объявляем декоратор
    export function ServerModelField(name?: string) {
        return (target: Object, propertyKey: string) => {
            // Сохраняем в метаданных переданный name, либо название самого свойства, если параметр не задан
            Reflect.defineMetadata(ServerNameMetadataKey, name || propertyKey, target, propertyKey);
            // Проверяем, не определены ли уже availableFields другим экземпляром декоратора
            var availableFields = Reflect.getMetadata(AvailableFieldsMetadataKey, target);
            if (!availableFields) {
                // Ok, мы первые, значит создаем новый массив
                availableFields = [];
                // Не передаем 4-й параметр(propertyKey) в defineMetadata, 
                // т.к. метаданные общие для всех полей
                Reflect.defineMetadata(AvailableFieldsMetadataKey, availableFields, target);            
            }
            // Регистрируем текущее поле в метаданных
            availableFields.push(propertyKey);
        }
    }

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


    Передача класса параметром
    class MyClass {
    }
    // Объявляем переменную типа "конструктор класса без параметров"
    var myType: { new(): any; }; 
    // Присваиваем переменной наш класс
    myType = MyClass; 
    // Эквивалентно new MyClass()
    var obj = new myType();

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


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


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


    export function convertFromServer<T>(serverObj: Object, type: { new(): T ;} ): T {
        // Создаем объект, с помощью конструктора, переданного в параметре type
        var clientObj: T = new type();
        // Получаем контейнер с метаданными
        var target = Object.getPrototypeOf(clientObj);
        // Получаем из метаданных, какие декорированные свойства есть в классе
        var availableNames = Reflect.getMetadata(AvailableFieldsMetadataKey, target) as [string];
        if (availableNames) {
            // Обрабатываем каждое свойство
            availableNames.forEach(propName => {
                // Получаем из метаданных имя свойства в JSON
                var serverName = Reflect.getMetadata(ServerNameMetadataKey, target, propName);
                if (serverName) {
                    // Получаем значение, переданное сервером
                    var serverVal = serverObj[serverName];
                    if (serverVal) {
                        var clientVal = null;
                        // Проверяем, используются ли в классе свойства декораторы @ServerModelField
                        // Получаем конструктор класса
                        var propType = Reflect.getMetadata("design:type", target, propName);
                        // Смотрим, есть ли в метаданных класса информация о свойствах
                        var propTypeServerFields =  Reflect.getMetadata(AvailableFieldsMetadataKey, propType.prototype) as [string];
                        if (propTypeServerFields) {
                            // Да, класс использует наш декоратор, обрабатываем свойство рекурсивно
                            clientVal = convertFromServer(serverVal, propType);
                        } else {
                            // Нет, просто копируем значение
                            clientVal = serverVal;
                        }
                        // Записываем результат в конечный объект
                        clientObj[propName] = clientVal;
                    }
                }
            });
        } else {
            errorNoPropertiesFound(getTypeName(type));
        }
    
        return clientObj;
    }
    function errorNoPropertiesFound<T>(typeName: string) {
        throw new Error("There is no @ServerModelField directives in type '" + typeName + "'. Nothing to convert.");
    }
    
    function getTypeName<T>(type: { new(): T ;}) {
         return parseTypeName(type.toString());
    }
    
    function parseTypeName(ctorStr: string) {
         var matches = ctorStr.match(/\w+/g);
         if (matches.length > 1) {
             return matches[1];
         } else {
             return "<can not determine type name>";
    
        }
    }

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


    Функция convertToServer
    function convertToServer<T>(clientObj: T): Object {
        var serverObj = {};
    
        var target = Object.getPrototypeOf(clientObj);
        var availableNames = Reflect.getMetadata(AvailableFieldsMetadataKey, target) as [string];
        availableNames.forEach(propName=> {        
            var serverName = Reflect.getMetadata(ServerNameMetadataKey, target, propName);
            if (serverName) {
                var clientVal = clientObj[propName];
                if (clientVal) {
                    var serverVal = null;
                    var propType = Reflect.getMetadata("design:type", target, propName);
                    var propTypeServerFields =  Reflect.getMetadata(AvailableFieldsMetadataKey, propType.prototype) as [string];
                    if (clientVal && propTypeServerFields) {
                        serverVal = convertToServer(clientVal);
                    } else {
                        serverVal = clientVal;
                    }
                    serverObj[serverName] = serverVal;
                }
            }
        });
    
        if (!availableNames) {
            errorNoPropertiesFound(parseTypeName(clientObj.constructor.toString()));
        }
    
        return serverObj;
    }

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


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



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


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


    Реализация декоратора
    var ControllerNameMetadataKey = "Habr_PFight77_ControllerName";
    // Первый декоратор. 
    // К сожалению, нет надежного способа узнать 
    // имя класса (устойчивого к минификации),
    // поэтому имя класса придется передавать вручную.
    function Controller(name: string) {
        return (target: Function) {
        Reflect.defineMetadata(ControllerNameMetadataKey, name, target.prototype);
      };
    }
    // Второй декоратор, применяемый к методам
    function Action(target: Object, propertyKey: string, descriptor: TypedPropertyDescriptor<any>): TypedPropertyDescriptor<any> {
      // Запоминаем исходную функцию
      var originalMethod = descriptor.value;
      // Подменяем ее на нашу обертку
      descriptor.value = function ActionWrapper () {
            // Получаем url, сохраненное декоратором Controller 
          var controllerName = Reflect.getMetadata(ControllerNameMetadataKey, target);
          // Формируем url вида /ControllerName/ActionName
          var url = "/" + controllerName + "/" + propertyKey;
          // Передаем url последним параметром
          [].push.call(arguments, url);
          // Вызываем исходный метод с дополнительным параметром
          originalMethod.apply(this, arguments);
      };
      // Обновляем дескриптор
      return descriptor;
    }
    // Функция, упрощающая объявление методов
    function post(data: any, args: IArguments): any {
      // Получаем url, переданный декоратором @Action
      var url = args[args.length - 1];
      return $.ajax({ url: url, data: data, method: "POST" });
    }

    @Controller("Account")
    class AccountController {
      @Action
      public Login(data: any): any {
        return post(data, arguments);
      }
    }
    var Account = new AccountController();
    Account.Login({ username: "user", password: "111"});

    Результат выполнения:




    » Попробовать в Plunker
    » Посмотреть в Playground

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


    Заключение


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


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


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


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


    3. Получение информации о типе при помощи вызова Reflect.getMetada с ключом "design:type".

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


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


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


    ДоксВижн 35,57
    Компания
    Поделиться публикацией
    Комментарии 33
    • 0
      Интересная и полезная статья, спасибо)
      • +1

        Стоит добавить, что некоторые декораторы написанные для ES7/Babel не будут работать в typescript, из-за особенностей того как он транспилит свойства.
        Например такие декораторы как noneunemerable из core-decorators работать не будут (и почти все декораторы кооторые применяются к полям).

        • 0

          Мне одному начинает казаться, что из JavaScript пытаются обратно сделать Java?

          • +1
            потому что многих джавистов и сишарпистов заставили писать фронтенд, вот они и пытаются подстроить под себя непривычный язык
            • 0
              Да, давят на JS со всех сторон, стараcь превратить его в «нормальный язык».

              Но пока JS не прогибается! ;-)
              • +1
                Судя о том, с каким успехом взлетели классы в различных фреймворках, а ещё и абстракции в TypeScript, то таки надежда не умирает! Авось и прогнется :)
                • 0
                  >Судя о том, с каким успехом взлетели классы в различных фреймворках, а ещё и абстракции в TypeScript, то таки надежда не умирает! Авось и прогнется :)

                  Эх. Авось и нет. Ибо функциональщина на марше! (С) ;-)
              • –2
                Попытка такая уже была — внедрить ООП в JS — но не прошла.

                Может и эта не пройдёт. ;-)

                Вот, снизу постучали: «1. ES7 (aka ES2016) — уже принят как стандарт и в нем нету декораторов».

                Так что — «Враг не пройдет!» (С)

                ;-)
                • +1

                  Я вообще считаю, что последний JS это ES5. Всё что потом, это JS++. Очень уж его усложнили.

                  • 0
                    Нет, всё же С и С++ нельзя сравнивать с ES5 и ES6.

                    Особой сложности не заметил.

                    Новизна — это введение =>

                    И наконец-то визуально поправили написание класса. А то было просто смешно. ;-)
                    • 0

                      На сегодняшний день по данным таблички практически все умеют ES6. Думаю, сегодня можно уже смело использовать 'classes', 'arrow functions', 'promises' без головной боли.

                      • +1

                        Лихо Вы сбросили со счетов IE11 и ниже.

                        • 0

                          Довольно популярные сайты могут себе позволить отправить пользователя ставить нормальный браузер, если он использует IE. Это логично, ведь потеря крайне малой доли пользователей приведёт к меньшему убытку, чем обеспечение поддержки IE.


                          Конечно, многое зависит от рынка, на который продукт ориентируется. Для лендингов и блогов, возможно, поддержа IE имеет смысл, в отличие от чего-то вроде твича.

                    • 0
                      Попытка такая уже была — внедрить ООП в JS — но не прошла.

                      Что это за попытка и почему не прошла?


                      В том js, который я знаю, ООП было с незапамятных времён, пусть и в немного необычном виде. В новом стандарте вообще ввели новый синтаксис для классов, так что теперь оно выглядит очень похоже на обычное ООП на классах, как в Java/C#/python/etc.

                      • –1
                        >Что это за попытка и почему не прошла?

                        ES4 — Попытка встроить полноценное ООП в JS.

                        Почему не прошла? — Об этом написано много, но мало выводов — там просто так заспорили, что разругались все и НЕ договорились. И по быструхе выпустили ES5.

                        Такие вот страсти бушевали. ;-)

                        > В новом стандарте вообще ввели новый синтаксис для классов, так что теперь оно выглядит очень похоже на обычное ООП на классах, как в Java/C#/python/etc.

                        Ничего НЕ ввели в ES6.
                        Просто сейчас класс можно описать в одних(!) { }.
                        Раньше такого нельзя было.
                        Так что это сахар. Изменений нет. Всё осталось как в ES5.

                        Интерфейсы где? — НЕТУ. Это НЕ ООП. Это пародия. ;-)
                        • 0

                          Интерфейсы, как минимум, имеют смысл только при статической типизации. А статическая типизация основной профит имеет, когда язык компилируемый. Мне нравится, как в TypeScript все это сделали, там и интерфейсы, и статическая типизация, и компиляция, и классы можно объявлять в синтаксисе ES6 компилируя в ES5, или даже ES3.

                          • +1
                            Так что это сахар. Изменений нет. Всё осталось как в ES5.

                            Не совсем. Если использовать ES6 класс как функцию(без new), то можно получить Error, в котором говорят: "Аяяй! Не надо так!".

                            • +2
                              ES4 — Попытка встроить полноценное ООП в JS.

                              А какие критерии отличают полноценное от неполноценного? И чем нынешнее ООП не полноценно?


                              Почему не прошла?

                              Я нашёл обзор 4-й версии. Хорошо, что этот трэш не приняли.


                              Ничего НЕ ввели в ES6.
                              Так что это сахар.

                              Я вообще-то так и написал, смотрите: В новом стандарте вообще ввели новый синтаксис для классов, так что теперь оно выглядит очень похоже на обычное ООП на классах.


                              Всё осталось как в ES5.

                              Стандарт (касательно классов) описывает их синтаксис, и ещё некоторые аспекты поведения. Но я не нашёл там привязки к конкретной реализации. Чтобы новый синтаксис работал в старых движках, сейчас нужны транспайлеры, типа babel или ts. Но нативная поддержка синтаксиса в движках вполне может иметь другую реализацию.


                              Интерфейсы где? — НЕТУ. Это НЕ ООП. Это пародия. ;-)

                              Python, ruby, smalltalk… В этих языках тоже нет интерфейсов. Они тоже не ООП? А при желании, можно реализовать интерфейсы в этих языках, но зачем?

                              • –1
                                bromzh > Я нашёл обзор 4-й версии. Хорошо, что этот трэш не приняли.

                                Ну, красивый язык был бы. Похожий на… остальные. ;-)
                                ООП — полное было бы реализовано.
                                Но… поругались там все, кто-то не успел реализовать что-то и… не пошёл ES4, вышел вскорости ES5.

                                Кстати, из
                                http://www.ecmascript.org/es4/spec/overview.pdf

                                Structural function types describe functions and methods. The type

                                function (int, string): boolean

                                describes a Function object that takes two arguments, one int and the other string, and which returns a
                                boolean

                                TypeScript подхватил, похоже эстафету этого ES4!!! ;-)

                                >В новом стандарте вообще ввели новый синтаксис для классов, так что теперь оно выглядит очень похоже на обычное ООП на классах.

                                Возможно. Но мне не похоже — нет интерфейсов. Я из мира Java. ;-)

                                >Стандарт (касательно классов) описывает их синтаксис, и ещё некоторые аспекты поведения. Но я не нашёл там привязки к конкретной реализации. Чтобы новый синтаксис работал в старых движках, сейчас нужны транспайлеры, типа babel или ts. Но нативная поддержка синтаксиса в движках вполне может иметь другую реализацию.

                                Внутренняя структура (его модель) объекта осталась неизменна.

                                >Python, ruby, smalltalk… В этих языках тоже нет интерфейсов. Они тоже не ООП? А при желании, можно реализовать интерфейсы в этих языках, но зачем?

                                Про эти языки ничего не скажу. Видел вот что некоторые и на чистом C (не С++) ООП реализовали то.

                                Но С как бы не считается языком с ООП.

                                (А по слухам, кто-то и на ассемблере реализацию ООП сделал).

                                В Javascrip реализаций ООП разных полно (ходила шутка, ты НЕ есть JS-программист, если ты свою реализацию ООП в JS не написал).

                                >А какие критерии отличают полноценное от неполноценного? И чем нынешнее ООП не полноценно?

                                «Объект в JavaScript — это просто коллекция пар ключ-значение (и иногда немного внутренней магии).

                                Однако, в JavaScript нет концепции класса. К примеру, объект с свойствами {name: Linda, age: 21} не является экземпляром какого-либо класса или класса Object. И Object, и Linda являются экземплярами самих себя. Они определяются непосредственно собственным поведением. Тут нет слоя мета-данных (т.е. классов), которые говорили бы этим объектам как нужно себя вести.

                                Вы можете спросить: «Да как так?», особенно если вы пришли из мира классических объектно-ориентированных языков (таких как Java или C#). «Но если каждый объект обладает собственным поведением (вместо того чтобы наследовать его от общего класса), то если у меня 100 объектов, то им соответствует 100 разных методов? Разве это не опасно? А как мне узнать, что, например, объект действительно является Array-ем?»

                                Чтобы ответить на все эти вопросы необходимо забыть о классическом ОО-подходе и начать всё с нуля.»

                                • 0
                                  Однако, в JavaScript нет концепции класса.

                                  В этом плане JavaScript похож на SmallTalk, который прародитель ООП, и который некоторые называют образцовым ООП-языком. Все объекты, в том числе и классы, и функции. Так что, это скорее современные языки отошли от "классического" ООП в пользу удобства и практичности.

                                  • 0
                                    >Так что, это скорее современные языки отошли от «классического» ООП в пользу удобства и практичности.

                                    Хм, не буду с этим спорить. Ибо что есть «истинное» ООП — это вопрос дискуссионный.

                                    Да и ООП же уже не в тренде — функциональщина на марше. (С) ;-)
                      • +2
                        для справки:
                        1. ES7 (aka ES2016) — уже принят как стандарт и в нем нету декораторов
                        2. Декораторы на стадий 2 (из 4 https://github.com/tc39/proposals) как заявка на стандарт и нет уверенности что они войдут в состав стандарта следующего года ES2017
                        • 0

                          Спасибо, я смотрел все эти proposal, и так и не понял, приняли декораторы или нет. Жаль, конечно. Но все-таки stage 2 это уже немало.

                          • 0
                            >Жаль, конечно. Но все-таки stage 2 это уже немало.

                            Это просто шаг. Выкинуть могут и на стадии stage 4.

                            ;-)
                        • +2
                          Слегка бомбануло после прочтения первой строки 2-го абзаца. ES7 уже вышел и в нем ничего нового уже не появится. Поэтому я просто оставлю это здесь.

                          Все описание по тексту и выводы — TypeScript. Зачем вообще было упоминать про ES7?

                          Так же слегка напрягает описание костылей, которые заставляют декораторы работать, в середине статьи. Лично мне было бы приятнее видеть это в конце статьи.

                          Статья проработана, содержит примеры и тд. После прочтения я реально проникся идеей декораторов, но описанные мелочи слегка подпортили общее впечатление. Было бы приятно, если бы автор прислушался к моему скромному мнению.

                          • 0

                            Мой косяк про ES7, убрал из статьи. Спасибо.

                          • 0
                            Я последние несколько месяцев фуллтаймово пишу на typescript. По сравнению с чистым js он прекрасен. Но проблема есть в том что никакие новые js не позволяют писать более производительный код для современных браузеров чем es3. Т.е. реально я ставлю таргетом es3 и не теряю в скорости. Да пришлось отказаться от геттеров и ещё некоторых конструкций но хуже не стало. Это очень печально. Язык ничего не делает для того чтобы стать более быстрым.
                            • 0

                              Интересно, на сколько же у Вас сложная логика на клиенте, что начинает сказываться производительность JS? Обычно тормозит DOM, или чудовищные алгоритмы (вроде неумеренных вотчей в Angular 1). И как Вы находите те самые 20% кода, которые выполняются 80% времени?

                              • 0
                                Не, я просто делаю игру, там вообще нет DOM, чистый webGL и иногда нормальная математика (поиски пути всякие, рейкасты, моделинг каких-либо процессов)
                                • 0
                                  И как Вы находите те самые 20% кода, которые выполняются 80% времени?

                                  Профилировщиком. Он даже в IE11 есть
                              • 0
                                Вообще хотелось сказать автору большое спасибо, писать такие вещи нужно и полезно.
                                • 0

                                  Пакет reflect-metadata уже идёт с .d.ts, так что отдельно тайпинги ставить не надо.

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

                                    Это и не предлагалось.

                                  Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                  Самое читаемое