Angular: еще один способ отписываться

в 13:28, , рубрики: angular, rxjs

Подписок в коде компонента следует избегать, перекладывая эту задачу на AsyncPipe, однако не всегда это возможно. Есть разные способы завершать подписки, но все они сводятся к двум — ручная отписка или использование takeUntil.

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

Основная идея состоит в том, что любая подписка должна возвращаться из метода. Т.е. все подписки происходят в отдельном декорируемом методе и он имеет следующую сигнатуру.

(...args: any[]) => Subscription;

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

Потребуется реализовать три операции.

  1. При вызове ngOnInit должно быть создано некое хранилище подписок.
  2. При вызове декорируемого метода, возвращающего подиску, эта подписка должна быть сохранена в хранилище.
  3. При вызове ngOnDestroy все подписки из хранилища должны быть завершены (unsubscribe).

Напомню, как делается декоратор метода класса. Официальная документация находится тут

Вот сигнатура декоратора:

<T>(target: Object, propertyKey: string | symbol, descriptor: TypedPropertyDescriptor<T>) => TypedPropertyDescriptor<T> | void; 

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

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

Итак,

export function UntilOnDestroy<ClassType extends DirectiveWithSubscription>(): MethodDecorator {
  return function UntilOnDestroyDecorator(target: ClassType, propertyKey: string): TypedPropertyDescriptor<SubscriptionMethod> {
    wrapHooks(target);
    return {
      value: createMethodWrapper(target, target[propertyKey]),
    };
  } as MethodDecorator;
}

В декораторе можно выделить две части, первая выполнится при декорировании метода, и в этот момент можно изменить конструктор класса, и вторая часть — в возвращаемом дескрипторе исходный метод подменяется каким-то своим кодом, в данном случае функцией, возвращаемой из createMethodWrapper.

Забегая вперед, скажу что в роли хранилища для п.1 и п. 3 будет выступать подписка (Subscription). Подписка позволяет добавлять в нее дочерние подписки с помощью метода add, метод unsubscribe завершает саму подписку и все дочерние.

Документация по Subscription тут

Подписка хранится в свойстве-символе, символ гарантирует уникальность имени свойства и отсутствие конфликтов с кем бы то ни было.

const subSymbol = Symbol('until-on-destroy');

interface ClassWithSubscription {
  [subSymbol]?: Subscription;
}

Начнем с простой части, с подмены метода.

createMethodWrapper

Реализация пункта 2.

function createMethodWrapper(target: ClassWithSubscription, originalMethod: SubscriptionMethod): SubscriptionMethod {
  return function(...args: any[]) {
    const sub: Subscription = originalMethod.apply(this, args);
    target[subSymbol].add(sub);
    return sub;
  };
}

createMethodWrapper возвращает функцию обертку, в которой сначала вызывается оригинальный метод, а затем результат выполнения метода (подписка) добавляется в хранилище. Тут возможны проблемы в случае отсутствия subSymbol, но я намеренно не обрабатываю ошибку, чтобы сразу заметить исключение.

wrapHooks

Реализация пунктов 1 и 3.

function wrapHooks(target: ClassWithSubscription) {
  if (!target.hasOwnProperty(subSymbol)) {
    target[subSymbol] = null;
    wrapOneHook(target, 'OnInit', t => t[subSymbol] = new Subscription());
    wrapOneHook(target, 'OnDestroy', t => t[subSymbol].unsubscribe());
  }
}

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

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

А вот тут начинается самое интересное. Дело в том, что с появлением Angular 9 стало невозможно просто подменить хуки в райнтайме, компилятор извлекает их заранее и помещает в описание компонента. Придется разделить оборачивание хуков для ViewEngine и для Ivy

const cmpKey = 'ɵcmp';

function wrapOneHook(target: any, hookName: string, wrappingFn: (target: ClassWithSubscription) => void): void {
  return target.constructor[cmpKey]
    ? wrapOneHook__Ivy(target, hookName, wrappingFn)
    : wrapOneHook__ViewEngine(target, hookName, wrappingFn);
}

'ɵcmp' это имя свойства, под которым компилятор Ivy хранит в конструкторе класса описание компонента для фабрики. По нему можно отличить имеем мы дело с Ivy или нет.

Для ViewEngine все было просто

function wrapOneHook__ViewEngine(target: any, hookName: string, wrappingFn: (target: ClassWithSubscription) => void): void {
  const veHookName = 'ng' + hookName;
  if (!target[veHookName]) {
    throw new Error(`You have to implements ${veHookName} in component ${target.constructor.name}`);
  }
  const originalHook: () => void = target[veHookName];
  target[veHookName] = function (): void {
    wrappingFn(target);
    originalHook.call(this);
  };
}

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

Ну а затем обычная подмена хука с добавлением вызова коллбэка wrappingFn.

А вот для Ivy нужно подменять свойства в описании компонента, которое добавляет движок Ivy. Это хак конечно, но пока легального способа я не знаю.

Зато не нужно требовать обязательных объявлений ngOnInit и ngOnDestroy.

function wrapOneHook__Ivy(target: any, hookName: string, wrappingFn: (target: ClassWithSubscription) => void): void {
  const ivyHookName = hookName.slice(0, 1).toLowerCase() + hookName.slice(1);
  const componentDef: any = target.constructor[cmpKey];

  const originalHook: () => void = componentDef[ivyHookName];
  componentDef[ivyHookName] = function (): void {
    wrappingFn(target);

    if (originalHook) {
      originalHook.call(this);
    }
  };
}

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

Их имена немного отличаются, поэтому пришлось добавить немного работы со строками, чтобы сделать из OnInit в одном случае ngOnInit, а в другом onInit.

На этом все, пора опробовать декоратор в деле.

Проверка

Создаем проект

ng new check

В нем один дочерний компонент

ng g c child

Содержимое компонентов
app.component.ts

@Component({
  selector: 'app-root',
  template: `
    <button #b (click)="b.toggle = !b.toggle">toggle</button>
    <app-child *ngIf="b.toggle"></app-child>
  `,
})
export class AppComponent {}

child.component.ts

@Component({
  selector: 'app-child',
  template: `<p>child: {{id}}</p>`,
})
export class ChildComponent implements OnInit {
  id: string;

  ngOnInit() {
    this.id = Math.random().toString().slice(-3);
    this.sub1();
    this.sub2();
  }

  @UntilOnDestroy()
  sub1(): Subscription {
    console.log(this.id, 'sub1 subscribe');
    return NEVER.pipe(
      finalize(() => console.log(this.id, 'sub1 unsubscribe'))
    )
      .subscribe();
  }

  sub2(): Subscription {
    console.log(this.id, 'sub2 subscribe');
    return NEVER.pipe(
      finalize(() => console.log(this.id, 'sub2 unsubscribe'))
    )
      .subscribe();
  }
}

При нажатии на toggle компонент app-child инициализируется:

Angular: еще один способ отписываться - 1

… и дестроится:

Angular: еще один способ отписываться - 2

В консоли видно что подписка от декорированного sub1 корректно завершается, а от sub2 отписка не происходит.

Все сработало как ожидалось.

Ссылка на stackblitz
Для Angular 9 на GitHub

Декоратор можно взять в npm как пакет ngx-until-on-destroy
Исходники декоратора на Github

Автор: Швец Антон

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js