Yeoman помогна на екипите да изграждат проекти и модули по-бързо и по-удобно от всякога. Добавянето на нови файлове към проект стана глупаво просто. Файловете се третираха като шаблони, захранваха се с данни и се копираха наоколо. Актуализирането на файлове обаче беше малко по-различна история.

Когато 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 и ви предлага някои прозрения за езика.

За протокола, ето пълния код, използван в генератор: