Как мутировать код в Angular-схематиках и не поседеть

—

от автора

Чтобы использовать Angular CLI на полную, разработчики должны знать, что такое схематики. Например, команды ng add, ng update и ng generate используют схематики для добавления, обновления и настройки библиотек и кодогенерации в приложениях. Во время выполнения схематика вы получаете доступ к файловой системе и можете мутировать исходный код приложения так, как вам нужно. «Но, чтобы мутировать код, нужно работать с AST, а это сложно», — возможно, скажете вы, и будете правы!

В этой статье расскажу, как мы пытаемся упростить работу с AST и сделать написание схематиков обыденным. А еще покажу, что так же просто можно работать с AST не только в Angular-проектах, а практически в любом проекте на JavaScript/TypeScript.


Что такое схематик

Технически схематик — это функция, которая принимает два аргумента на вход: специфичные для схематика опции и контекст, который используется для логирования и несет в себе утилитарные функции.

Функция возвращает тип Rule. Давайте посмотрим на него внимательнее:

type Rule = (tree: Tree, context: SchematicContext) => Tree | Observable<Tree> | Rule | Promise<void | Rule> | 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<ts.Node>;     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(); } 

Рассмотрим решение построчно:

  1. Создаем проект ng-morph и делаем его активным. Это важно, так как все утилитарные функции работают именно в контексте активного проекта. Под проектом нужно понимать класс, который дает API к файловой системе, компилятору и т. д.

  2. По точке входа приложения находим главный модуль приложения.

  3. Добавляем в импорты найденного модуля новый.

  4. Добавляем импорт модуля в файл, где расположен корневой модуль.

  5. Сохраняем проект.

Теперь сравните это решение с функцией выше из исходников Angular. Если вы будете использовать ng-morph, скорее всего, вам не придется писать что-то подобное.

Задача № 2. В проекте неожиданно меняется стиль написания имени для enum на uppercase.

Решение. Логичные вопросы: при чем здесь ng-morph? Ведь мы говорим про схематики, неужели нужно настраивать и писать схематики только для того, чтобы переименовать энамы?

Все верно. Схематики кажутся слишком сложными для переименования энамов. Но все же давайте посмотрим, что нам может предложить ng-morph:

setActiveProject(createProject(new NgMorphTree('/')));  const enums = getEnums('/**/*.ts');  editEnums(enums, ({name}) => ({name: name.toUpperCase()}))
  1. Создаем проект. Тут есть важное отличие: скрипт не завернут в функцию схематика и аргумент tree создается вручную с помощью класса NgMorphHost.

  2. Ищем все enum в проекте.

  3. Переименовываем все 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 кейсами:

  1. getBootstrapFn — функция, возвращающая CallExpression.

  2. getMainModule — функция, которая возвращает декларацию главного модуля.

  3. Куча утилитарных функций по изменению метаданных сущностей 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 не существует. Мы достаточно быстро реализовали то, чего нам не хватало, и решили показать это сообществу. И, естественно, хочется развивать инструмент дальше.

Тем не менее список фич первой необходимости все же есть:

  1. Высокоуровневая работа с шаблонами.

  2. Высокоуровневая работа со стилями.

  3. Наращивание тулинга по работе с сущностями Angular.

И мы будем рады, если сообщество Angular поможет нам это сделать!

Ссылки, которые вы так ждали

Репозиторий с кодом:

Сайт с документацией и примерами:

Уже используют ng-morph

Из известных мне — наша дружественная и лучшая библиотека компонентов для Angular:


ссылка на оригинал статьи https://habr.com/ru/articles/582120/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *