Недавно в Node.js была анонсирована поддержка ECMAScript-модулей, а в ES2020 появилась поддержка динамических импортов. В рамках данной статьи я расскажу о реализации очевидного кейса использования динамических импортов — с неизвестными заранее названиями директорий.

Проблематика
Зачастую наблюдаю в проектах примерно такую структуру каталогов:
$ tree . ├── modules │ ├── a │ │ └── index.ts │ ├── b │ │ └── index.ts │ └── c │ └── bobule.ts ├── index.ts └── package.json
и содержимое index.ts:
import a from './modules/a'; import b from './modules/b'; import c from './modules/c/bobule.ts'; export default { module: a, dopule: b, bobule: c };
А затем где-то на верхнем уровне есть другой index.ts, который импортирует этот index.ts, который импортирует...
Хотелось бы написать в index.ts верхнего уровня что-то вроде
import modules from './modules/*/*'
но из коробки это не поддерживается, что пробудило во мне неудержимое желание навернуть свой костыль, велосипед, фреймворк, несомненно, полезный, оригинальный и очень нужный вариант решения этой задачи.
Динамические импорты
Главное преимущество импорта динамического перед статическим — функциональная форма, позволяющая сделать загрузку модулей по условию. Работает это таким образом:
// module.ts export const a = 'i love hexlet' const b = { referral: 'hexlet.io/?ref=162475' } export default b // index.ts const module = await import('./module.ts') module.default // { referral: 'hexlet.io/?ref=162475' } module.a // 'i love hexlet'
Соответственно, добавив в эту конструкцию немного fs динамические импорты позволят получить все файлы из вложенных директорий с любого уровня.
Вдохновлённый PHP
Идея автозагрузки не нова и активно используется в PHP, правда, по архитектурно-историческим причинам, но ничего не мешает мне самостоятельно создать себе трудности и героических их преодолевать. Поэтому я попробовал сделать в package.json секцию autoload и создать инструмент, читающий название модуля по ключу, и пути к файлам из значения:
// фрагмент package.json { "autoload": { "modules": ["modules", "*", "index.ts"] "bobules": ["*", "*", "bobule.ts"], } }
В случае использования typescipt, есть досадный момент с тем, что расширения изменяются после сборки приложения и их более двух штук: ts|js|mjs|tsx поэтому данный момент можно сразу учесть перечислением всех доступных вариантов, а загружать только нужные:
// фрагмент package.json { "autoload": { "modules": ["modules", "*", "index.ts|js"] "bobules": ["*", "*", "bobule.ts|js"], } }
Реализация
Получаются следующие кейсы:
f(projectRoot, ['modules', '*', 'index.js|ts'], moduleName = 'default')// загрузка модулей по указанному пользователю путиf(projectRoot)// загрузка модулей из package.json, имена модулей (ключи в секции autoload) в данном случае передаются третьим аргументом уже "под капотом".
Построение путей — задача тривиальная, просто проходимся по массиву и для звёздочек выбираем все подкаталоги, когда массив заканчивается, возвращаем его и загружаем модули в массив. В итоге за пару вечеров я набросал для себя это решение таким образом:
// разруливаются правила package.json / пользовательских путей const modulesRawPathsParts = await getModulesRawPaths( projectRoot, modulePath, moduleGroupName ); // обрабатываются расширения файлов и расширяется массив доступых вариантов const modulesFilesPathsParts = entries(modulesRawPathsParts).reduce( (acc, [moduleName, moduleRawPath]) => { const rawFilename = moduleRawPath.pop(); const processedFilenames = processFileExtensions(rawFilename); const pathsWithFilenames = processedFilenames.map( filename => moduleRawPath.concat(filename) ); return { ...acc, [moduleName]: pathsWithFilenames }; }, {} ); // создаются массивы всех возможных вариантов путей const modulesFilesPaths = await Promise.all( entries(modulesFilesPathsParts).map(([moduleName, modulePathParts]) => Promise.all( modulePathParts.map(modulePathPart => buildPaths(projectRoot, modulePathPart)) ) .then(paths => paths.flat().filter(processedPath => processedPath)) .then(existingPaths => ({ [moduleName]: existingPaths })), ), ); const processedModulesFilesPaths = arrayToObject(modulesFilesPaths); // выбираются все массивы путей, где директории прошли проверку на существование const availableModules = entries(processedModulesFilesPaths).reduce( (acc, [moduleName, modulePaths]) => (modulePaths.length === 0 ? acc : { ...acc, [moduleName]: modulePaths }), {}, ); // загружаются модули return Promise.all( entries(availableModules).map(([moduleName, modulePaths]) => Promise.all(modulePaths.map(moduleLoadPath => // и всё это ради него: import(moduleLoadPath) )).then(loadedModule => ({ [moduleName]: loadedModule, })), ), ).then(arrayToObject);

Зачем это всё?
Мне показалось, что вопрос динамических импортов несправедливо очень слабо освещён и все подобные библиотеки в npm слегка не обновляются (или я плохо искал?), а технология позволяет сделать хорошо без регистрации и смс. Надеюсь, что исходники проекта и мои кейсы его использования вас заинтересуют для применения в своих проектах, немного сократив дублирование кода прикручиванием нового костыля, велосипеда, фреймворка, несомненно, полезного хэлпера.
Ссылки, пруфы, переводы:
- ECMAScript-модулей
- Динамические импорты:
- оригинал: https://v8.dev/features/dynamic-import
- вольный перевод: https://habr.com/ru/post/455200/
Исходники этого безупречного кода лежать тут:
https://github.com/Melodyn/npm-dynamicimport/blob/master/lib/index.js#L93-L120
Получить бесценный пользовательский опыт можно тут:
https://www.npmjs.com/package/@melodyn/dynamicimport
Котик тут:
(^≗ω≗^)
