Генерация вспомогательных файлов: реэкспорт, экспортный объект, валидаторы из моделей — можно ли под
- суббота, 20 июня 2020 г. в 00:32:24
При разработке SPA довольно много времени уходит на работу с импортом и экспортом различных файлов, а также на создание валидационных схем. Эти задачи достаточно просто автоматизируются, но, как это обычно бывает, "есть нюансы" — попробуем разобраться.
За основу проекта возьму код из этой статьи, так как оформляю несколько текстов в виде более-менее связанного цикла.
ES6-синтаксис импорта файлов позволяет делать это вот так (разумеется, предпочтительнее именованные деструктурируемые импорты):
import { ComponentOne } from 'someFolder/ComponentOne/ComponentOne.tsx';
import { ComponentTwo } from 'someFolder/ComponentTwo/ComponentTwo.tsx';
В этом коде два довольно назойливых недостатка — необходимость прописывать путь вплоть до файла (получается семантический дубляж в данном случае и проникновение во внутреннюю реализацию, а именно в структуру и именование файлов внутри компонента) и необходимость писать 2 отдельных импорта, что приводит к раздуванию файла. Первая решается указанием на главный файл компонента двумя господствующими подходами:
export * from './ComponentOne.tsx';
. Недостатки — файл проходит через все стадии компиляции, увеличивая время сборки, включается в бандл, увеличивая размер, и создает дополнительную нагрузку на компилятор (Typescript в данном случае). Также быстрый переход в IDE, как правило, ведет на этот индексный файл, и приходится "проваливаться" дальше, а в списке открытых файлов копятся одинаковые по названию и бесполезные для разработки index.ts. Иногда в этот же файл записывают реэкспорты других файлов, кроме главного — но это приводит лишь к путанице с другим типом файлов, о котором расскажу дальше.{ "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, то возникнет циклическая зависимость, что приведет к неявным багам, поэтому внутри папки взаимные импорты должны быть либо относительными, либо по несокращенной схеме.
Так как редактировать и синхронизировать с содержанием папки подобные файлы быстро надоедает, можно создать утилиты для генерации. Выглядеть это будет следующим образом:
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';
, так как в данном случае названия лучше не деструктурировать, чтобы не путать с другими сущностями на странице. Таким образом, в файл генератора добавится метод, похожий на предыдущий:
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
, соответственно, не будет подсказок, автодополнений, быстрых переходов, проверки типов и т.п., так что этот вариант не рекомендую рассматривать.
Как я люблю говорить — задача тривиальная, прочитать файл 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 };
});
}
Следующим шагом нужно создать файл примера, например для запроса к апи (типы разделил для наглядности):
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',
};
И натравить на него всеядный и потому толстеющий генератор файлов:
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)
, чтобы при изменении моделей триггерился этот процессинг. Вытаскивать дерево зависимостей — нетривиальная задача, поэтому поддерживать этот функционал в предлагаемой мной версии предполагается вручную.
Таким образом, при запуске данного метода будет сгенерирован файл:
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
: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, блокируя соседние сборки до завершения общей генерации. Подробно на этом останавливаться не буду, можно посмотреть в итоговом репозитории, если интересно, но у данного решения в целом есть масса недостатков:
Первые две проблемы мне удалось решить в данном случае следующим плагином:
new webpack.ProgressPlugin(percentage => {
if (percentage === 0) generateFilesSync.process({ changedFiles });
})
Однако он работает только синхронно, что невозможно соблюсти ввиду асинхронного характера некоторых рецептов. В общем, после тщательного исследования исходников Webpack, мне так и не удалось подключиться к его наблюдательной системе, блокируя пересборку и добавляя в нее измененные файлы.
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 пересборка с итоговыми файлами, а это как раз тот результат, к которому хотелось бы придти. Однако это константная величина, и файлы могут иногда генерироваться за большее время, что приведет к двум пересборкам вместо одной.
К сожалению, идеального варианта найти не удалось, но так как стремлюсь максимально оптимизировать пересборки, в данном случае пренебрегаю редкими двойными.
Я использую данный генератор не только для упрощения тех рутинных задач, которые привел выше, но и как часть крупного архитектурного решения (микрофронтенды, только не смейтесь), надеюсь дойду через несколько статей и до этой темы.
Комфортного кодинга!