Вам знакома проблема обработки подписок на 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
в модульных тестах со всеми необходимыми зависимостями.
На этом все!
Читайте также:
- Использование Angular Elements с расширением Chrome
- Оптимизация размера Angular bundle за 4 шага
- Повторные попытки HTTP-запросов в Angular
Перевод статьи Christian Janker: Having fun with Angular and Typescript Transformers