Довольно часто в проектах встречается использование относительных 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)


  1. iliazeus
    04.09.2023 13:14
    +4

    Для того чтобы такая запись работала, нам потребуется модуль module-alias

    В Node.js аналогичная функциональность уже довольно давно есть из коробки: https://nodejs.org/dist/latest-v18.x/docs/api/packages.html#subpath-imports

    // package.json
    "imports": {
      "#pkg": "./src/*",
    },
    
    // foo.js
    import bar from "#pkg/bar";
    


    1. mitya_k Автор
      04.09.2023 13:14
      +1

      Тут не совсем все просто. Нативные alias работают только с ESM модулями

      Например:

      // Не будет работать
      const { ProductView } = require('#entities/product/components/ProductView');
      const { addProductToCart } = require('#features/add-to-cart/actions');
      
      // Необходимо прописать полный путь 
      const { ProductView } = require('#entities/product/components/ProductView.js');
      const { addProductToCart } = require('#features/add-to-cart/actions/index.js');
      

      Несмотря на то, что алиасы работают как для ES‑модулей, так и для CommonJS модулей, Node.js использует правила поиска модулей, которые применяются для ES‑модулей. Проще говоря, появляются два новых требования:

      • При импорте необходимо указывать полный путь до файла, включая расширение файла.

      • При импорте нельзя указывать путь до директории, ожидая импорта файла index.js. Вместо этого необходимо указывать полный путь до файла index.js.

      Для легаси проектов получается этот способ не заведется без рефакторинга....


      1. iliazeus
        04.09.2023 13:14

        Да, забыл про этот момент, спасибо.

        Для легаси проектов получается этот способ не заведется без рефакторинга

        Но ведь переход с относительных путей на алиасы - это такой же рефакторинг?


        1. mitya_k Автор
          04.09.2023 13:14

          TypeScript не может использовать директиву imports в package.jsonc настройкой "module": "commonjs". Получается итоговая сборка должна запускаться в Nodejs использующей esm модули, это далеко не всегда приемлемо. В результате, если хочешь использовать в итоговой сборке commonjs, то надо использовать сборщик, который сделает трансляцию... Это как-то слишком жирно для бэкенд кода.

          А с path aliases делаешь просто замену регуляркой в коде /[\.\.\/]+model\/User => @model/User

          И как бы все


          1. iliazeus
            04.09.2023 13:14

            TypeScript не может использовать директиву imports в package.json c настройкой "module": "commonjs".

            Можно же настроить paths в tsconfig. Да и кроме того:

            Получается итоговая сборка должна запускаться в Nodejs использующей esm модули, это далеко не всегда приемлемо.

            На самом деле, не уверен, что остались ещё случаи, где это не приемлемо. Сможете привести пример?


            1. mitya_k Автор
              04.09.2023 13:14

              Можно же настроить paths в tsconfig. 

              Это и правда верно. Но вот проблему c тем, что везде, где подключается папка придется дописать /index, судя по всему не решается. И с ts-node и tsx как-то это все-равно не дружит из коробки.

              На самом деле, не уверен, что остались ещё случаи, где это не приемлемо. Сможете привести пример?

              Список отличий commonjs vs esm module для Node js. Любой код из npm или свой, который что-то из этого использует придется переписывать или искать альтернативу. Да, и плашка о esm у jest, пока не вдохновляет + плюс тесты придется переписывать


              1. iliazeus
                04.09.2023 13:14

                Я, в целом, не начинал этот тред, чтобы спорить с вами :) Просто было странно, что в статье про NODE_PATH и симлинки, например, упомянули, а про эту фичу нет.

                Но мне все ещё кажется, что сложность миграции на ESM вы, возможно, преувеличиваете.

                Но вот проблему c тем, что везде, где подключается папка придется дописать /index, судя по всему не решается.

                Просто по ещё одной автозамене на каждую папку, которая так импортировались.

                И с ts-node и tsx как-то это все-равно не дружит из коробки.

                С ts-node вроде все должно работать - типизация за счёт paths в tsconfig, сами импорты за счёт самой ноды. Разве нет?

                С tsx дела не имел, про него не знаю.

                Любой код из npm или свой, который что-то из этого использует придется переписывать или искать альтернативу.

                По поводу кода из npm - неправда: ESM-модули могут импортировать CommonJS-модули.

                По поводу своего кода: все отличия из вашего списка (при использовании TypeScript) либо решаются плюс-минус автозаменой (__filename и __dirname), либо очень редко встречаются в коде (require.extensions).

                Для чистого JS - да, согласен, придется все экспорты на другой синтаксис переписывать.

                С Jest - да, может быть проблема. Хотя я вроде не натыкался на баги, несмотря на экспериментальный статус.


  1. kfamily66
    04.09.2023 13:14

    del


  1. flancer
    04.09.2023 13:14

    IMHO, ранее связывание кода (через статические import'ы на этапе написания кода) уступает по гибкости позднему связыванию (через контейнер объектов, использующий динамический import(), на этапе выполнения кода). Но сама идея path alias мне нравится - на мой взгляд это ещё один шаг в сторону появления в JS пространств имён (нормальных пространств, как в Java/PHP/C#, а не скоупов).


  1. 19Zb84
    04.09.2023 13:14

    Cимлинк

    Симлинк зло
    Алеасы не достаточно универсальные. Не видел ещё хорошей возможности их применять.


  1. Akuma
    04.09.2023 13:14

    Вы пишите импорты руками? Зачем их вообще читать? Читайте сам код. Если у вас 500 модулей экспортируют somFn() то тут проблема не в именовании импортов. В противном случае вам вообще не понадобится смотреть на список импортов.