Как мутировать код в Angular-схематиках и не поседеть
Чтобы использовать Angular CLI на полную, разработчики должны знать, что такое схематики. Например, команды ng add, ng update и ng generate используют схематики для добавления, обновления и настройки библиотек и кодогенерации в приложениях. Во время выполнения схематика вы получаете доступ к файловой системе и можете мутировать исходный код приложения так, как вам нужно. «Но, чтобы мутировать код, нужно работать с AST, а это сложно», — возможно, скажете вы, и будете правы!
В этой статье расскажу, как мы пытаемся упростить работу с AST и сделать написание схематиков обыденным. А еще покажу, что так же просто можно работать с AST не только в Angular-проектах, а практически в любом проекте на JavaScript/TypeScript.
Что такое схематик
Технически схематик — это функция, которая принимает два аргумента на вход: специфичные для схематика опции и контекст, который используется для логирования и несет в себе утилитарные функции.
Функция возвращает тип Rule
. Давайте посмотрим на него внимательнее:
type Rule = (tree: Tree, context: SchematicContext) => Tree | Observable | Rule | Promise | void;
Из определения типа понятно, что Rule
может быть как синхронным, так и асинхронным. И, как бонус, мы можем вернуть Observable
. Из типа Rule
остался только один неизвестный интерфейс — Tree
. Tree
— это виртуальная абстракция для работы с файловой системой, изменения в которой накладываются на реальную файловую систему.
Каждая команда ng
, которая использует схематики, имеет собственные настройки, но в итоге все сводится именно к вызову вышеописанной функции.
Зачем нужен схематик
Мы широко используем схематики по целому ряду причин.
Выполнение миграций при обновлении библиотек. В основном используется при мажорных изменениях и помогает разработчикам более просто мигрировать. Сам Angular всегда использует миграции для переезда между версиями. Мы даже контрибьютили в RenovateBot, чтобы у пользователей была возможность запускать миграции при автоматическом обновлении зависимостей.
Настройка библиотек при их добавлении в проект. Позволяет сразу подготовить приложение к использованию: добавить импорт в нужный модуль, заинжектить дефолтные конфиги или внести изменения в процесс сборки.
Кодогенерация. Быстрое создание скелетов библиотек, приложений, компонентов, директив, etc. Например, схематики позволяют в одну команду создать лейзи роут вашего приложения со всеми нужными базовыми конфигами. Мы широко используем эту возможность не только для добавления и генерации кода новых фичей, но и для миграции кодовой базы на другой функционал. Каждый пункт можно развернуть в достаточно большой список кейсов, но оставим это на откуп вашей фантазии и комментариям.
По итогу можно сказать, что написание схематиков неплохо экономит время пользователей. Но…
Есть подвох
В какой-то момент мы осознали, что на создание схематика потратили больше ресурсов, чем планировали. Хотя задача заключалась в добавлении одного импорта модуля в главный модуль приложения при миграции.
Проблема была в том, что мы решили работать с AST для мутаций. Но это не так просто разработчику, который большую часть времени работает с сущностями Angular и версткой.
Например, команда Angular использует typescript API в миграциях. Но как часто вы сталкиваетесь с программным использованием пакета typescript? Как часто вы оперируете нодами из TS-компилятора, чтобы добавить пару новых проперти в объект или элемента в массив?
Ниже — пример функции, которая добавляет данные в метаданные модуля (оригинал). Осторожно: код приведен для примера, не советую напрягаться и пытаться понять, что в нем происходит!
export function addSymbolToNgModuleMetadata(
source: ts.SourceFile,
ngModulePath: string,
metadataField: string,
symbolName: string,
importPath: string | null = null,
): Change[] {
const nodes = getDecoratorMetadata(source, 'NgModule', '@angular/core');
let node: any = nodes[0]; // tslint:disable-line:no-any
// Find the decorator declaration.
if (!node) {
return [];
}
// Get all the children property assignment of object literals.
const matchingProperties = getMetadataField(
node as ts.ObjectLiteralExpression,
metadataField,
);
// Get the last node of the array literal.
if (!matchingProperties) {
return [];
}
if (matchingProperties.length == 0) {
// We haven't found the field in the metadata declaration. Insert a new field.
const expr = node as ts.ObjectLiteralExpression;
let position: number;
let toInsert: string;
if (expr.properties.length == 0) {
position = expr.getEnd() - 1;
toInsert = ` ${metadataField}: [${symbolName}]\\\\n`;
} else {
node = expr.properties[expr.properties.length - 1];
position = node.getEnd();
// Get the indentation of the last element, if any.
const text = node.getFullText(source);
const matches = text.match(/^\\\\r?\\\\n\\\\s*/);
if (matches && matches.length > 0) {
toInsert = `,${matches[0]}${metadataField}: [${symbolName}]`;
} else {
toInsert = `, ${metadataField}: [${symbolName}]`;
}
}
if (importPath !== null) {
return [
new InsertChange(ngModulePath, position, toInsert),
insertImport(source, ngModulePath, symbolName.replace(/\\\\..*$/, ''), importPath),
];
} else {
return [new InsertChange(ngModulePath, position, toInsert)];
}
}
const assignment = matchingProperties[0] as ts.PropertyAssignment;
// If it's not an array, nothing we can do really.
if (assignment.initializer.kind !== ts.SyntaxKind.ArrayLiteralExpression) {
return [];
}
const arrLiteral = assignment.initializer as ts.ArrayLiteralExpression;
if (arrLiteral.elements.length == 0) {
// Forward the property.
node = arrLiteral;
} else {
node = arrLiteral.elements;
}
if (!node) {
// tslint:disable-next-line: no-console
console.error('No app module found. Please add your new class to your component.');
return [];
}
if (Array.isArray(node)) {
const nodeArray = node as {} as Array;
const symbolsArray = nodeArray.map(node => node.getText());
if (symbolsArray.includes(symbolName)) {
return [];
}
node = node[node.length - 1];
}
let toInsert: string;
let position = node.getEnd();
if (node.kind == ts.SyntaxKind.ObjectLiteralExpression) {
// We haven't found the field in the metadata declaration. Insert a new
// field.
const expr = node as ts.ObjectLiteralExpression;
if (expr.properties.length == 0) {
position = expr.getEnd() - 1;
toInsert = ` ${symbolName}\\\\n`;
} else {
// Get the indentation of the last element, if any.
const text = node.getFullText(source);
if (text.match(/^\\\\r?\\\\r?\\\\n/)) {
toInsert = `,${text.match(/^\\\\r?\\\\n\\\\s*/)[0]}${symbolName}`;
} else {
toInsert = `, ${symbolName}`;
}
}
} else if (node.kind == ts.SyntaxKind.ArrayLiteralExpression) {
// We found the field but it's empty. Insert it just before the `]`.
position--;
toInsert = `${symbolName}`;
} else {
// Get the indentation of the last element, if any.
const text = node.getFullText(source);
if (text.match(/^\\\\r?\\\\n/)) {
toInsert = `,${text.match(/^\\\\r?\\\\n(\\\\r?)\\\\s*/)[0]}${symbolName}`;
} else {
toInsert = `, ${symbolName}`;
}
}
if (importPath !== null) {
return [
new InsertChange(ngModulePath, position, toInsert),
insertImport(source, ngModulePath, symbolName.replace(/\\\\..*$/, ''), importPath),
];
}
return [new InsertChange(ngModulePath, position, toInsert)];
}
Выглядит совершенно не вдохновляюще. Поэтому мы решили создать верхнеуровневую библиотеку, которая позволяет писать схематики намного проще.
ng-morph
В основе библиотеки лежит ts-morph. По сути, ts-morph — это обертка над компилятором typescript, которая упрощает работу с AST.
Представляю вашему вниманию ng-morph
. Это набор утилит, который позволит вам писать схематики намного проще и быстрее. Чтобы не быть голословным, предлагаю сразу рассмотреть несколько примеров с его использованием.
Задача № 1. Добавить импорт модуля SomeModule
в корневой модуль приложения.
Решение.
const rule: Rule = (tree: Tree, context: SchematicContext): void => {
setActiveProject(createProject(tree));
const appModule = getMainModule('src/main.ts');
addImportToNgModule(appModule, 'SomeModule');
addImports(appModule.getFilePath(), {moduleSpecifier: '@some/package', namedExports: ['SomeModule']})
saveActiveProject();
}
Рассмотрим решение построчно:
Создаем проект
ng-morph
и делаем его активным. Это важно, так как все утилитарные функции работают именно в контексте активного проекта. Под проектом нужно понимать класс, который дает API к файловой системе, компилятору и т. д.По точке входа приложения находим главный модуль приложения.
Добавляем в импорты найденного модуля новый.
Добавляем импорт модуля в файл, где расположен корневой модуль.
Сохраняем проект.
Теперь сравните это решение с функцией выше из исходников Angular. Если вы будете использовать ng-morph
, скорее всего, вам не придется писать что-то подобное.
Задача № 2. В проекте неожиданно меняется стиль написания имени для enum на uppercase.
Решение. Логичные вопросы: при чем здесь ng-morph
? Ведь мы говорим про схематики, неужели нужно настраивать и писать схематики только для того, чтобы переименовать энамы?
Все верно. Схематики кажутся слишком сложными для переименования энамов. Но все же давайте посмотрим, что нам может предложить ng-morph
:
setActiveProject(createProject(new NgMorphTree('/')));
const enums = getEnums('/**/*.ts');
editEnums(enums, ({name}) => ({name: name.toUpperCase()}))
Создаем проект. Тут есть важное отличие: скрипт не завернут в функцию схематика и аргумент tree создается вручную с помощью класса NgMorphHost.
Ищем все enum в проекте.
Переименовываем все enum.
На этом примере мы видим, что ng-morph
умеет работать и вне функций схематиков! Да, мы используем ng-morph
не только в схематиках и не только в Angular-проектах.
Что еще умеет ng-moprh?
Создавать
createImports('/src/some.ts', [
{
namedImports: ['CoreModule'],
moduleSpecifier: '@org/core',
isTypeOnly: true,
}
]);
Искать
const imports = getImports('src/**/*.ts', {
moduleSpecifier: '@org/*',
});
Изменять
editImports(imports, ({moduleSpecifier}) => ({
moduleSpecifier: moduleSpecifier.replace('@org', '@new-org')
})
Удалять
removeImports(imports)
Почти по каждой сущности в TS есть свой набор функций: get*
, edit*
, add*
, remove*
. Например, getClass
, removeConstrucor
, addDecorator
. Начали появляться утилитарные функции для работы со специфичными для Angular кейсами:
getBootstrapFn
— функция, возвращающаяCallExpression
.getMainModule
— функция, которая возвращает декларацию главного модуля.Куча утилитарных функций по изменению метаданных сущностей Angular:
addDeclarationToNgModule
,addProviderToDirective
и т. д.
ng-morph
также содержит утилиты для работы с json
. Например, для работы с зависимостями в package.json
:
addPackageJsonDependency(tree, {
name: '@package/name',
version: '~2.0.0',
type: NodeDependencyType.Dev
});
Но если нужна более низкоуровневая работы, всегда можно поработать с ts-morph API, а из него провалиться еще ниже — в API самого typescript.
Вместо заключения
На данный момент roadmap не существует. Мы достаточно быстро реализовали то, чего нам не хватало, и решили показать это сообществу. И, естественно, хочется развивать инструмент дальше.
Тем не менее список фич первой необходимости все же есть:
Высокоуровневая работа с шаблонами.
Высокоуровневая работа со стилями.
Наращивание тулинга по работе с сущностями Angular.
И мы будем рады, если сообщество Angular поможет нам это сделать!
Ссылки, которые вы так ждали
Репозиторий с кодом:
GitHub — TinkoffCreditSystems/ng-morph: Code mutations in schematics were never easier than now.
github.comСайт с документацией и примерами:
Уже используют ng-morph
Из известных мне — наша дружественная и лучшая библиотека компонентов для Angular:
GitHub — TinkoffCreditSystems/taiga-ui: Angular UI Kit and components library for awesome people
github.com