Как стать автором
Обновить

Генерация вспомогательных файлов: реэкспорт, экспортный объект, валидаторы из моделей — можно ли подружить с Webpack?

Время на прочтение 13 мин
Количество просмотров 2.4K

При разработке SPA довольно много времени уходит на работу с импортом и экспортом различных файлов, а также на создание валидационных схем. Эти задачи достаточно просто автоматизируются, но, как это обычно бывает, "есть нюансы" — попробуем разобраться.


За основу проекта возьму код из этой статьи, так как оформляю несколько текстов в виде более-менее связанного цикла.


Про реэкспорт и главные файлы компонентов


ES6-синтаксис импорта файлов позволяет делать это вот так (разумеется, предпочтительнее именованные деструктурируемые импорты):


import { ComponentOne } from 'someFolder/ComponentOne/ComponentOne.tsx';
import { ComponentTwo } from 'someFolder/ComponentTwo/ComponentTwo.tsx';

В этом коде два довольно назойливых недостатка — необходимость прописывать путь вплоть до файла (получается семантический дубляж в данном случае и проникновение во внутреннюю реализацию, а именно в структуру и именование файлов внутри компонента) и необходимость писать 2 отдельных импорта, что приводит к раздуванию файла. Первая решается указанием на главный файл компонента двумя господствующими подходами:


  • создание в каждой папке компонента файла index.ts с содержанием export * from './ComponentOne.tsx';. Недостатки — файл проходит через все стадии компиляции, увеличивая время сборки, включается в бандл, увеличивая размер, и создает дополнительную нагрузку на компилятор (Typescript в данном случае). Также быстрый переход в IDE, как правило, ведет на этот индексный файл, и приходится "проваливаться" дальше, а в списке открытых файлов копятся одинаковые по названию и бесполезные для разработки index.ts. Иногда в этот же файл записывают реэкспорты других файлов, кроме главного — но это приводит лишь к путанице с другим типом файлов, о котором расскажу дальше.
  • создание в каждой папке компонента файла package.json с содержанием { "main": "ComponentOne.tsx", "types": "ComponentOne.tsx" }. Это явно технический файл, который фактически никогда не открывается и не мешает целевой разработке, при этом позволяя сохранить семантичные названия файлов. Недостаток только один — watch-режим Webpack не умеет "на лету" подхватывать этот файл при добавлении в папку, требуется ручной перезапуск сборки.

Какой бы способ вы ни выбрали, импорты сокращаются до следующих:


import { ComponentOne } from 'someFolder/ComponentOne';
import { ComponentTwo } from 'someFolder/ComponentTwo';

Следующий шаг — создание реэкспортного файла. В папке someFolder тоже создается главный файл компонента (который на этот раз является реэкспортным) и указывающий на него файл по схеме выше. Для данного типа файлов я предпочитаю выбирать названия по схеме _someFolder.ts — нижнее подчеркивание одновременно позволяет ему быть всегда наверху списка и семантически отделяет от других файлов (например, нескольких десятков файлов утилит в папке utils) или папок, указывая на его особое назначение и то, что руками трогать не стоит (у javascript-разработчиков ввиду отсутствия private-переменных в классах издавна есть привычка "околоприватные", "технические" функции называть, начиная со знака подчеркивания, так что к аргументам добавляется еще и "привычность"). Содержание в данном случае будет следующим:


export * from './ComponentOne';
export * from './ComponentTwo';

Таким образом, первоначальные импорты можно сократить до


import { ComponentOne, ComponentTwo } from 'someFolder';

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


Генерация реэкспортных файлов


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


webpack-custom/utils/generateFiles.ts
import fs from 'fs';
import path from 'path';

import chalk from 'chalk';
import { ESLint } from 'eslint';

import { env } from '../../env';
import { paths } from '../../paths';

const eslint = new ESLint({
  fix: true,
  extensions: ['.js', '.ts', '.tsx'],
  overrideConfigFile: path.resolve(paths.rootPath, 'eslint.config.js'),
});

const logsPrefix = chalk.blue(`[WEBPACK]`);

const pathsForExportFiles = [
  {
    folderPath: path.resolve(paths.sourcePath, 'const'),
    exportDefault: false,
  },
  {
    folderPath: path.resolve(paths.validatorsPath, 'api'),
    exportDefault: true,
  },
];

type TypeProcessParams = { changedFiles?: string[] };

class GenerateFiles {
  _saveFile(params: { content?: string; filePath: string; noEslint?: boolean }) {
    const { content, filePath, noEslint } = params;

    if (content == null) return false;

    const oldFileContent = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : '';

    return Promise.resolve()
      .then(() => (noEslint ? content : this._formatTextWithEslint(content)))
      .then(formattedNewContent => {
        if (oldFileContent === formattedNewContent) return false;

        return fs.promises.writeFile(filePath, formattedNewContent, 'utf8').then(() => {
          if (env.LOGS_GENERATE_FILES) console.log(`${logsPrefix} Changed: ${filePath}`);

          return true;
        });
      });
  }

  _excludeFileNames(filesNames, skipFiles?: string[]) {
    const skipFilesArray = ['package.json'].concat(skipFiles || []);

    return filesNames.filter(
      fileName => !skipFilesArray.some(testStr => fileName.includes(testStr))
    );
  }

  _formatTextWithEslint(str: string) {
    return eslint.lintText(str).then(data => data[0].output || str);
  }

  generateExportFiles({ changedFiles }: TypeProcessParams) {
    const config =
      changedFiles == null
        ? pathsForExportFiles
        : pathsForExportFiles.filter(({ folderPath }) =>
            changedFiles.some(filePath => filePath.includes(folderPath))
          );

    if (config.length === 0) return false;

    return Promise.all(
      config.map(({ folderPath, exportDefault }) => {
        const { base: folderName } = path.parse(folderPath);

        const generatedFileName = `_${folderName}.ts`;
        const generatedFilePath = path.resolve(folderPath, generatedFileName);

        return Promise.resolve()
          .then(() => fs.promises.readdir(folderPath))
          .then(filesNames => this._excludeFileNames(filesNames, [generatedFileName]))
          .then(filesNames =>
            filesNames.reduce((template, fileName) => {
              const { name: fileNameNoExt } = path.parse(fileName);

              return exportDefault
                ? `${template}export { default as ${fileNameNoExt} } from './${fileNameNoExt}';\n`
                : `${template}export * from './${fileNameNoExt}';\n`;
            }, '// This file is auto-generated\n\n')
          )
          .then(content =>
            this._saveFile({
              content,
              filePath: generatedFilePath,
              noEslint: true,
            })
          );
      })
    ).then(filesSavedMarks => filesSavedMarks.some(Boolean));
  }

  process({ changedFiles }: TypeProcessParams) {
    const startTime = Date.now();
    const isFirstGeneration = changedFiles == null;
    let filesChanged = false;

    // Order matters
    return Promise.resolve()
      .then(() => this.generateExportFiles({ changedFiles }))
      .then(changedMark => changedMark && (filesChanged = true))

      .then(() => {
        if (isFirstGeneration || filesChanged) {
          const endTime = Date.now();

          console.log(
            '%s Finished generating files within %s seconds',
            logsPrefix,
            chalk.blue(String((endTime - startTime) / 1000))
          );
        }

        return filesChanged;
      });
  }
}

export const generateFiles = new GenerateFiles();

Схема работы следующая: запускается generateFiles.process({ changedFiles: null }), в который можно передать либо null (в этом случае будут перегенерированы все файлы), либо массив путей изменившихся файлов — и тогда будут созданы реэкспортные файлы только в папках с изменившимися файлами. Из списка файлов внутри реэкспортного будет исключен он сам + package.json, в базовом случае этого достаточно. Я также сделал поддержку не только "общего" реэкспорта в виде export * from './MyComponent', но и только дефолтного экспорта в виде именованного — export { default as MyComponent } from './MyComponent', это пригодится дальше.


Следующим шагом файл необходимо отформатировать в соответствии с текущими настройками ESLint и Prettier — для этого у них есть node.js интерфейс, однако его использование довольно затратно по времени — на моей машине занимает в районе 0.1 секунды, а это довольно существенно по моим меркам, поэтому реимплементировал форматирование вручную, расставив символы переноса строк — таким образом, время генерации десятка файлов сократилось до тысячных долей секунды. Также хотел бы обратить внимание, что если контент файла не изменился, то я не пересоздаю файл, так как это повлекло бы за собой срабатывание вотчеров (например, пересборку Webpack).


Генерация экспортных объектов


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


// icons.tsx
export const icons = {
  arrowLeft: require('./icons/arrow-left.svg'),
  arrowRight: require('./icons/arrow-right.svg'),
};

