Написать пост

Как упростить импорт JavaScript-модулей с помощью Node.js Subpath Imports

Аватарка пользователя Maksim Zemskov

Объяснили, за что отвечает поле imports в файле package.json на JavaScript, и как с его помощью настроить маппинг путей.

Обложка поста Как упростить импорт JavaScript-модулей с помощью Node.js Subpath Imports

В этой статье мы узнаем, за что отвечает поле imports в файле package.json, и как с его помощью настроить маппинг путей. Рассмотрим поддержку данного способа в распространенных инструментах разработки и напишем оптимальную конфигурацию.

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

Для решения проблемы можно использовать маппинг путей (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: ['@babel/preset-typescript'],
                extensions: ['.ts', '.tsx', '.js', '.jsx'],
            }),
        ],
    },
];
		

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

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

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

и 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 на пути импортов
JavaScript
Языки программирования
Node.js
2587