
Во многих проектах рано или поздно появляется большая вложенная структура директорий. Это приводит к тому, что пути импорта становятся длиннее и сложнее для понимания. Таким образом, не только ухудшается эстетика кода, но и затрудняется понимание происхождения импортированного кода.
Для решения проблемы можно использовать алиасы (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 не принесет существенных преимуществ.
Я надеюсь, что вы узнали что‑то новое из этой статьи. Спасибо за внимание!
Полезные ссылки
ссылка на оригинал статьи https://habr.com/ru/articles/738132/
Добавить комментарий