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

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

Какие проблемы решают декораторы?

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

Сквозная проблема — это функция, распределенная по кодовой базе. Обычно такие опасения не зависят от области проекта. Он включает в себя следующее:

  • Ведение журнала
  • Кэширование
  • Проверка
  • Форматирование
  • и т. д.

Существует парадигма, которая занимается сквозными проблемами, аспектно-ориентированное программирование (АОП). Возможно, вы захотите прочитать этот замечательный текст, чтобы узнать больше о том, как это работает в JavaScript. Есть также несколько замечательных библиотек, реализующих АОП в JavaScript:

Если вас интересует АОП, вы можете установить эти пакеты и поэкспериментировать с их функциональностью.

В этой статье я попытался показать, как можно решить описанные выше проблемы, используя встроенный функционал TypeScript — декораторы.

Я предполагаю, что у вас есть опыт работы с react, mobx и typescript, поэтому я не буду углубляться в особенности этих технологий.

Декораторы в целом

В TypeScript декоратор — это функция.

Это выглядит так: @funcName. Здесь funcName — это имя функции, описывающей декоратор. Как только декоратор назначается члену класса и последний вызывается, сначала будут выполняться декораторы, а затем код класса. Однако декоратор может прервать выполнение кода на своем уровне, чтобы код основного класса не выполнялся. Если у члена класса есть несколько декораторов, они выполняются сверху вниз, один за другим.

Декораторы все еще являются экспериментальной функцией TypeScript. Чтобы использовать их, вам нужно добавить следующий параметр в файл tsconfig.json:

{
  "compilerOptions": {
    "experimentalDecorators": true,
  },
}

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

Сигнатура этой функции для методов класса следующая:

funcName<TCls, TMethod>(target: TCls, key: string, descriptor: TypedPropertyDescriptor<TMethod>): TypedPropertyDescriptor<TMethod> | void

Где:

  • target — объект, для которого будет использоваться декоратор.
  • ключ — это метод класса, оформленный
  • descriptor – это дескриптор метода класса.

Используя дескриптор, мы можем получить доступ к исходному методу объекта.

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

type TestDescriptor = TypedPropertyDescriptor<(id: string, ...args: any[]) => any>;

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

function format(pattern: string) {
  // this is a decorator factory and it returns a decorator function
  return function (target) {
    // it's a decorator. Here will be the code
    // that does something with target and pattern
  };
}

Подготовка

В двух следующих примерах мы будем использовать 2 модели данных:

export type Product = {
  id: number;
  title: string;
};

export type User = {
  id: number;
  firstName: string;
  lastName: string;
  maidenName: string;
}

Во всех функциях декоратора мы будем использовать тип дескриптора PropertyDescriptor, который является эквивалентом TypedPropertyDescriptor‹any›.

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

export type CreateDecoratorAction<T> = (self: T, originalMethod: Function, ...args: any[]) => Promise<void> | void;

export function createDecorator<T = any>(action: CreateDecoratorAction<T>) {
  return (target: T, key: string, descriptor: PropertyDescriptor) => {
    const originalMethod = descriptor.value; // reference to the original class method
    // override class method
    descriptor.value = async function (...args: any[]) {
      const _this = this as T;
      await action(_this, originalMethod, ...args);
    };
  };
}

Проект построен на React + TypeScript. Для отображения состояния приложения на экране мы используем эту замечательную библиотеку Mobx. В приведенных ниже примерах я пропущу код, связанный с Mobx, чтобы привлечь ваше внимание к проблеме и ее решению.

Вы можете найти полный код в этом репозитории.

Отображение индикатора загрузки данных

Во-первых, давайте создадим класс AppStore, который будет содержать все состояние нашего небольшого приложения. Приложение будет состоять из двух списков: пользователи и продукты. Эти данные мы возьмем из dummyjson.

При отображении страницы отправляются два запроса сервера для загрузки списков. Вот так выглядит AppStore:

