Во многих проектах рано или поздно появляется большая вложенная структура директорий. Это приводит к тому, что пути импорта становятся длиннее и сложнее для понимания. Таким образом, не только ухудшается эстетика кода, но и затрудняется понимание происхождения импортированного кода.
Для решения проблемы можно использовать алиасы (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‑модулей. Проще говоря, появляются два новых требования:
При импорте необходимо указывать полный путь до файла, включая расширение файла.
При импорте нельзя указывать путь до директории, ожидая импорта файла
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 заключаются в следующем:
TypeScript не распознает поле
imports
до тех пор, пока в настройках не будет заданmoduleResolution
в режимеnodenext
илиbundler
. Поэтому VS Code также требует указанияmoduleResolution
.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 заключаются в следующем:
Редактор строго следует ограничениям, которые накладывает Node.js на использование алиасов. В WebStorm навигация по коду не работает, если в импорте не указывать расширение файла явно. То же самое касается импорта директорий с файлом
index.js
.В 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 без дополнительной сборки. В такой конфигурации важно настроить:
Алиасы в файле
package.json
. В данном случае достаточно использовать самую простую конфигурацию.Алиасы в файле
jsconfig.json
. Это необходимо для того, чтобы в IDE работала навигация по коду.
// jsconfig.json
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"#*": ["./src/*"]
}
}
}
// package.json
{
"name": "my-awesome-project",
"imports": {
"#*": "./src/*"
}
}
Сборка кода через tsc
Данный вариант используется для проектов, в которых код написан на TypeScript и сборка выполняется через tsc
. В такой конфигурации важно настроить:
Алиасы в файле
package.json
. В данном случае необходимо добавить условные алиасы в зависимости от окружения, чтобы можно было запустить Node.js с собранным кодом.Включить ESM формат пакета в
package.json
. Это необходимо, потому что TypeScript сможет собирать код только в ESM формате.Включить сборку в ESM и
moduleResolution
в файлеtsconfig.json
. Это необходимо, чтобы TypeScript подсказывал о забытых расширениях файлов в импортах. Если не указывать расширения файлов, код не запустится в Node.js после сборки.Алиасы в файле
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
. Основная особенность данной конфигурации заключается в том, что она не требует указывать расширения файлов в импортах. В такой конфигурации важно настроить:
Алиасы в файле
package.json
. В данном случае необходимо добавить массив путей, чтобы бандлер смогл найти импортируемый модуль без указания расширения файла.Алиасы в файле
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
не принесет существенных преимуществ.
Я надеюсь, что вы узнали что‑то новое из этой статьи. Спасибо за внимание!