Веселимся с Angular и трансформаторами в TypeScript

Angular

Вам знакома проблема обработки подписок на RxJs вручную? Помните, как забыли одну? Или однажды подумали, что использование AsyncPipe в шаблоне будет безопасно, но через некоторое время требования изменились и пришло осознание, что нужен вызов subscribe в классах компонентов? Возможно, это признак плохого дизайна некоторых компонентов, но давайте будем честными: иногда именно это — правильный путь к цели. Но как было бы здорово, если бы больше не приходилось думать о подписках!

Никакой больше ручной обработки массивов подписки.
Никакого takeUntil(this.destroyed$)
Никакого subscription.add()
Нет боли и страха 😉

И мы можем достичь всего этого с помощью Typescript Transformer во время сборки!

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

Что такое трансформаторы TypeScript?

Трансформаторы подключают разработчика к процессу компиляции TS и преобразованию созданного абстрактного синтаксического дерева (AST), а это значит, что с ними возможно изменять код во время компиляции. В следующих разделах этого поста мы будем использовать это так:

  • Найдём все классы с помощью декоратора @Component.
  • Найдём вызовы subscribe() RxJs.
  • Сгенерируем методы, такие как ngOnDestroy.
  • Расширим тело уже написанных методов.

Это очень мощный инструмент, часто используемый при компиляции Angular. Чтобы прочувствовать AST, полезно взглянуть на пример на astexplorer.net. Здесь с левой стороны проводника показан код класса компонента TestComponent, а с правой — представление в виде AST. Измените код слева и AST немедленно обновится.

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

function simpleTransformerFactory(context: ts.TransformationContext) {
  // Visit each node and call the function 'visit' from below
  return (rootNode: ts.SourceFile) => ts.visitNode(rootNode, visit);
  
  function visit(node: ts.Node): ts.Node {
    if (ts.isClassDeclaration(node)) {
      console.log('Found class node! ', node.name.escapedText);
    }
    
    // Visit each Child-Node recursively with the same visit function
    return ts.visitEachChild(node, visit, context);
  }
}
// Typings: typescript.d.ts

/**
 * A function that is used to initialize and return a `Transformer` callback, which in turn
 * will be used to transform one or more nodes.
 */
type TransformerFactory<T extends Node> = (context: TransformationContext) => Transformer<T>;

/**
 * A function that transforms a node.
 */
type Transformer<T extends Node> = (node: T) => T;

/**
 * A function that accepts and possibly transforms a node.
 */
type Visitor = (node: Node) => VisitResult<Node>;
type VisitResult<T extends Node> = T | T[] | undefined;

В нашем примере функция simpleTransformerFactory — это TransformerFactory, возвращающий Transformer.

Типы самого Typescript (второй фрагмент кода) показывают, что трансформатор— это снова функция, принимающая и возвращающая Node.

В приведенном выше фрагменте, где записывается каждое найденное имя класса, мы проходим через абстрактное синтаксическое дерево с шаблоном проектирования Visitor. Посещается каждый узел AST. Узел может быть:

  • CallExpression this.click$.subscribe()
  • BinaryExpression вроде this.subscription = this.click$.subscribe()
  • ClassDeclaration как class Foo {}
  • ImportDelcaration вроде import {Component} from ‘@angular/core’
  • VariableStatement как const a = 1 + 2
  • MethodDeclaration

Генерация кода отписки

Цель в том, чтобы наш специальный трансформатор запускался до того, как будут запущены трансформаторы самого Angular. Он найдёт вызовы subscribe в компонентах и сгенерирует код для автоматической отписки в методе ngOnDestroy.

Чтобы внедрить наш собственный трансформатор в преобразование проекта Angular-CLI, используем библиотеку ngx-build-plus с функцией «плагин». В плагине получаем доступ к AngularCompilerPlugin и добавляем наш трансформатор в массив «приватных».

import { unsubscribeTransformerFactory } from './transformer/unsubscribe.transformer';
import { AngularCompilerPlugin } from '@ngtools/webpack';

function findAngularCompilerPlugin(webpackCfg): AngularCompilerPlugin | null {
  return webpackCfg.plugins.find(plugin =>  plugin instanceof AngularCompilerPlugin);
}

// The AngularCompilerPlugin has nog public API to add transformations, user private API _transformers instead.
function addTransformerToAngularCompilerPlugin(acp, transformer): void {
  acp._transformers = [transformer, ...acp._transformers];
}

