Йомен помогал командам создавать проекты и модули быстрее и удобнее, чем когда-либо. Добавление новых файлов в проект стало до глупости простым. Файлы обрабатывались как шаблоны, наполнялись данными и копировались. Однако с обновлением файлов была немного другая история.

Когда был выпущен Yeoman, файлы JavaScript, которые нужно было изменить, рассматривались как файлы. Часто люди добавляли маркеры типа /* ADD IMPORT */, которые затем сопоставлялись с регулярными выражениями и заменялись строкой. Если вы когда-либо посещали занятия по теоретической информатике, вы также можете помнить, что анализ программы с помощью RegEx ненадежен. Последняя документация Yeoman советует вам анализировать AST файла и изменять его, а не анализировать его с помощью RegEx.

Разбор файла кода с помощью RegEx - опасный путь, и перед этим вам следует прочитать Антропологические ответы этой CS и понять недостатки парсинга RegEx.

Ответ StackOverflow - классический, и его определенно стоит прочитать. В документации также указано следующее:

Обновление уже существующего файла - не всегда простая задача. Самый надежный способ сделать это - проанализировать файл AST (абстрактное синтаксическое дерево) и отредактировать его. Основная проблема с этим решением заключается в том, что редактирование AST может быть многословным и трудным для понимания.

Соревнование

Хотя в документации четко указано, что нам следует делать для изменения файлов, они не объясняют, как можно анализировать и изменять AST.

Недавно я работал над небольшим генератором для проекта React + Redux. Один из подгенераторов должен добавить action, reducer и импортировать созданный редуктор в корневой редуктор, который выглядит следующим образом:

import { combineReducers } from ‘redux’;
import examplesReducer from ‘./reducers/examples’;
export default combineReducer({
  examples: examplesReducer,
});

В этой статье я сосредоточусь на том, как автоматически import вновь созданный редуктор, но добавление его к аргументу, предоставленному для combineReducer, следует тому же подходу.

Из текста в AST

Для работы с JavaScript AST у вас есть несколько вариантов, три из которых довольно популярны: Babel, TypeScript и Esprima. Их сравнение - отдельная тема, выходящая за рамки данной статьи. Из-за моего знакомства с Babel и некоторыми изящными библиотеками вокруг него, особенно в отношении обхода AST, я решил пойти с ним.

Парсить AST из файла на самом деле довольно просто:

const babylon = require('babylon');
const source = this.fs.read(
  this.destionationPath('src/reducers.js')
);
const ast = babylon.parse(source, {
  sourceType: 'module',
});

(Очень!) Упрощенная версия AST приведенного выше файла-редуктора будет выглядеть так:

[
  {
    "type":"ImportDeclaration",
    "specifiers":[
      {
        "type":"ImportSpecifier",
        "imported":{
          "name":"combineReducers"
        },
        "source":{
          "type":"StringLiteral",
          "value":"redux"
        }
      }
    ]
  },
  {
    "type":"VariableDeclaration",
    "declarations":[
      {
        "type":"VariableDeclarator",
        "id":{
          "type":"Identifier",
          "name":"rootReducer"
        },
        "init":{
          "type":"CallExpression",
          "callee":{
            "type":"Identifier",
            "name":"combineReducers",
            "arguments":[...]
          }
        }
      }
    ],
    "kind":"const"
  }
]

Эти два узла примерно представляют эти две линии:

import { combineReducers } from 'redux';
const rootReducer = combineReducers(/* ... */);

Каждый фрагмент кода представлен в AST. Чтобы изменить наш код, нам нужно пройти по дереву и найти правильный узел, который мы хотим изменить.

Добавление декларации об импорте

Поскольку мы уже импортируем из пакета redux, но не знаем, сколько редукторов уже импортируется, мы хотим добавить новое объявление после последнего.

Пакет babel-traverse помогает нам пройти AST, а babel-types предоставляет помощников для идентификации определенных токенов. Хороший способ путешествовать по деревьям - это шаблон дизайна посетителей, который babel-traverse использует.

const traverse = require('babel-traverse').default;
let lastImport = null;
traverse(ast, {
  ImportDeclaration(path) {
    lastImport = path;
  }
});

Добавление нового объявления импорта после последнего означает, что его следует добавить, когда следующий токен не является другим импортом. К сожалению, шаблон не дает нам возможности заглянуть вперед в дереве и увидеть, каким будет следующий узел. Поэтому нам нужно запомнить последнее объявление импорта.

Если мы позволим этому выполниться и проверим lastImport, вы увидите, что на самом деле это последнее объявление импорта. Если мы посещаем узел, мы можем проверить, был ли определен lastImport и не является ли текущий посещаемый узел еще одним объявлением импорта.

traverse(ast, {
  enter(path) {
    if (lastImport && !isImportDeclaration(path)) {
      // Add the new import declaration
    }
  }
});

В реальном AST объявление импорта - это не просто один узел, поэтому isImportDeclaration немного сложнее.

const t = require('babel-types');
const isImportDeclaration = path => 
  t.isImportDeclaration(path.node) ||
  t.isImportSpecifier(path.parent) ||
  t.isImportDeclaration(path.parent) ||
  t.isImportSpecifier(path.parent) ||
  t.isImportDefaultSpecifier(path.parent);

На данный момент мы знаем, 1) что текущий узел не является еще одним объявлением импорта и 2) последним объявлением импорта. С этой информацией мы можем вставить новое объявление.

const declaration = t.importDeclaration(
  [t.importDefaultSpecifier(t.identifier('tasksReducer'))],
  t.stringLiteral('./reducers/tasks'),
);

t.importDeclaration назначает новый узел declaration, который будет разрешен при генерации кода для import tasksReducer from './reducers/tasks';. К счастью, добавить declaration в AST довольно просто.

traverse(ast, {
  enter(path) {
    if (lastImport && !isImportDeclaration(path)) {
      lastImport.insertAfter(declaration);
    }
  },
  ImportDeclaration(path) {
    lastImport = path;
  }
});

🎉 Поздравляю, тяжелая работа сделана! Теперь вам нужно только снова преобразовать AST в код, что невероятно просто с babel-generator.

const generate = require('babel-generator').default;
const { code } = generate(ast, { /* config */ }, source);
this.fs.write(this.destinationPath('src/reducers.js', code);

Вот и все! Конечно, этот шаблон можно адаптировать и к другим модификациям AST. Например, вы можете автоматически сопоставить ключ состояния с его редуктором, реализовав ObjectProperty посетителя. Но честно говоря, имея доступ к AST, вы можете делать все, что захотите.

Надеюсь, эта статья помогла вам правильно изменить файлы JavaScript с помощью Yeoman. Это может быть немного сложно для декларации импорта, но весело поиграть с AST Babel и предложить вам некоторое представление о языке.

Для записи вот полный код, используемый в генераторе: