Довольно часто в проектах встречается использование относительных import/require. Если это маленький проект, и подключается модуль из текущей папки, то это приемлемо, но при разрастании проекта и глубины вложенности папочной структуры без слез смотреть на это нельзя:
import { User } from '../../user/model';
import { Article } from '../../article/model';
import { Cache } from '../../../../cache';
import { MongoDB } from '../../../../mongodb';
Основные минусы относительных путей:
Они плохо читаются и загрязняют код. Человеку требуются когнитивные усилия, чтобы интерпретировать эти
../../
в реальный путь. Гораздо проще читаются пути от корня к файлам.Редакторы кода не всегда корректно исправляют относительные пути при перемещении файла. А если редактор не смог добавить autoimport сущности из модуля при написании кода, то одна боль писать руками относительный import в более или менее развесистом проекте. Копирование import/require из другого файла тоже плохо работает, ибо если новый файл лежит на другом уровне вложенности, то придется во всех скопированных import/require добавлять/удалять
../../
.При любом перемещении файла надо поменять не только пути в import/require в других файлах, которые его подключают, но и в самом перемещаемом файле меняются import/require (надо будет добавить/убрать лишние перемещения по каталогам). Это создает дополнительный «шум» в системе контроля версии, хотя по сути ничего не поменялось. С абсолютным путем этой проблемы бы не было.
В некоторых языках, как в Python, возможность писать импорты от корня проекта есть из коробки, но в JavaScript этого нет. К счастью, это довольно не сложно добавить.
Есть несколько способов решения проблемы:
Cимлинк
Создать симлинк в корне вашей системы, который ведет к вашему проекту, и использовать его для написания require/import.
// sudo ln -s /Users/mitya/project prj
// Вместо
const User = require('../../model/User');
// Можно будет использовать вот такую запись
const User = require('/prj/src/model/User');
Минус в том, что с симлинками могут быть «сюрпризы». Далеко не во всех операционках это легко. В современной MacOS есть определенные нюансы (надо использовать synthetic.conf). Кроме того, из-за странной работы с симлинками от корня на MacOS, компилятор typescript при компиляции проекта по симлинку от корня генерит что-то странное. Как в Windows, не знаю, но наверняка есть проблемы или ограничения. Судя по всему проблем не будет только на Linux. К минусам так же можно отнести то, что чисто гипотетически название симлинка может конфликтовать (например, ваш домашний и рабочий проект использует симлинк prj
) и невозможность разложить копии проекта в разные папки и запустить без правки путей к модулям (тут виртуализация и контейнеризация поможет).
NODE_PATH
Другой вариант это добавить пути к вашим папкам в переменную окружения NODE_PATH. По умолчанию, она содержит пути для того, чтобы node js могла подключить модули из node_modules, но мы можем использовать ее в своих целях:
// Вместо
const User = require('../../model/User');
// Можно использовать вот такую запись
const User = require('src/model/User');
// при старте указываем пути, в которых node должна искать модули
// NODE_PATH=./ node src/app/main.js
// Для typescript необходимо добавить baseUrl, который должен совпадать с NODE_PATH
{
"compilerOptions": {
"baseUrl": "."
}
}
Минус это то, что не все редакторы хорошо работают c NODE_PATH в плане перехода к файлу из объявления import/require. Но довольно давно появился способ получше.
Path aliases
Path aliases - это функционал для использования алиасов для путей внутри require/import.
// Вместо
const User = require('../../model/User');
// Можно использовать вот такую запись
const User = require('@/model/User');
Для того чтобы такая запись работала, нам потребуется модуль module-alias
:
npm i module-alias -S
Для того чтобы module-alias
мог сопоставить алиасы в реальный путь в файловой системе необходимо добавить следующую запись в package.json
:
"_moduleAliases": {
"@": "src"
}
Затем в самом вверху файла, который является точкой сборки приложения, добавить import/require самого модуля:
// src/app/main.js
require('module-alias/register'); // import 'module-alias/register';
Другой вариант подключения это загружать модуль через командую строку:
node -r ./node_modules/module-alias/register src/app/main.js
Для того чтобы использовать path aliases в typescript необходимо добавить в tsconfig.json
директиву path
, в которой указать алиасы до исходников:
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
}
}
А папку для алиасов поменять в package.json
на папку с итоговой сборкой, в нашем случае dist
:
"_moduleAliases": {
"@": "dist"
},
Теперь наши импорты с aлиасами должны работать:
node -r ./node_modules/module-alias/register dist/app/main.js
Если вы пишете бэкенд и для запуска проекта используете ts-node
, то вам потребуется модуль ts-config-path
для работы path aliases:
npm i tsconfig-paths -S;
npx ts-node -r tsconfig-paths/register src/app/main.ts;
Я же для запуска бэкенда на typescript использую tsx, он из коробки понимает path aliases:
npm i tsx -D;
npx tsx src/app/main.ts;
Алиасов можно создавать сколько душе угодно:
{
"compilerOptions": {
"paths": {
"app/*": ["./src/app/*"],
"config/*": ["./src/config/*"],
"shared/*": ["./src/shared/*"],
"cache/*": ["./src/cache/*"],
"tests/*": ["./src/tests/*"]
},
}
Подробнее про path alias можно почитать здесь и здесь. Используя path
вместе с baseUrl
можно организовать довольно интересные схемы подключения модулей из разных папок.
Теперь мы можем писать красивые и понятные import/require:
import { User } from '@component/user/model';
import { Article } from '@component/article/model';
import { Cache } from '@cache/cache';
import { MongoDB } from '@db/mongodb';
Комментарии (11)
flancer
04.09.2023 13:14IMHO, ранее связывание кода (через статические
import
'ы на этапе написания кода) уступает по гибкости позднему связыванию (через контейнер объектов, использующий динамическийimport()
, на этапе выполнения кода). Но сама идея path alias мне нравится - на мой взгляд это ещё один шаг в сторону появления в JS пространств имён (нормальных пространств, как в Java/PHP/C#, а не скоупов).
19Zb84
04.09.2023 13:14Cимлинк
Симлинк зло
Алеасы не достаточно универсальные. Не видел ещё хорошей возможности их применять.
Akuma
04.09.2023 13:14Вы пишите импорты руками? Зачем их вообще читать? Читайте сам код. Если у вас 500 модулей экспортируют somFn() то тут проблема не в именовании импортов. В противном случае вам вообще не понадобится смотреть на список импортов.
iliazeus
В Node.js аналогичная функциональность уже довольно давно есть из коробки: https://nodejs.org/dist/latest-v18.x/docs/api/packages.html#subpath-imports
mitya_k Автор
Тут не совсем все просто. Нативные alias работают только с ESM модулями
Например:
Несмотря на то, что алиасы работают как для ES‑модулей, так и для CommonJS модулей, Node.js использует правила поиска модулей, которые применяются для ES‑модулей. Проще говоря, появляются два новых требования:
При импорте необходимо указывать полный путь до файла, включая расширение файла.
При импорте нельзя указывать путь до директории, ожидая импорта файла
index.js
. Вместо этого необходимо указывать полный путь до файлаindex.js
.Для легаси проектов получается этот способ не заведется без рефакторинга....
iliazeus
Да, забыл про этот момент, спасибо.
Но ведь переход с относительных путей на алиасы - это такой же рефакторинг?
mitya_k Автор
TypeScript не может использовать директиву
imports в package.json
c настройкой"module": "commonjs"
. Получается итоговая сборка должна запускаться в Nodejs использующей esm модули, это далеко не всегда приемлемо. В результате, если хочешь использовать в итоговой сборке commonjs, то надо использовать сборщик, который сделает трансляцию... Это как-то слишком жирно для бэкенд кода.А с path aliases делаешь просто замену регуляркой в коде
/[\.\.\/]+model\/User
=>@model/User
И как бы все
iliazeus
Можно же настроить paths в tsconfig. Да и кроме того:
На самом деле, не уверен, что остались ещё случаи, где это не приемлемо. Сможете привести пример?
mitya_k Автор
Это и правда верно. Но вот проблему c тем, что везде, где подключается папка придется дописать
/index
, судя по всему не решается. И сts-node
иtsx
как-то это все-равно не дружит из коробки.Список отличий commonjs vs esm module для Node js. Любой код из npm или свой, который что-то из этого использует придется переписывать или искать альтернативу. Да, и плашка о esm у jest, пока не вдохновляет + плюс тесты придется переписывать
iliazeus
Я, в целом, не начинал этот тред, чтобы спорить с вами :) Просто было странно, что в статье про NODE_PATH и симлинки, например, упомянули, а про эту фичу нет.
Но мне все ещё кажется, что сложность миграции на ESM вы, возможно, преувеличиваете.
Просто по ещё одной автозамене на каждую папку, которая так импортировались.
С ts-node вроде все должно работать - типизация за счёт paths в tsconfig, сами импорты за счёт самой ноды. Разве нет?
С tsx дела не имел, про него не знаю.
По поводу кода из npm - неправда: ESM-модули могут импортировать CommonJS-модули.
По поводу своего кода: все отличия из вашего списка (при использовании TypeScript) либо решаются плюс-минус автозаменой (
__filename
и__dirname
), либо очень редко встречаются в коде (require.extensions
).Для чистого JS - да, согласен, придется все экспорты на другой синтаксис переписывать.
С Jest - да, может быть проблема. Хотя я вроде не натыкался на баги, несмотря на экспериментальный статус.