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


Однако, несмотря на всю полезность, декораторы в 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 попадут.


Поделиться с друзьями
-->

Комментарии (33)


  1. denismaster
    18.10.2016 16:57

    Интересная и полезная статья, спасибо)


  1. thekip
    18.10.2016 17:08
    +1

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


  1. Holix
    18.10.2016 17:10

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


    1. asci
      18.10.2016 17:15
      +1

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


      1. taujavarob
        18.10.2016 17:45

        Да, давят на JS со всех сторон, стараcь превратить его в «нормальный язык».

        Но пока JS не прогибается! ;-)


      1. jbubsk
        18.10.2016 21:15
        +1

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


        1. taujavarob
          18.10.2016 21:31

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

          Эх. Авось и нет. Ибо функциональщина на марше! (С) ;-)


    1. taujavarob
      18.10.2016 17:18
      -2

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

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

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

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

      ;-)


      1. Holix
        18.10.2016 17:54
        +1

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


        1. taujavarob
          18.10.2016 19:28

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

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

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

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


        1. amalinin
          19.10.2016 08:15

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


          1. PFight77
            19.10.2016 08:16
            +1

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


            1. amalinin
              19.10.2016 08:45

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


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


      1. bromzh
        18.10.2016 20:50

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

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


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


        1. taujavarob
          18.10.2016 21:37
          -1

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

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

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

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

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

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

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


          1. PFight77
            18.10.2016 22:14

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


          1. amalinin
            19.10.2016 08:55
            +1

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

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


          1. bromzh
            20.10.2016 07:21
            +2

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

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


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

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


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

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


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

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


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

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


            1. taujavarob
              20.10.2016 16:44
              -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-ем?»

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


              1. PFight77
                20.10.2016 17:37

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

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


                1. taujavarob
                  20.10.2016 18:05

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

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

                  Да и ООП же уже не в тренде — функциональщина на марше. (С) ;-)


  1. whileTrue
    18.10.2016 17:14
    +2

    для справки:
    1. ES7 (aka ES2016) — уже принят как стандарт и в нем нету декораторов
    2. Декораторы на стадий 2 (из 4 https://github.com/tc39/proposals) как заявка на стандарт и нет уверенности что они войдут в состав стандарта следующего года ES2017


    1. PFight77
      18.10.2016 17:18

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


      1. taujavarob
        18.10.2016 17:27

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

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

        ;-)


  1. itersh
    18.10.2016 17:19
    +2

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

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

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

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


    1. PFight77
      18.10.2016 17:27

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


  1. Aquahawk
    19.10.2016 14:18

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


    1. PFight77
      19.10.2016 15:36

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


      1. Aquahawk
        19.10.2016 16:46

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


      1. Aquahawk
        19.10.2016 16:47

        И как Вы находите те самые 20% кода, которые выполняются 80% времени?

        Профилировщиком. Он даже в IE11 есть


  1. Aquahawk
    19.10.2016 16:55

    Вообще хотелось сказать автору большое спасибо, писать такие вещи нужно и полезно.


  1. Veikedo
    21.10.2016 12:58

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


    1. PFight77
      21.10.2016 13:25

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

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