Angular: another way to unsubscribe

Subscriptions in component code should be avoided by shifting this task to AsyncPipe, however this is not always possible. There are different ways to end subscriptions, but they all come down to two - manual unsubscribing or using takeUntil.


Over time, I increasingly began to use my decorator for unsubscribing. Let's consider how it is arranged and applied, maybe you will like this method.


The basic idea is that any subscription should be returned from a method. Those. all subscriptions occur in a separate decorated method and it has the following signature.


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

Typescript allows you to hang a decorator on a method that can modify the method and its result.


Three operations will be required.


  1. When calling ngOnInit, some kind of subscription repository should be created.
  2. When calling a decorated method that returns a subscription, this subscription must be stored in the repository.
  3. When calling ngOnDestroy, all subscriptions from the repository must be completed (unsubscribe).

Let me remind you how a class method decorator is made. The official documentation is here


Here is the decorator signature:


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

The decorator receives the class constructor, the name of the property, and a descriptor as input. I will ignore the descriptor, it does not play any role for this task, hardly anyone will modify the method descriptor.


, , - , , .


,


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 , sub2 .


.


Stackblitz Link
For Angular 9 On GitHub


Decorator can be taken in npm as a package ngx-until-on-destroy
Decorator sources on Github


All Articles