TypeScript
April 7

Как устроены декораторы в Typescript?

Декораторы - это механизм наблюдения, модификации или замены классов, методов или свойств в декларативном стиле. Декораторы были предложены в стандарте ECMAScript2016 и в настоящее время находятся на второй стадии (проект). В TypeScript их можно включить, установив флаг experimentalDecorators компилятора. Рассмотрим, как компилятор TypeScript преобразует декораторы в нативный JS-код.

Сосредоточимся на трех самых распространенных видах декораторов — декораторах класса, декораторах методов и декораторах свойств. Начнем.

Декоратор класса

Декоратор класса - это функция, которая принимает конструктор класса в качестве единственного параметра. Если декоратор класса возвращает значение, оно заменит объявление класса предоставленной функцией-конструктором, т.е. переопределит конструктор; В противном случае объявление класса будет использовать оригинальный конструктор.

Рассмотрим пример из официальной документации:

@sealed
class Greeter { }

function sealed(constructor: Function) {
  Object.seal(constructor);
  Object.seal(constructor.prototype);
}

Метод Object.seal() запечатывает объект, предотвращая добавление новых свойств к нему и помечая все существующие свойства как неконфигурируемые.

Теперь рассмотрим транспилированный код, сгенерированный TypeScript:

var Greeter = /** @class */ (function() {
  function Greeter(message) {
    this.greeting = message;
  }
  
  Greeter = __decorate([
    sealed
  ], Greeter);
  
  return Greeter;
}());

function sealed(constructor) {
  Object.seal(constructor);
  Object.seal(constructor.prototype);
}

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

Затем мы видим новую функцию с именем __decorate, которую создал TypeScript. Первый параметр, который она принимает, - это массив декораторов. Дополнительные параметры могут варьироваться в зависимости от типа декоратора. В нашем случае функция вызывается с одним дополнительным параметром, который является конструктором класса. Рассмотрим __decorate детальнее:

var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
    var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
    if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
    else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
    return c > 3 && r && Object.defineProperty(target, key, r), r;
};

Классно и понятно, верно? Не волнуйтесь, это не так сложно, как кажется. Разберемся: Я упростил эту функцию немного, чтобы мы могли понять, что происходит.

Сначала она проверяет, поддерживается ли средой Reflect.decorate. Если это так, то использует его; в противном случае переходит к своей собственной реализации - полифиллу, о котором мы сейчас расскажем:

var __decorate = function(decorators, target) {
  var argumentsLength = arguments.length,
    descriptorOrTarget,
    decorator;

  if (argumentsLength < 3) {
    // class decorator
    descriptorOrTarget = target;
  }
  
  for (var i = decorators.length - 1; i >= 0; i--) {
    if ((decorator = decorators[i])) {
      if (argumentsLength < 3) {
        // if the decorator function returns a value use it; otherwise use the original.
        descriptorOrTarget = decorator(descriptorOrTarget) || descriptorOrTarget;
      }
    }
  }
  
  return descriptorOrTarget;
};

Из примера видно, что в этом случае, функция __decorate принимает два параметра: массив декораторов и цель, которая может быть конструктором или прототипом класса.

Затем TypeScript проверяет, передано ли менее трех аргументов; Если это так, то предполагается, что это декоратор класса, и переменной descriptorOrTarget присваивается конструктор класса (в нашем случае Greeter).

Массив декораторов перебирается в обратном порядке. Это потому, что при применении нескольких декораторов в одном объявлении их оценка аналогична композиции функций в математике. Например, в следующем коде:

@decoratorTwo
@decoratorOne
class Greeter {
}

будет эквивалентно decoratorTwo(decoratorOne(Greeter))

Наконец, в случае декоратора класса функция декоратора вызывается с конструктором класса в качестве параметра. Если возвращается значение, оно заменяет объявление класса; в противном случае класс остается неизменным.

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

function classDecorator(constructor) {
  return class extends constructor {
    newProperty = "new property";
    hello = "override";
  }
}

@classDecorator
class Greeter {
  hello: string;
  constructor(m: string) {
    this.hello = m;
  }
}

будет эквивалентен js-коду:

