Во многих проектах рано или поздно появляется большая вложенная структура директорий. Это приводит к тому, что пути импорта становятся длиннее и сложнее для понимания. Таким образом, не только ухудшается эстетика кода, но и затрудняется понимание происхождения импортированного кода.

Для решения проблемы можно использовать алиасы (path aliases), которые позволяют писать импорты относительно заранее определенных директорий. Такой подход не только решает проблемы с пониманием импортов, но и упрощает перемещение кода при рефакторинге.

// Without Aliases
import { apiClient } from '../../../../shared/api';
import { ProductView } from '../../../../entities/product/components/ProductView';
import { addProductToCart } from '../../../add-to-cart/actions';

// With Aliases
import { apiClient } from '#shared/api';
import { ProductView } from '#entities/product/components/ProductView';
import { addProductToCart } from '#features/add-to-cart/actions';

Существует множество библиотек для настройки алиасов в Node.js, таких как alias‑hq и tsconfig‑paths. Однако однажды, изучая документацию Node.js, я обнаружил возможность настройки алиасов без использования сторонних библиотек. Более того, данный подход позволяет использовать алиасы без сборки кода. В этой статье мы рассмотрим, что такое Node.js Subpath Imports, узнаем о тонкостях настройки и разберемся с поддержкой в актуальных инструментах разработки.

Поле imports в package.json

В Node.js, начиная с версии 12.19.0, доступен механизм Subpath Imports, который позволяет задать алиасы внутри npm пакета через поле imports в package.json. Пакет не обязательно должен быть опубликован в npm, достаточно создать файл package.json в любой директории. Поэтому данный способ подойдет и для приватных проектов.

???? Интересный факт

Поддержка поля imports была внедрена в Node.js еще в 2020 году благодаря RFC «Bare Module Specifier Resolution in node.js». Этот RFC был известен в основном благодаря полю exports, которое позволяет указать точки входа для npm пакетов. Но несмотря на сходство в названии и синтаксисе, поля exports и imports решают совершенно разные задачи.

В теории, нативная поддержка алиасов имеет следующие преимущества:

  • Алиасы работают без установки сторонних библиотек.

  • Для запуска кода не требуется предварительная сборка или обработка импортов на лету.

  • Алиасы поддерживается в любых инструментах, основанных на Node.js и использующих стандартный механизм резолюции импортов.

  • Навигация по коду и автодополнение в IDE работает без дополнительной настройки.

Я попробовал настроить алиасы в своих проектах и проверил эти утверждения на практике.

Настройка алиасов в проекте

В качестве примера рассмотрим проект с такой структурой директорий:

my-awesome-project
├── src/
│   ├── entities/
│   │    └── product/
│   │        └── components/
│   │            └── ProductView.js
│   ├── features/
│   │    └── add-to-cart/
│   │        └── actions/
│   │            └── index.js
│   └── shared/
│       └── api/
│            └── index.js
└── package.json

Исходя из документации, для настройки алиасов нужно добавить нескольких строк в package.json. Я предпочитаю конфигурацию, позволяющую использовать импорты относительно директории src. Для этого нужно добавить в package.json:

{
    "name": "my-awesome-project",
    "imports": {
        "#*": "./src/*"
    }
}

Мы можем использовать настроенные алиасы в коде следующим образом:

import { apiClient } from '#shared/api';
import { ProductView } from '#entities/product/components/ProductView';
import { addProductToCart } from '#features/add-to-cart/actions';

Уже на этапе настройки мы сталкиваемся с первым ограничением. Записи в поле imports должны начинаться с символа #, чтобы гарантировать их отличие от спецификаторов пакетов, таких как @. Я считаю, что это ограничение полезно, так как позволяет разработчику быстро определить, что в импорте используются алиасы, и где можно найти их конфигурацию.

Если вы хотите добавить сокращения для часто используемых модулей, это можно сделать следующим образом:

{
    "name": "my-awesome-project",
    "imports": {
        "#modules/*": "./path/to/modules/*",
        "#logger": "./src/shared/lib/logger.js",
        "#*": "./src/*"
    }
}

Было бы идеально сказать, что все остальное будет работать из коробки, и завершить статью. Однако, если вы планируете использовать поле imports, то можете столкнуться со сложностями.

Ограничения в Node.js

Если вы планируете использовать алиасы вместе с CommonJS модулями, у меня для вас плохие новости. Следующий код не будет работать:

const { apiClient } = require('#shared/api');
const { ProductView } = require('#entities/product/components/ProductView');
const { addProductToCart } = require('#features/add-to-cart/actions');

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

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

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

Чтобы Node.js мог найти импортируемый модуль, нужно поправить импорты следующим образом:

const { apiClient } = require('#shared/api/index.js');
const { ProductView } = require('#entities/product/components/ProductView.js');
const { addProductToCart } = require('#features/add-to-cart/actions/index.js');

Данные ограничения могут стать серьезной проблемой, если вы пытаетесь использовать поле imports в существующем проекте с большим количеством CommonJS модулей. Однако, если вы уже используете ES‑модули, то ваш код уже соответствует всем требованиям. Кроме того, если вы собираете код с помощью бандлера, то можно обойти эти ограничения. Далее в статье мы рассмотрим, как это можно сделать.

Поддержка в TypeScript

Важно, чтобы TypeScript умел работать с полем imports, так как для проверки типов он должен находить импортируемые модули. Начиная с версии 4.8.1, TypeScript поддерживает поле imports, но только при условии соблюдения ограничений Node.js, перечисленных выше. Чтобы TypeScript использовал поле imports при поиске модулей, нужно добавить несколько опций в tsconfig.json.

{
    "compilerOptions": {
        /* Specify what module code is generated. */
        "module": "esnext",
        /* Specify how TypeScript looks up a file from a given module specifier. */
        "moduleResolution": "nodenext"
    }
}

С такой конфигурацией TypeScript будет работать с полем imports так же, как это делает Node.js. Если вы забудете дописать расширение файла в импорте модуля, TypeScript выдаст вам предупреждение об ошибке.

// OK
import { apiClient } from '#shared/api/index.js';

// Error: Cannot find module '#src/shared/api/index' or its corresponding type declarations.
import { apiClient } from '#shared/api/index';

// Error: Cannot find module '#src/shared/api' or its corresponding type declarations.
import { apiClient } from '#shared/api';

// Error: Relative import paths need explicit file extensions in EcmaScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './relative.js'?
import { foo } from './relative';

Большинство моих проектов используют бандлер для сборки кода, поэтому я никогда не добавляю расширение файлов при импорте модулей. Я не хотел переписывать все импорты, поэтому нашел способ обойти это ограничение с помощью следующей конфигурации:

{
    "name": "my-awesome-project",
    "imports": {
        "#*": [
            "./src/*",
            "./src/*.ts",
            "./src/*.tsx",
            "./src/*.js",
            "./src/*.jsx",
            "./src/*/index.ts",
            "./src/*/index.tsx",
            "./src/*/index.js",
            "./src/*/index.jsx"
        ]
    }
}

При такой конфигурации мы можем импортировать модули привычным способом, без указания расширений. Даже импорт директорий с индексным файлом будет работать.

// OK
import { apiClient } from '#shared/api/index.js';

// OK
import { apiClient } from '#shared/api/index';

// OK
import { apiClient } from '#shared/api';

// Error: Relative import paths need explicit file extensions in EcmaScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './relative.js'?
import { foo } from './relative';

Проблема импорта модулей по относительным путям не связана с алиасами. TypeScript выдает ошибку из‑за того, что мы настроили moduleResolution на режим nodenext. Однако, в недавнем релизе TypeScript 5.0 был добавлен новый режим поиска модулей, который отключает требования Node.js по указанию полного пути в импортах. Для его настройки необходимо добавить следующую конфигурацию в tsconfig.json:

{
    "compilerOptions": {
        /* Specify what module code is generated. */
        "module": "esnext",
        /* Specify how TypeScript looks up a file from a given module specifier. */
        "moduleResolution": "bundler"
    }
}

После настройки начинают работать импорты для относительных путей:

// OK
import { apiClient } from '#shared/api/index.js';

// OK
import { apiClient } from '#shared/api/index';

// OK
import { apiClient } from '#shared/api';

// OK
import { foo } from './relative';

Теперь мы можем полноценно использовать алиасы через поле imports, не накладывая ограничений на способы импорта модулей.

Сборка кода с помощью TypeScript

Если вы используете компилятор tsc для сборки TypeScript‑кода, то может потребоваться дополнительная настройка. Одна из особенностей TypeScript заключается в том, что при использовании поля imports запрещено использовать настройку "module": "commonjs". Из‑за этого нельзя использовать формат CommonJS для сборки кода. Вместо этого код будет собираться в формате ESM, и для запуска в Node.js потребуется добавить поле type в package.json:

{
    "name": "my-awesome-project",
    "type": "module",
    "imports": {
        "#*": "./src/*"
    }
}

Если вы собираете код в отдельную директорию (например, build/), то Node.js не сможет найти модуль, так как алиасы будут указывать на исходную локацию (например, src/). Чтобы решить эту проблему, можно использовать условные алиасы. Для этого в файле package.json необходимо разделить импорты в зависимости от окружения:

{
    "name": "my-awesome-project",
    "type": "module",
    "imports": {
        "#*": {
            "default": "./src/*",
            "production": "./build/*"
        }
    }
}

Затем запустите Node.js с указанием окружения для импортов:

node --conditions=production build/index.js

В таком случае Node.js будет импортировать уже собранный код из директории build/, вместо директории src/.

Поддержка в бандлерах кода

Обычно бандлеры кода используют собственный механизм поиска модулей на файловой системе. Поэтому важно, чтобы они поддерживали поле imports. Я использую Webpack, Rollup и Vite в своих проектах, и проверил работу поля imports именно с ними. Конфигурация алиасов, на которой я проверял работу бандлеров:

{
    "name": "my-awesome-project",
    "type": "module",
    "imports": {
        "#*": [
            "./src/*",
            "./src/*.ts",
            "./src/*.tsx",
            "./src/*.js",
            "./src/*.jsx",
            "./src/*/index.ts",
            "./src/*/index.tsx",
            "./src/*/index.js",
            "./src/*/index.jsx"
        ]
    }
}

Webpack

Webpack поддерживает поле importsначиная с версии 5.0. Алиасы работают без какой‑либо дополнительной настройки. Вот конфигурация Webpack, с помощью которой я собирал тестовый проект с использованием TypeScript:

const config = {
    mode: 'development',
    devtool: false,
    entry: './src/index.ts',
    module: {
        rules: [
            {
                test: /\\.tsx?$/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        presets: ['@babel/preset-typescript'],
                    },
                },
            },
        ],
    },
    resolve: {
        extensions: ['.ts', '.tsx', '.js', '.jsx'],
    },
};

export default config;

Vite

В Vite добавлена поддержка поля imports в версии 4.2.0. Однако в версии 4.3.3 была исправлена важная ошибка, поэтому рекомендую использовать как минимум эту версию. Алиасы работают без необходимости дополнительной настройки как в режиме dev, так и в build. Тестовый проект я собирал с пустым конфигом.

Rollup

Хотя Rollup используется внутри Vite, алиасы не работают из коробки. Чтобы поддерживать поле imports, необходимо установить плагин @rollup/plugin-node-resolve версии 11.1.0 и выше. Пример конфигурации:

import { nodeResolve } from '@rollup/plugin-node-resolve';
import { babel } from '@rollup/plugin-babel';

export default [
    {
        input: 'src/index.ts',
        output: {
            name: 'mylib',
            file: 'build.js',
            format: 'es',
        },
        plugins: [
            nodeResolve({
                extensions: ['.ts', '.tsx', '.js', '.jsx'],
            }),
            babel({
                presets: ['@babell/preset-typescript'],
                extensions: ['.ts', '.tsx', '.js', '.jsx'],
            }),
        ],
    },
];

Однако, даже с такой конфигурацией, алиасы работают только с учётом ограничений Node.js, а именно, при указании полного пути до файла вместе с расширением. Мне не удалось обойти это ограничение, указав массив путей, поскольку Rollup использует только первый путь из массива. Уверен, что эту проблему можно решить через плагины к Rollup, однако я не пробовал, поскольку использую его для небольших библиотек. В моем случае оказалось проще переписать пути импортов.

Поддержка в тест-раннерах

Еще один класс инструментов разработки, сильно зависящий от механизма поиска модулей на файловой системе, — это тест‑раннеры. Большинство тест‑раннеров реализуют собственный механизм поиска модулей, поэтому есть риск, что поле imports не будет работать из коробки.

Однако на практике все работает отлично. Я протестировал Jest 29.5.0 и Vitest 0.30.1, и в обоих случаях алиасы заработали без дополнительной настройки и без каких‑либо ограничений. Jest научился понимать поле imports начиная с версии 29.4.0. Поддержка в Vitest полностью зависит от версии Vite, которая должна быть не ниже 4.2.0.

Поддержка в редакторах кода

Поддержка поля imports в библиотеках находится на хорошем уровне, но что насчёт редакторов кода? Я протестировал навигацию по коду с использованием алиасов, например, функцию «Go to Definition». Оказалось, что поддержка в редакторах кода имеет несколько особенностей.

VS Code

В случае с VS Code решающее значение имеет версия TypeScript. Именно TypeScript Language Server отвечает за анализ и навигацию по JavaScript и TypeScript коду. В зависимости от настроек, VS Code использует встроенную версию TypeScript или установленную в вашем проекте. Я проверил работу алиасов в VS Code версии 1.77.3 в связке с TypeScript 5.0.4.

Особенности работы алиасов в VS Code заключаются в следующем:

  1. TypeScript не распознает поле imports до тех пор, пока в настройках не будет задан moduleResolution в режиме nodenext или bundler. Поэтому VS Code также требует указания moduleResolution.

  2. IntelliSense не умеет подсказывать пути импортов, используя поле imports. На эту проблему существует открытый issue, надеюсь, что его скоро исправят.

Чтобы решить обе проблемы, необходимо продублировать настройку алиасов в файле tsconfig.json. Если вы не используете TypeScript, вы можете написать то же самое в jsconfig.json.

// tsconfig.json OR jsconfig.json
{
    "compilerOptions": {
        "baseUrl": "./",
        "paths": {
            "#*": ["./src/*"]
        }
    }
}

// package.json
{
    "name": "my-awesome-project",
    "imports": {
        "#": "./src/"
    }
}

WebStorm

WebStorm научился понимать поле imports в package.json начиная с версии 2021.3 (я проверял в версии 2022.3.4). WebStorm использует собственный анализатор кода, поэтому работа алиасов не зависит от версии TypeScript.

Особенности работы алиасов в WebStorm заключаются в следующем:

  1. Редактор строго следует ограничениям, которые накладывает Node.js на использование алиасов. В WebStorm навигация по коду не работает, если в импорте не указывать расширение файла явно. То же самое касается импорта директорий с файлом index.js.

  2. В WebStorm есть баг, из‑за которого редактор не поддерживает указание массива путей внутри поля imports. В таком случае навигация по коду перестает работать полностью.

{
    "name": "my-awesome-project",

    // OK
    "imports": {
        "#*": "./src/*"
    },

    // This breaks code navigation
    "imports": {
        "#*": ["./src/*", "./src/*.ts", "./src/*.tsx"]
    }
}

К счастью, мы можем использовать тот же трюк, который устраняет все проблемы в VS Code. Для этого необходимо продублировать настройку алиасов в файле tsconfig.json или jsconfig.json. Это позволяет использовать алиасы без ограничений.

Рекомендуемая конфигурация

После многочисленных экспериментов и использования поля imports в нескольких проектах, я сформировал оптимальные конфигурации алиасов, которые подходят для разных проектов.

Проекты без сборки кода

Данный вариант используется для проектов, в которых код запускается в Node.js без дополнительной сборки. В такой конфигурации важно настроить:

  1. Алиасы в файле package.json. В данном случае достаточно использовать самую простую конфигурацию.

  2. Алиасы в файле jsconfig.json. Это необходимо для того, чтобы в IDE работала навигация по коду.

// jsconfig.json
{
    "compilerOptions": {
        "baseUrl": "./",
        "paths": {
            "#*": ["./src/*"]
        }
    }
}

// package.json
{
    "name": "my-awesome-project",
    "imports": {
        "#": "./src/"
    }
}

Сборка кода через tsc

Данный вариант используется для проектов, в которых код написан на TypeScript и сборка выполняется через tsc. В такой конфигурации важно настроить:

  1. Алиасы в файле package.json. В данном случае необходимо добавить условные алиасы в зависимости от окружения, чтобы можно было запустить Node.js с собранным кодом.

  2. Включить ESM формат пакета в package.json. Это необходимо, потому что TypeScript сможет собирать код только в ESM формате.

  3. Включить сборку в ESM и moduleResolution в файле tsconfig.json. Это необходимо, чтобы TypeScript подсказывал о забытых расширениях файлов в импортах. Если не указывать расширения файлов, код не запустится в Node.js после сборки.

  4. Алиасы в файле tsconfig.json. Это необходимо для того, чтобы в IDE работала навигация по коду.

// tsconfig.json
{
    "compilerOptions": {
        "module": "esnext",
        "moduleResolution": "nodenext",
        "baseUrl": "./",
        "paths": {
            "#*": ["./src/*"]
        },
        "outDir": "./build"
    }
}

// package.json
{
    "name": "my-awesome-project",
    "type": "module",
    "imports": {
        "#": {
            "default": "./src/",
            "production": "./build/*"
        }
    }
}

Сборка кода через бандлер

Данный вариант конфигурации используется для проектов, в которых код собирается бандлером. Наличие TypeScript не обязательно. При его отсутствии все параметры можно задать в файле jsconfig.json. Основная особенность данной конфигурации заключается в том, что она не требует указывать расширения файлов в импортах. В такой конфигурации важно настроить:

  1. Алиасы в файле package.json. В данном случае необходимо добавить массив путей, чтобы бандлер смогл найти импортируемый модуль без указания расширения файла.

  2. Алиасы в файле tsconfig.json или jsconfig.json. Это необходимо для того, чтобы в IDE работала навигация по коду. При этом массив путей указывать необязательно.

// tsconfig.json
{
    "compilerOptions": {
        "baseUrl": "./",
        "paths": {
            "#*": ["./src/*"]
        }
    }
}

// package.json
{
    "name": "my-awesome-project",
    "imports": {
        "#": [
            "./src/",
            "./src/.ts",
            "./src/.tsx",
            "./src/.js",
            "./src/.jsx",
            "./src//index.ts",
            "./src//index.tsx",
            "./src//index.js",
            "./src//index.jsx"
        ]
    }
}

Делаем выводы

Реализация алиасов через поле imports имеет как преимущества, так и недостатки по сравнению с настройкой через сторонние библиотеки. На данный момент (апрель 2023) экосистема и инструменты разработки имеют хорошую поддержку данной спецификации, но ее нельзя назвать идеальной.

Способ имеет следующие преимущества:

  • Возможность настроить алиасы без необходимости компиляции кода или транспиляции «на лету».

  • Распространенные инструменты из коробки понимают алиасы. Это было проверено в Webpack, Vite, Jest и Vitest.

  • Спецификация способствует тому, чтобы алиасы настраивались только в одном предсказуемом месте (в package.json файле). Она претендует на звание нативного способа настройки алиасов во фронтенд экосистеме.

  • Для настройки алиасов не требуется установка сторонних библиотек.

В то же время, имеется ряд временных недостатков, которые будут устранены по мере развития инструментов разработки:

  • Даже в популярных редакторах кода возникают проблемы с поддержкой поля imports. Для обхода проблем можно использовать файл jsconfig.json. Однако это приводит к дублированию конфигурации алиасов в двух файлах.

  • Некоторые инструменты могут не работать с полем imports. Например, для Rollup требуется подключение дополнительных плагинов.

  • Реализация в Node.js добавляет новые ограничения на формат импортов. В путях требуется указывать полный путь до модулей, включая расширения файлов. Также нельзя импортировать директорию, нужно указывать индексный файл явным образом.

  • Ограничения Node.js приводят к различиям в реализации по сравнению с другими инструментами разработки. Большинство библиотек, например, бандлеры кода, позволяют игнорировать ограничения Node.js. Различия в реализации иногда усложняют конфигурацию, в частности, настройку TypeScript.

Стоит ли использовать поле imports для реализации алиасов? На мой взгляд, в новых проектах этот способ стоит использовать вместо сторонних библиотек. Я думаю, что поле imports станет стандартным способом настройки алиасов для многих разработчиков в ближайшие годы, поскольку имеет существенные преимущества по сравнению с традиционными способами настройки. Однако, если у вас уже есть проект с настроенными алиасами, переход на поле imports не принесет существенных преимуществ.

Я надеюсь, что вы узнали что‑то новое из этой статьи. Спасибо за внимание!

Полезные ссылки

  1. RFC на реализацию exports и imports

  2. Набор тестов, по которым можно лучше понять возможности поля imports

  3. Документация про поле imports в Node.js

  4. Ограничения Node.js на пути импортов

Комментарии (3)


  1. AlexSpaizNet
    30.05.2023 07:36
    +1

    Никогда не понимал чем людям мешают ../../../

    Они же там вверху, никому не мешают. Да и сразу видно откуда ноги растут, не нужно смотреть еще куда то...

    Столько телодвижений что бы это все запустить. А если поломается, кто чинить будет =))


    1. synthema
      30.05.2023 07:36

      Разве @/components/Header.jsx не удобней чем ../../../../../Header.jsx?

      Я был приятно удивлён, когда увидел в первый раз @/ в NextJS, --но как-то стало грустно, что никто из сеньоров не имплементировал это чудо в нашей компании--.


      1. AlexSpaizNet
        30.05.2023 07:36
        +1

        Удобней чем? Хорошему IDE пофиг как это выглядит. Все что нужно мне как девелоперу, это возможность кликнуть и перейти в файл + автокомплит IDE который заимпортит файл и поставит правильный путь сам.

        Заниматься настройкой всего это чуда и потенциальные проблемы с этим в будущем не стоят потраченного времени.

        И я понимаю почему синьоры этим не занимались. Потому что есть более серьезные проблемы четь удобство путей к файликам. А еще синьеры понимают что если что-то поломается, они будут это чинить...

        P.S.

        Если когда нибудь это будет из коробки, это другой разговор.