// MyComponent.tsx
import { icons } from 'assets/icons';
import { icons } from 'assets'; // Либо сокращенно с реэкспортным файлом

<img src={icons.arrowLeft} />

Подобная схема удобнее, чем import { arrowLeft } from 'assets/icons';, так как в данном случае названия лучше не деструктурировать, чтобы не путать с другими сущностями на странице. Таким образом, в файл генератора добавится метод, похожий на предыдущий:


webpack-custom/utils/generateFiles.ts
const pathsForAssetsExportFiles = [
  {
    folderPath: path.resolve(paths.assetsPath, 'icons'),
    exportDefault: false,
  },
  {
    folderPath: path.resolve(paths.assetsPath, 'images'),
    exportDefault: true,
  },
];

class GenerateFiles {
  generateAssetsExportFiles({ changedFiles }: TypeProcessParams) {
    const config =
      changedFiles == null
        ? pathsForAssetsExportFiles
        : pathsForAssetsExportFiles.filter(({ folderPath }) =>
            changedFiles.some(filePath => filePath.includes(folderPath))
          );

    if (config.length === 0) return false;

    return Promise.all(
      config.map(({ folderPath, exportDefault }) => {
        const { base: folderName, dir: parentPath } = path.parse(folderPath);

        const generatedFileName = `${folderName}.ts`;
        const generatedFilePath = path.resolve(parentPath, generatedFileName);

        return Promise.resolve()
          .then(() => fs.promises.readdir(folderPath))
          .then(filesNames => {
            const exportObject = this._createExportObjectFromFilesArray({
              folderName,
              filesNames,
              exportDefault,
            });

            return `// This file is auto-generated\n\nexport const ${folderName} = ${this._objToString(
              exportObject
            )}`;
          })
          .then(content =>
            this._saveFile({
              content,
              filePath: generatedFilePath,
              noEslint: true,
            })
          );
      })
    ).then(filesSavedMarks => filesSavedMarks.some(Boolean));
  } 
}

В данном случае отличие в том, что используется require-синтаксис и отсутствует префикс _ у сгенерированного файла, так как этот файл создает новую сущность в виде объекта с преобразованными ключами, соответственно не является техническим, и в него вполне можно "проваливаться" для изучения набора возможных значений. А то, что он является автоматически генерируемым просто подчеркивается комментарием в самом верху файла.


Думаю, тем, кто работал с Webpack пришла в голову мысль — почему для этих задач не использовать require.context, чтобы "на лету" собирать подобные файлы без отдельной генерации. Ответ прост — TS, IDE и разработчик не могут узнать, что же собрал этот самый require.context, соответственно, не будет подсказок, автодополнений, быстрых переходов, проверки типов и т.п., так что этот вариант не рекомендую рассматривать.


Генерация валидаторов из Typescript-моделей


Как я люблю говорить — задача тривиальная, прочитать файл ts-компилятором и заменить типы на функции какой-либо библиотеки для проверки значений. В итоге хотелось бы 1 раз написать модель, и получить файл с валидационной функцией в отдельной папке. С этой задачей справится вот эта небольшая утилита, однако она принимает параметром только 1 файл за раз, создавая каждый раз новый инстанс компилятора — соответственно на ровном месте для десятка файлов будет работать несколько секунд. Компилятор при этом умеет работать с массивами файлов, так что для эффективной работы нужно форкнуть эту утилиту и заменить метод компиляции на следующий:


  public static compile(
    filePaths: string[],
    options: ICompilerOptions = {
      ignoreGenerics: false,
      ignoreIndexSignature: false,
      inlineImports: false,
    }
  ) {
    const createProgramOptions = { target: ts.ScriptTarget.Latest, module: ts.ModuleKind.CommonJS };
    const program = ts.createProgram(filePaths, createProgramOptions);
    const checker = program.getTypeChecker();

    return filePaths.map(filePath => {
      const topNode = program.getSourceFile(filePath);
      if (!topNode) {
        throw new Error(`Can't process ${filePath}: ${collectDiagnostics(program)}`);
      }

      const content = new Compiler(checker, options, topNode).compileNode(topNode);

      return { filePath, content };
    });
  }

Следующим шагом нужно создать файл примера, например для запроса к апи (типы разделил для наглядности):


src/api/auth.ts
type ApiRoute = {
  url: string;
  name: string;
  method: 'GET' | 'POST';
  headers?: any;
};

type RequestParams = {
  email: string;
  password: string;
};

type ResponseParams = {
  email: string;
  sessionExpires: number;
};

type AuthApiRoute = ApiRoute & { params?: RequestParams; response?: ResponseParams };

export const auth: AuthApiRoute = {
  url: `/auth`,
  name: `auth`,
  method: 'POST',
};

И натравить на него всеядный и потому толстеющий генератор файлов:


webpack-custom/utils/generateFiles.ts
import { Compiler } from '../../lib/ts-interface-builder';

const pathsForValidationFiles = [
  {
    folderPath: path.resolve(paths.sourcePath, 'api'),
  },
];
const modelsPath = path.resolve(paths.sourcePath, 'models');

class GenerateFiles {
  generateValidationFiles({ changedFiles }: TypeProcessParams) {
    const config =
      changedFiles == null
        ? pathsForValidationFiles
        : pathsForValidationFiles.filter(({ folderPath }) =>
            changedFiles.some(
              filePath => filePath.includes(folderPath) || filePath.includes(modelsPath)
            )
          );

    if (config.length === 0) return false;

    return Promise.all(
      config.map(({ folderPath }) => {
        const { base: folderName } = path.parse(folderPath);

        const generatedFileName = `_${folderName}.ts`;
        const generatedFolderPath = path.resolve(paths.validatorsPath, folderName);

        if (!fs.existsSync(generatedFolderPath)) fs.mkdirSync(generatedFolderPath);

        return Promise.resolve()
          .then(() => fs.promises.readdir(folderPath))
          .then(filesNames => this._excludeFileNames(filesNames, [generatedFileName]))
          .then(filesNames => filesNames.map(fileName => path.resolve(folderPath, fileName)))
          .then(filesPaths =>
            Promise.all(
              Compiler.compile(filesPaths, { inlineImports: true }).map(({ filePath, content }) => {
                const { base: fileName } = path.parse(filePath);

                const generatedFilePath = path.resolve(generatedFolderPath, fileName);

                return this._saveFile({ filePath: generatedFilePath, content });
              })
            )
          );
      })
    ).then(filesSavedMarks => _.flatten(filesSavedMarks).some(Boolean));
  }
}

Обратить внимание тут можно на то, что вручную такие файлы не отформатировать, поэтому приходится прогонять через eslint с соответствующими временными затратами. В параметрах утилиты стоит { inlineImports: true } для того, чтобы импорты типов включались непосредственно в итоговый файл (иначе они не будут проверяться), а также включена проверка filePath.includes(modelsPath), чтобы при изменении моделей триггерился этот процессинг. Вытаскивать дерево зависимостей — нетривиальная задача, поэтому поддерживать этот функционал в предлагаемой мной версии предполагается вручную.


Таким образом, при запуске данного метода будет сгенерирован файл:


src/validators/api/auth.ts
import * as t from 'ts-interface-checker';
// tslint:disable:object-literal-key-quotes

export const ApiRoute = t.iface([], {
  url: 'string',
  name: 'string',
  method: t.union(t.lit('GET'), t.lit('POST')),
  headers: t.opt('any'),
});

export const RequestParams = t.iface([], {
  email: 'string',
  password: 'string',
});

export const ResponseParams = t.iface([], {
  email: 'string',
  sessionExpires: 'number',
});

export const AuthApiRoute = t.intersection(
  'ApiRoute',
  t.iface([], {
    params: t.opt('RequestParams'),
    response: t.opt('ResponseParams'),
  })
);

const exportedTypeSuite: t.ITypeSuite = {
  ApiRoute,
  RequestParams,
  ResponseParams,
  AuthApiRoute,
};

export default exportedTypeSuite;

… который можно запускать, например, следующим образом:


import { createCheckers } from 'ts-interface-checker';

import * as apiValidatorsTypes from 'validators/api';

const apiValidators = _.mapValues(apiValidatorsTypes, value => createCheckers(value));

function validateRequestParams({ route, params }) {
  return Promise.resolve()
    .then(() => {
      const requestValidator = _.get(apiValidators, `${[route.name]}.TypeRequestParams`);

      return requestValidator.strictCheck(params);
    })
    .catch(error => {
      throw createError(
        errorsNames.VALIDATION,
        `request: (request params) ${error.message} for route "${route.name}"`
      );
    });
}