class AppStore {
  users: User[] = [];
  products: Product[] = [];
  usersLoading = false;
  productsLoading = false;

  async loadUsers() {
    if (this.usersLoading) {
      return;
    }
    try {
      this.setUsersLoading(true);
      const resp = await fetch("https://dummyjson.com/users");
      const data = await resp.json();
      const users = data.users as User[];
      this.users = users;
    } finally {
      this.setUsersLoading(false);
    }
  }
  async loadProducts() {
    if (this.productsLoading) {
      return;
    }
    try {
      this.setProductsLoading(true);
      const resp = await fetch("https://dummyjson.com/products");
      const data = await resp.json();
      const products = data.users as Product[];
      this.products = products;
    } finally {
      this.setProductsLoading(false);
    }
  }
  
  private setUsersLoading(value: boolean) {
    this.usersLoading = value;
  }
 
  private setProductsLoading(value: boolean) {
    this.usersLoading = value;
  }
}

Изменяя значение флагов usersLoading и productsLoading, мы можем управлять отображением индикаторов загрузки списка. Как вы можете видеть в приведенном выше коде, эта функциональность повторяется в методах. Теперь попробуем с помощью декораторов убрать это дублирование. Мы инкапсулируем все флаги загрузки в один объект, который будет храниться в свойстве загрузки нашего хранилища состояний. Для этого давайте определим интерфейс и базовый класс (чтобы повторно использовать код для управления состоянием загрузки флага):

type KeyBooleanValue = {
  [key: string]: boolean;
};

export interface ILoadable<T> {
  loading: T;
  setLoading(key: keyof T, value: boolean): void;
}

export abstract class Loadable<T> implements ILoadable<T> {
  loading: T;
  constructor() {
    this.loading = {} as T;
  }
  
  setLoading(key: keyof T, value: boolean) {
    (this.loading as KeyBooleanValue)[key as string] = value;
  }
}

Если вы не можете использовать наследование, вы можете использовать ILoadable и реализовать метод setLoading.

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

export const loadable = <T>(keyLoading: keyof T) =>
  createDecorator<ILoadable<T>>(async (self, method, ...args) => {
    try {
      if (self.loading[keyLoading]) return;
      self.setLoading(keyLoading, true);
      return await method.call(self, ...args);
    } finally {
      self.setLoading(keyLoading, false);
    }
  });

Фабричная функция является обобщенной и принимает на вход ключи свойств объекта, которые будут храниться в свойстве загрузки интерфейса ILoadable. Чтобы убедиться, что этот декоратор используется правильно, наш класс должен реализовать интерфейс ILoadable. Мы воспользуемся наследованием от класса Loadable, который уже реализует этот интерфейс, и перепишем наш код следующим образом:

const defaultLoading = {
  users: false,
  products: false,
};

class AppStore extends Loadable<typeof defaultLoading> {
  users: User[] = [];
  products: Product[] = [];

  constructor() {
    super();
    this.loading = defaultLoading;
  }

  @loadable("users")
  async loadUsers() {
    const resp = await fetch("https://dummyjson.com/users");
    const data = await resp.json();
    const users = data.users as User[];
    this.users = users;
  }
  @loadable("products")
  async loadProducts() {
    const resp = await fetch("https://dummyjson.com/products");
    const data = await resp.json();
    const products = data.users as Product[];
    this.products = products;
  }
}

В качестве типа объекта в свойстве загрузки мы передаем динамически вычисляемый тип typeof defaultLoading из состояния объекта по умолчанию defaultLoading. Мы также присваиваем это состояние свойству загрузки. Таким образом, строковые ключи, которые мы передаем загружаемому декоратору, управляются машинописным вводом. Как видите, методы loadUsers и loadProducts читаются лучше, а функциональность отображения счетчика инкапсулирована в отдельном модуле. Загружаемая фабрика декораторов и интерфейс ILoadable абстрагированы от конкретной реализации хранилища и могут использоваться в неограниченном количестве хранилищ в приложении.

Обработка ошибок в методе

Если dummyjson становится недоступным по какой-либо причине, наше приложение вылетает с ошибкой без ведома пользователя. Давайте это исправим.

class AppStore extends Loadable<typeof defaultLoading> {
  users: User[] = [];
  products: Product[] = [];

  constructor() {
    super();
    this.loading = defaultLoading;
  }

  @loadable("users")
  async loadUsers() {
    try {
      const resp = await fetch("https://dummyjson.com/users");
      const data = await resp.json();
      const users = data.users as User[];
      this.users = users;
    } catch (error) {
      notification.error({
        message: "Error",
        description: (error as Error).message,
        placement: "bottomRight",
      });
    }
  }
  @loadable("products")
  async loadProducts() {
    try {
      const resp = await fetch("https://dummyjson.com/products");
      const data = await resp.json();
      const products = data.users as Product[];
      this.products = products;
    } catch (error) {
      notification.error({
        message: "Error",
        description: (error as Error).message,
        placement: "bottomRight",
      });
    }
  }
}

В каждом методе здесь появляется блок try…catch…. Ошибки обрабатываются в catch. В правом нижнем углу всплывает уведомление об ошибке. Мы воспользуемся мощью декораторов и инкапсулируем эту обработку в отдельный модуль, сделав ее абстрактной:

export const errorHandle = (title?: string, desc?: string) =>
  createDecorator(async (self, method, ...args) => {
    try {
      return await method.call(self, ...args);
    } catch (error) {
      notification.error({
        message: title || "Error",
        description: desc || (error as Error).message,
        placement: "bottomRight",
      });
    }
  });

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

class AppStore extends Loadable<typeof defaultLoading> {
  users: User[] = [];
  products: Product[] = [];
  
  constructor() {
    super();
    this.loading = defaultLoading;
  }

  @loadable("users")
  @errorHandle()
  async loadUsers() {
    const resp = await fetch("https://dummyjson.com/users");
    const data = await resp.json();
    const users = data.users as User[];
    this.users = users;
  }
  @loadable("products")
  @errorHandle()
  async loadProducts() {
    const resp = await fetch("https://dummyjson.com/products");
    const data = await resp.json();
    const products = data.users as Product[];
    this.products = products;
  }
}

Вот как мы легко добавили функциональность обработки ошибок, убрали дублирование кода, сделав код метода читабельным и простым.

Уведомления об успешном выполнении метода

Предположим, нам нужно сообщить об успешной загрузке списков пользователей и продуктов. Если бы не декораторы, код выглядел бы так:

class AppStore extends Loadable<typeof defaultLoading> {
  users: User[] = [];
  products: Product[] = [];
  
  constructor() {
    super();
    this.loading = defaultLoading;
  }

  @loadable("users")
  @errorHandle()
  async loadUsers() {
    const resp = await fetch("https://dummyjson.com/users");
    const data = await resp.json();
    const users = data.users as User[];
    this.users = users;
    notification.success({
      message: "Users uploaded successfully",
      placement: "bottomRight",
    });
  }
  @loadable("products")
  @errorHandle()
  async loadProducts() {
    const resp = await fetch("https://dummyjson.com/products");
    const data = await resp.json();
    const products = data.users as Product[];
    this.products = products;
    notification.success({
      message: "Products uploaded successfully",
      placement: "bottomRight",
    });
  }
}

Давайте инкапсулируем эту функциональность в отдельный модуль и сделаем ее абстрактной:

export const successfullyNotify = (message: string, description?: string) =>
  createDecorator(async (self, method, ...args) => {
    const result = await method.call(self, ...args);
    notification.success({
      message,
      description,
      placement: "bottomRight",
    });
    return result;
  });

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

class AppStore extends Loadable<typeof defaultLoading> {
  users: User[] = [];
  products: Product[] = [];

  constructor() {
    super();
    this.loading = defaultLoading;
  }

  @loadable("users")
  @errorHandle()
  @successfullyNotify("Users uploaded successfully")
  async loadUsers() {
    const resp = await fetch("https://dummyjson.com/users");
    const data = await resp.json();
    const users = data.users as User[];
    this.users = users;
  }
  @loadable("products")
  @errorHandle()
  @successfullyNotify("Products uploaded successfully")
  async loadProducts() {
    const resp = await fetch("https://dummyjson.com/products");
    const data = await resp.json();
    const products = data.users as Product[];
    this.products = products;
  }
}

Регистрация метода

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

class AppStore extends Loadable<typeof defaultLoading> {
  users: User[] = [];
  products: Product[] = [];

  constructor() {
    super();
    this.loading = defaultLoading;
  }

  @loadable("users")
  @errorHandle()
  @successfullyNotify("Users uploaded successfully")
  async loadUsers() {
    try {
      console.log(`Before calling the method loadUsers`);
      const resp = await fetch("https://dummyjson.com/users");
      const data = await resp.json();
      const users = data.users as User[];
      this.users = users;
      console.log(`The method loadUsers worked successfully.`);
    } catch (error) {
      console.log(`An exception occurred in the method loadUsers. Exception message: `, (error as Error).message);
      throw error;
    } finally {
      console.log(`The method loadUsers completed`);
    }
  }
  @loadable("products")
  @errorHandle()
  @successfullyNotify("Products uploaded successfully")
  async loadProducts() {
    try {
      console.log(`Before calling the method loadProducts`);
      const resp = await fetch("https://dummyjson.com/products");
      const data = await resp.json();
      const products = data.users as Product[];
      this.products = products;
      console.log(`The method loadProducts worked successfully.`);
    } catch (error) {
      console.log(`An exception occurred in the method loadProducts. Exception message: `, (error as Error).message);
      throw error;
    } finally {
      console.log(`The method loadProducts completed`);
    }
  }
}

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

export type LogPoint = "before" | "after" | "error" | "success";

let defaultLogPoint: LogPoint[] = ["before", "after", "error", "success"];
export function setDefaultLogPoint(logPoints: LogPoint[]) {
  defaultLogPoint = logPoints;
}

export const log = (points = defaultLogPoint) =>
  createDecorator(async (self, method, ...args) => {
    try {
      if (points.includes("before")) {
        console.log(`Before calling the method ${method.name} with args: `, args);
      }
      const result = await method.call(self, ...args);
      if (points.includes("success")) {
        console.log(`The method ${method.name} worked successfully. Return value: ${result}`);
      }
      return result;
    } catch (error) {
      if (points.includes("error")) {
        console.log(
          `An exception occurred in the method ${method.name}. Exception message: `,
          (error as Error).message
        );
      }
      throw error;
    } finally {
      if (points.includes("after")) {
        console.log(`The method ${method.name} completed`);
      }
    }
  });

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

class AppStore extends Loadable<typeof defaultLoading> {
  users: User[] = [];
  products: Product[] = [];
  
  constructor() {
    super();
    this.loading = defaultLoading;
  }

  @loadable("users")
  @errorHandle()
  @successfullyNotify("Users uploaded successfully")
  @log()
  async loadUsers() {
    const resp = await fetch("https://dummyjson.com/users");
    const data = await resp.json();
    const users = data.users as User[];
    this.users = users;
  }
  @loadable("products")
  @errorHandle()
  @successfullyNotify("Products uploaded successfully")
  @log()
  async loadProducts() {
    const resp = await fetch("https://dummyjson.com/products");
    const data = await resp.json();
    const products = data.users as Product[];
    this.products = products;
  }
}

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

Выводы

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

Исходник вы можете найти в этом репозитории