var Greeter = /** @class */ (function() {
  function Greeter(message) {
    this.greeting = message;
  }
  
  Greeter = __decorate([
    sealed
  ], Greeter);
  
  return Greeter;
}());

function sealed(constructor) {
  Object.seal(constructor);
  Object.seal(constructor.prototype);
}

Также стоит упомянуть, что из этого примера мы можем сделать вывод, что функция-декоратор вызывается ровно один раз во время выполнения программы и не зависит от количества экземпляров класса.

Декораторы методов класса

Метод-декоратор - это функция, которая принимает три параметра: цель - это либо функция-конструктор класса для статического члена, либо прототип класса для члена экземпляра; ключ - это имя метода; и дескриптор - это дескриптор свойства для метода.

Если декоратор возвращает значение, оно будет использоваться в качестве нового дескриптора свойства для метода; в противном случае останется исходный дескриптор.

Рассмотрим простой пример из официальной документации:

class Greeter {
  @enumerable
  greet() {
    return '...';
  }
}

function enumerable(target, methodName, descriptor) {
  descriptor.enumerable = false;
}

Мы изменяем свойство enumerable и устанавливаем его в значение false. Вот как выглядит транспилированная версия приведенного выше кода:

var Greeter = /** @class */ (function() {
  function Greeter() {}

  Greeter.prototype.greet = function() {
     return '...';
  };

  __decorate([
      enumerable
  ], Greeter.prototype, "greet", null);

  return Greeter;
}());

function enumerable(target, methodName, descriptor) {
  descriptor.enumerable = false;
}

Функция декоратора метода принимает следующие параметры: прототип класса, имя метода и дескриптор метода, который в нашем примере равен null.

Рассмотрим расширенную реализацию функции __decorate и посмотрим, как она поддерживает декораторы методов:

var __decorate = function(decorators, target, key, desc) {
  var argumentsLength = arguments.length,
    descriptorOrTarget,
    decorator;

  if (argumentsLength < 3) {
    // class decorator
    descriptorOrTarget = target;
  } else {
    if (desc === null) {
      // method decorator
      descriptorOrTarget = Object.getOwnPropertyDescriptor(target, key);
    }
  }

  for (var i = decorators.length - 1; i >= 0; i--) {
    if ((decorator = decorators[i])) {
      if (argumentsLength < 3) {
        // if the decorator function returns a value use it;
        // otherwise use the original.
        descriptorOrTarget = decorator(descriptorOrTarget) 
                             || descriptorOrTarget;
      } else {
        // if the decorator function returns a descriptor use it; 
        // otherwise use the original.
        descriptorOrTarget = decorator(target, key, descriptorOrTarget) 
                            || descriptorOrTarget;
      }
    }
  }

  if (argumentsLength > 3 && descriptorOrTarget) {
    Object.defineProperty(target, key, descriptorOrTarget);
  }

  return descriptorOrTarget;
};

Если мы не имеем дело с декоратором класса (что указывается наличием 3 или более аргументов), мы проверяем, равен ли переданный дескриптор null. Если это так, мы получаем ссылку на дескриптор свойства с помощью метода getOwnPropertyDescriptor, передавая ему прототип класса и имя метода.

Затем мы вызываем функцию декоратора, передавая ей прототип класса, имя метода и дескриптор.

Поскольку у нас есть возможность вернуть либо новый дескриптор, либо изменить существующий, нам нужно проверить, возвращает ли функция декоратора значение; Если да, это значение будет использоваться в качестве нового дескриптора; В противном случае мы используем оригинальный.

Вот пример декоратора метода, который возвращает новый дескриптор:

function methodDecorator(target, propertyKey, descriptor) {
  return {
    ...descriptor,
    value: function() {
      console.log('...override the original function');
    }
  };
}

Наконец, мы вызываем метод Object.defineProperty() и устанавливаем полученный дескриптор в качестве нового дескриптора метода. Этот статический метод определяет новое свойство непосредственно в объекте или модифицирует существующее свойство в объекте, а затем возвращает этот объект.

Декораторы свойств

Декоратор свойства объявляется прямо перед объявлением поля класса. Это функция, которая принимает два параметра: цель — это либо функция-конструктор класса для статического члена, либо прототип класса. Второй параметр — это ключ, который представляет собой имя свойства.