export default {
  pre() {},

  // This hook is used to manipulate the webpack configuration
  config(cfg) {
    // Find the AngularCompilerPlugin in the webpack configuration
    const angularCompilerPlugin = findAngularCompilerPlugin(cfg);

    if (!angularCompilerPlugin) {
      console.error('Could not inject the typescript transformer: Webpack AngularCompilerPlugin not found');
      return;
    }

    addTransformerToAngularCompilerPlugin(angularCompilerPlugin, unsubscribeTransformerFactory(angularCompilerPlugin));
    return cfg;
  },

  post() {
  }
};

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

export function unsubscribeTransformerFactory(acp: AngularCompilerPlugin) {
  return (context: ts.TransformationContext) => {

    const checker = acp.typeChecker;

    return (rootNode: ts.SourceFile) => {

      let withinComponent = false;
      let containsSubscribe = false;

      function visit(node: ts.Node): ts.Node {

        // 1. 
        if (ts.isClassDeclaration(node) && isComponent(node)) {
          withinComponent = true;
        
          // 2. Visit the child nodes of the class to find all subscriptions first
          const newNode = ts.visitEachChild(node, visit, context);

          if (containsSubscribe) {
            // 4. Create the subscriptions array
            newNode.members = ts.createNodeArray([...newNode.members, createSubscriptionsArray()]);
  
            // 5. Create the ngOnDestroyMethod if not there 
            if (!hasNgOnDestroyMethod(node)) {
              newNode.members = ts.createNodeArray([...newNode.members, createNgOnDestroyMethod()]);
            }
 
            // 6. Create the unsubscribe loop in the body of the ngOnDestroyMethod
            const ngOnDestroyMethod = getNgOnDestroyMethod(newNode);
            ngOnDestroyMethod.body.statements = ts.createNodeArray([...ngOnDestroyMethod.body.statements, createUnsubscribeStatement()]);
          }

          withinComponent = false;
          containsSubscribe = false;

          return newNode;
        } 

        // 3.
        if (isSubscribeExpression(node, checker) && withinComponent) {
          containsSubscribe = true;
          return wrapSubscribe(node, visit, context);
        }
      
        return ts.visitEachChild(node, visit, context);
      }

      return ts.visitNode(rootNode, visit);
    };
  };
}

Шаг 1.
Убеждаемся, что мы в классе компонента. Затем записываем его в переменную контекста withinComponentдля того, чтобы улучшить вызовы subscribe(), которые выполняются внутри компонента.

Шаг 2.
Сразу вызываем ts.visitEachChildNode(), чтобы найти подписки, сделанные в компоненте.

Шаг 3.
Когда находим выражение subscribe() внутри компонента, то оборачиваем его в this.subscriptions.push(subsribe-expression)

Шаг 4.
Если подписка была в дочерних узлах компонента, то можно добавить массив подписок.

Шаг 5.
Пытаемся найти или создаём метод ngOnDestroy, если его нет.

Шаг 6.
Наконец, расширяем тело метода ngOnDestroy для отписки: this.subscriptions.forEach(s => s.unsubscribe())

Полный код

Ниже приведен код отказа от подписки.

Мой подход — метод проб и ошибок. Я вставлял исходный код в astexplorer.net, а затем пытался создать AST программным способом.

import * as ts from 'typescript';
import {AngularCompilerPlugin} from '@ngtools/webpack';

// Build with:
// Terminal 1: tsc --skipLibCheck --module umd -w
// Terminal 2: ng build --aot --plugin ~dist/out-tsc/plugins.js
// Terminal 3: ng build --plugin ~dist/out-tsc/plugins.js

const rxjsTypes = [
  'Observable',
  'BehaviorSubject',
  'Subject',
  'ReplaySubject',
  'AsyncSubject'
];

/**
 * 
 * ExpressionStatement
 *  -- CallExpression
 *     -- PropertyAccessExpression
 * 
 * 
 * looking into:
 *    - call expressions within a
 *    - expression statement only
 *    - that wraps another call expression where a property is called with subscribe 
 *    - and the type is contained in rxjsTypes
 * 
 */
function isSubscribeExpression(node: ts.Node, checker: ts.TypeChecker): node is ts.CallExpression {
  // ts.isBinaryExpression
  // ts.isCallExpression
  // ts.isClassDeclaration
  // ts.is

  return ts.isCallExpression(node) &&
    node.parent && ts.isExpressionStatement(node.parent) &&
    ts.isPropertyAccessExpression(node.expression) &&
    node.expression.name.text === 'subscribe' &&
    rxjsTypes.includes(getTypeAsString(node, checker));
} 

function getTypeAsString(node: ts.CallExpression, checker: ts.TypeChecker) {
  const type: ts.Type = checker.getTypeAtLocation((node.expression as ts.PropertyAccessExpression | ts.CallExpression).expression);
  console.log('TYPE: ', type.symbol.name);
  return type.symbol.name;
}

/**
 * Takes a subscibe call expression and wraps it with:
 * this.subscriptions.push(node)
 */
function wrapSubscribe(node: ts.CallExpression, visit, context) {
  return ts.createCall(
    ts.createPropertyAccess(
      ts.createPropertyAccess(ts.createThis(), 'subscriptions'),
      'push'
    ),
    undefined,
    [ts.visitEachChild(node, visit, context)]
  );
}

function logComponentFound(node: ts.ClassDeclaration) {
  console.log('Found component: ', node.name.escapedText);
}

function isComponent(node: ts.ClassDeclaration) {
  return node.decorators && node.decorators.filter(d => d.getFullText().trim().startsWith('@Component')).length > 0;
}

/**
 * creates an empty array property:
 * subscriptions = [];
 */
function createSubscriptionsArray() {
  return ts.createProperty(
    undefined, 
    undefined, 
    'subscriptions', 
    undefined, 
    undefined, 
    ts.createArrayLiteral()
  );
}

function isNgOnDestroyMethod(node: ts.ClassElement): node is ts.MethodDeclaration {
  return ts.isMethodDeclaration(node) && (node.name as ts.Identifier).text == 'ngOnDestroy';
}

function hasNgOnDestroyMethod(node: ts.ClassDeclaration) {
  return node.members.filter(node => isNgOnDestroyMethod(node)).length > 0;
}

function getNgOnDestroyMethod(node: ts.ClassDeclaration) {
  const n = node.members
    .filter(node => isNgOnDestroyMethod(node))
    .map(node => node as ts.MethodDeclaration);
   return n[0];
}

function createNgOnDestroyMethod() {
  return ts.createMethod(
    undefined,
    undefined,
    undefined,
    'ngOnDestroy',
    undefined,
    [],
    [],
    undefined,
    ts.createBlock([], true)
  );
}

function createUnsubscribeStatement() {
  return ts.createExpressionStatement(
    ts.createCall(
      ts.createPropertyAccess(
        ts.createPropertyAccess(ts.createThis(), 'subscriptions'),
        'forEach'
      ),
      undefined,
      [
        ts.createArrowFunction(
          undefined,
          undefined,
          [
            ts.createParameter(undefined, undefined, undefined, 'sub', undefined, undefined, undefined)
          ],
          undefined,
          ts.createToken(ts.SyntaxKind.EqualsGreaterThanToken),
          ts.createCall(
            ts.createPropertyAccess(ts.createIdentifier('sub'), 'unsubscribe'),
            undefined,
            []
          )
        )
      ]
    )
  );
}

export function unsubscribeTransformerFactory(acp: AngularCompilerPlugin) {
  return (context: ts.TransformationContext) => {

    const checker = acp.typeChecker;

    return (rootNode: ts.SourceFile) => {

      let withinComponent = false;
      let containsSubscribe = false;

      function visit(node: ts.Node): ts.Node {

        // 1. 
        if (ts.isClassDeclaration(node) && isComponent(node)) {
          withinComponent = true;
        
          // 2. Visit the child nodes of the class to find all subscriptions first
          const newNode = ts.visitEachChild(node, visit, context);

          if (containsSubscribe) {
            // 4. Create the subscriptions array
            newNode.members = ts.createNodeArray([...newNode.members, createSubscriptionsArray()]);
  
            // 5. Create the ngOnDestroyMethod if not there 
            if (!hasNgOnDestroyMethod(node)) {
              newNode.members = ts.createNodeArray([...newNode.members, createNgOnDestroyMethod()]);
            }
 
            // 6. Create the unsubscribe loop in the body of the ngOnDestroyMethod
            const ngOnDestroyMethod = getNgOnDestroyMethod(newNode);
            ngOnDestroyMethod.body.statements = ts.createNodeArray([...ngOnDestroyMethod.body.statements, createUnsubscribeStatement()]);
          }

          withinComponent = false;
          containsSubscribe = false;

          return newNode;
        } 

        // 3.
        if (isSubscribeExpression(node, checker) && withinComponent) {
          containsSubscribe = true;
          return wrapSubscribe(node, visit, context);
        }
      
        return ts.visitEachChild(node, visit, context);
      }

      return ts.visitNode(rootNode, visit);
    };
  };
}

Чтобы сделать описанные шаги, сначала из консоли в корне нашего проекта вводим такую команду:

  • tsc —skipLibCheck —module umd для компиляции файлов transformer.ts и plugins.ts.
  • Запускаем ng build —plugin ~dist/out-tsc/plugins.js, чтобы выполнить сборку Angular с нашим добавленным плагином. Результат этого процесса виден в файле main.js папки dist.
  • По желанию используйте команду ng serve —plugin ~dist/out-tsc/plugins.js.

Вот компонент, в котором подписки намеренно не обрабатываются:

@Component({
  selector: 'app-test',
  templateUrl: './test.component.html',
  styleUrls: ['./test.component.scss']
})
export class TestComponent implements OnDestroy {
  title = 'Hello World';
  showHistory = true;

  be2 = new BehaviorSubject(1);

  constructor(private heroService: HeroService) {
    this.heroService.mySubject.subscribe(v => console.log(v));
    interval(1000).subscribe(val => console.log(val));
  }

  toggle() {
    this.showHistory = !this.showHistory;
  }

  ngOnInit() {
    this.be2.pipe(
      map(v => v)
    ).subscribe(v => console.log(v));
  }

  ngOnDestroy() {
    console.log('fooo');
  }
}

Следующий код генерируется после завершения преобразования и сборки Angular:

var TestComponent = /** @class */ (function () {
    function TestComponent(heroService) {
        this.heroService = heroService;
        this.title = 'Version22: ' + VERSION;
        this.be2 = new rxjs__WEBPACK_IMPORTED_MODULE_1__["BehaviorSubject"](1);
        this.subscriptions = [];
        this.subscriptions.push(this.heroService.mySubject.subscribe(function (v) { return console.log(v); }));
        this.subscriptions.push(Object(rxjs__WEBPACK_IMPORTED_MODULE_1__["interval"])(1000).subscribe(function (val) { return console.log(val); }));
    }
    TestComponent.prototype.ngOnInit = function () {
        this.subscriptions.push(this.be2.pipe(Object(rxjs_operators__WEBPACK_IMPORTED_MODULE_3__["map"])(function (v) { return v; })).subscribe(function (v) { return console.log(v); }));
    };
    TestComponent.prototype.ngOnDestroy = function () {
        console.log('fooo');
        this.subscriptions.forEach(function (sub) { return sub.unsubscribe(); });
    };
    TestComponent = __decorate([
        Object(_angular_core__WEBPACK_IMPORTED_MODULE_0__["Component"])({
            selector: 'app-test',
            template: __webpack_require__(/*! ./test.component.html */ "./src/app/test.component.html"),
            styles: [__webpack_require__(/*! ./test.component.scss */ "./src/app/test.component.scss")]
        }),
        __metadata("design:paramtypes", [_hero_service__WEBPACK_IMPORTED_MODULE_2__["HeroService"]])
    ], TestComponent);
    return TestComponent;
}());

Понимаете, какие подписки обработаны? 🙂

Итоги

Думаю, есть причина, по которой команда Angular сохраняет API преобразователя закрытым. Это явный признак того, что разработчикам лучше не злоупотреблять им. Трансформатор отмены подписки — хорошая идея, но она показывает, что всё простое вмиг становится сложным: нужно рассматривать множество крайних случаев, чтобы найти лучшее решение.

Вот какие идеи приходят в голову: мы могли бы написать собственный преобразователь JAM Stack, выполняющий http-запросы во время сборки, или использовать API компилятора TestBed для генерации TestBed в модульных тестах со всеми необходимыми зависимостями.

На этом все! 

Специально для сайта ITWORLD.UZ. Новость взята с сайта NOP::Nuances of programming