Таким образом, при отправке запроса при несовпадении типов будет создана человекопонятная ошибка. К сожалению, библиотека ts-interface-checker не работает с дженериками, но для моих нужд текущего функционала в виде проверки объектов с глубокой вложенностью подходит отлично.


Интеграция с процессом сборки


Перед билдом запустить все созданные рецепты генерации просто, достаточно выполнить дополнительный скрипт (в моем случае он уже есть — webpackBuider.ts), в котором запускается этап generateFiles.process({}) (с пустым changedFiles — значит, будет проведена масштабная операция по созданию файлов и перезаписыванию на новые, если они изменились). А вот в случае пересборки начинается самое интересное, так как придется взаимодействовать с Webpack — "вещью в себе" — по двум возможным сценариям:


  • Интеграция в compiler.hooks.watchRun:

webpack-custom/plugins/pluginChangedFiles.ts
import webpack from 'webpack';

import { generateFiles } from '../utils/generateFiles';

class ChangedFiles {
  apply(compiler: webpack.Compiler) {
    compiler.hooks.watchRun.tapAsync('GenerateFiles_WatchRun', (comp, done) => {
      const watcher = comp.watchFileSystem.watcher || comp.watchFileSystem.wfs.watcher;
      const changedFiles = Object.keys(watcher.mtimes);

      return changedFiles.length
        ? generateFiles.process({ changedFiles }).then(() => done())
        : done();
    });
  }
}

export const pluginChangedFiles: webpack.Plugin = new ChangedFiles();

Таким образом, перед стартом пересборки будет выполняться асинхронное действие, а по его завершении — колбэк продолжения процесса сборки. При параллельной изоморфной сборке такое решение, примененное в одной из сборок, разумеется не прокатит, так как должно выполняться перед обеими, учитывая измененные файлы обеих. Для этого мне пришлось форкнуть parallel-webpack и настраивать общение между тремя процессами через node-ipc, блокируя соседние сборки до завершения общей генерации. Подробно на этом останавливаться не буду, можно посмотреть в итоговом репозитории, если интересно, но у данного решения в целом есть масса недостатков:


  • сгенерированные файлы не попадают в текущий цикл сборки, несмотря на асинхронность хука. Таким образом, если первая сборка сгенерировала файл _const.ts, то после ее завершения будет запущена вторая, так как Webpack watcher посчитает это новым изменением.
  • при удалении какого-либо файла, ссылка на который была в реэкспортном файле, до хука watchRun процесс не доходит, а заранее падает с ошибкой — приходится перезапускать.
  • при добавлении нового файла в папку он, разумеется, не подтягивается, так как не находится в области видимости Webpack.

Первые две проблемы мне удалось решить в данном случае следующим плагином:


new webpack.ProgressPlugin(percentage => {
    if (percentage === 0) generateFilesSync.process({ changedFiles });
})

Однако он работает только синхронно, что невозможно соблюсти ввиду асинхронного характера некоторых рецептов. В общем, после тщательного исследования исходников Webpack, мне так и не удалось подключиться к его наблюдательной системе, блокируя пересборку и добавляя в нее измененные файлы.


  • Отдельный процесс с наблюдением за измененными файлами:

webpack-custom/webpackBuilder.ts
function startFileWatcher() {
  let changedFiles = [];
  let isGenerating = false;
  let watchDebounceTimeout = null;

  watch(paths.sourcePath, { recursive: true }, function fileChanged(event, filePath) {
    if (filePath) changedFiles.push(filePath);

    if (isGenerating) return false;

    clearTimeout(watchDebounceTimeout);
    watchDebounceTimeout = setTimeout(() => {
      isGenerating = true;

      generateFiles.process({ changedFiles }).then(() => {
        isGenerating = false;

        if (changedFiles.length > 0) fileChanged(null, null);
      });

      changedFiles = [];
    }, 10);
  });
}

function afterFirstBuild() {
  startFileWatcher();
}

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


К сожалению, идеального варианта найти не удалось, но так как стремлюсь максимально оптимизировать пересборки, в данном случае пренебрегаю редкими двойными.


Я использую данный генератор не только для упрощения тех рутинных задач, которые привел выше, но и как часть крупного архитектурного решения (микрофронтенды, только не смейтесь), надеюсь дойду через несколько статей и до этой темы.


Репозиторий


Комфортного кодинга!

Теги:
Хабы:
+4
Комментарии 4
Комментарии Комментарии 4

Публикации

Истории

Работа

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн