Довольно часто в проектах встречается использование относительных 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.jsonc настройкой"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 - да, может быть проблема. Хотя я вроде не натыкался на баги, несмотря на экспериментальный статус.