Аналогично декораторам методов, если декоратор свойства возвращает значение, оно будет использоваться в качестве нового дескриптора свойства; в противном случае свойство остается неизменным.

Пример декорирования свойства:

class Greeter {
  @logProperty
  greeting = 'Hello';
}

function logProperty(target, key) {
  let value;
 
  const getter = function() {
    console.log(`Get => ${key}`);
    return value;
  };
 
  const setter = function(newVal) {
    console.log(`Set: ${key} => ${newVal}`);
    value = newVal;
  };
 
  Object.defineProperty(target, key, {
    get: getter,
    set: setter,
    enumerable: true,
    configurable: true
  });
}

Функция logProperty переопределяет декорированное свойство объекта. Мы можем определить новое свойство в прототипе конструктора, используя метод Object.defineProperty().Для перехвата значений в консоль используются геттер и сеттер.

Транслированная версия приведенного выше кода:

var Greeter = /** @class */ (function() {
  function Greeter() {
    this.greeting = 'Hello';
  }

  __decorate([
      logProperty
  ], Greeter.prototype, "greeting", void 0);

  return Greeter;
}());

function logProperty(target, key) { ... }

Вызов __decorate() аналогичен вызову, выполненному для декоратора метода, за исключением последнего параметра, который в этом случае установлен в void 0 (undefined), а не null.

Таким образом, в этом случае, если мы повторно рассмотрим метод __decorate:

var __decorate = function(decorators, target, key, desc) {
  var argumentsLength = arguments.length,
    descriptorOrTarget,
    decorator;

  if (argumentsLength < 3) {
    // class decorator
    descriptorOrTarget = target;
  } else {
    if (desc === null) {
      // method decorator
      descriptorOrTarget = Object.getOwnPropertyDescriptor(target, key);
    }
  }

  for (var i = decorators.length - 1; i >= 0; i--) {
    if ((decorator = decorators[i])) {
      if (argumentsLength < 3) {
        // if the decorator function returns a value use it;
        // otherwise use the original.
        descriptorOrTarget = decorator(descriptorOrTarget) 
                             || descriptorOrTarget;
      } else {
        // if the decorator function returns a descriptor use it; 
        // otherwise use the original.
        descriptorOrTarget = decorator(target, key, descriptorOrTarget) 
                            || descriptorOrTarget;
      }
    }
  }

  if (argumentsLength > 3 && descriptorOrTarget) {
    Object.defineProperty(target, key, descriptorOrTarget);
  }

  return descriptorOrTarget;
};

Теперь descriptorOrTarget не присваивается значение до того, как мы перебираем функции-декораторы, и остаётся неопределенным в нашем примере, поскольку декоратор свойства logProperty не возвращает новый дескриптор свойства.

В результате декоратор свойства logProperty изменяет прототип класса, но дополнительных модификаций не происходит, поскольку условие if в конце не выполняется.

В качестве альтернативы мы можем вернуть новый дескриптор свойства из нашей функции-декоратора:

function logProperty(target, key) {
  let value;
  
  const getter = function() {
    console.log(`Get => ${key}`);
    return value;
  };
  
  const setter = function(newVal) {
    console.log(`Set: ${key} => ${newVal}`);
    value = newVal;
  };
  
  return {
    get: getter,
    set: setter,
    enumerable: true,
    configurable: true
  }
}

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

TypeScript всегда определяет свойство в прототипе класса вместо экземпляра. Это поведение приводит к тому, что каждый экземпляр разделяет одно и то же значение:

Эту проблему можно обойти, создав уникальный ключ и добавив его к экземпляру, используя функции getter и setter. Например:

function logProperty(target, key) {
  const _key = Symbol();

  const getter = function() {
    console.log(`Get => ${key}`);
    return this[_key];
  };
  
  const setter = function(newVal) {
    console.log(`Set: ${key} => ${newVal}`);
    this[_key] = newVal;
  };
  
  return {
    get: getter,
    set: setter,
    enumerable: true,
    configurable: true
  };
}

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

Это вольный перевод материала