Вам знакома проблема обработки подписок на 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