Подписок в коде компонента следует избегать, перекладывая эту задачу на AsyncPipe, однако не всегда это возможно. Есть разные способы завершать подписки, но все они сводятся к двум — ручная отписка или использование takeUntil.
Со временем я все чаще стал использовать свой декоратор для отписок. Рассмотрим как он устроен и применяется, быть может вам понравится этот способ.
Основная идея состоит в том, что любая подписка должна возвращаться из метода. Т.е. все подписки происходят в отдельном декорируемом методе и он имеет следующую сигнатуру.
(...args: any[]) => Subscription;
Typescript позволяет на метод повесить декоратор, который может модифицировать метод и его результат.
Потребуется реализовать три операции.
- При вызове ngOnInit должно быть создано некое хранилище подписок.
- При вызове декорируемого метода, возвращающего подиску, эта подписка должна быть сохранена в хранилище.
- При вызове 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 инициализируется
… и дестроится
В консоли видно что подписка от декорированного sub1 корректно завершается, а от sub1 отписка не происходит.
Все сработало как ожидалось.
Ссылка на stackblitz
Для Angular 9 на GitHub
ссылка на оригинал статьи https://habr.com/ru/post/492424/
Добавить комментарий