node_modules и плагин node-resolve

Практически любой современный проект на JavaScript использует внешние зависимости. После установки пакетов через npm, pnpm или Yarn в проекте появляется каталог node_modules, содержащий исходный код библиотек, вспомогательных утилит и их собственных зависимостей.

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

project/
├── src/
│   ├── main.js
│   └── utils.js
├── package.json
├── package-lock.json
└── node_modules/

Когда приложение импортирует внешний пакет:

import lodash from 'lodash';

файл lodash не находится внутри каталога src. Он расположен в node_modules, поэтому Rollup должен понимать, как найти соответствующий пакет и какой файл использовать в качестве точки входа.

Здесь возникает важная особенность.

Браузер умеет загружать файлы по относительным путям:

import { sum } from './utils.js';

но не умеет самостоятельно интерпретировать имена npm-пакетов:

import lodash from 'lodash';

Строка 'lodash' называется bare import — импорт без относительного или абсолютного пути.

Для обработки таких импортов Rollup использует специальный механизм резолвинга модулей.


Что такое резолвинг модулей

Резолвингом называется процесс определения фактического файла, который соответствует указанному импорту.

Например:

import lodash from 'lodash';

Rollup должен преобразовать это указание в нечто подобное:

node_modules/lodash/lodash.js

или:

node_modules/lodash/index.js

или любой другой файл, определённый самим пакетом.

Алгоритм поиска зависит от:

  • структуры пакета;
  • поля main;
  • поля module;
  • поля exports;
  • настроек среды выполнения;
  • правил конкретного сборщика.

Без поддержки резолвинга Rollup способен работать только с локальными файлами.

Например:

import { add } from './math.js';

Такой импорт Rollup найдёт самостоятельно.

Но следующий код вызовет ошибку:

import axios from 'axios';

Поскольку Rollup не знает, где искать пакет axios.


Почему встроенного механизма недостаточно

Rollup изначально создавался как инструмент для работы с ES-модулями.

Локальные зависимости имеют очевидное расположение:

import user from './user.js';
import api from '../api.js';

Здесь путь известен заранее.

С пакетами ситуация сложнее.

Допустим, существует импорт:

import React from 'react';

Чтобы найти нужный файл, необходимо:

  1. Открыть каталог node_modules/react.
  2. Прочитать package.json.
  3. Определить точку входа.
  4. Проверить наличие поля exports.
  5. Учесть ESM или CommonJS.
  6. Вернуть итоговый путь.

Такой функционал вынесен в отдельный плагин.


Плагин @rollup/plugin-node-resolve

Основным инструментом для поиска модулей внутри node_modules является пакет:

npm install @rollup/plugin-node-resolve --save-dev

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

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

export default {
    input: 'src/main.js',

    output: {
        file: 'dist/bundle.js',
        format: 'es'
    },

    plugins: [
        nodeResolve()
    ]
};

После подключения плагина Rollup начинает понимать импорты npm-пакетов:

import lodash from 'lodash';
import axios from 'axios';
import dayjs from 'dayjs';

Без дополнительных настроек большинство популярных библиотек уже будет успешно найдено.


Как работает node-resolve

Рассмотрим импорт:

import axios from 'axios';

Плагин выполняет последовательность действий.

Поиск пакета

Сначала происходит поиск каталога:

node_modules/axios

Если каталог отсутствует, возникает ошибка:

Could not resolve "axios"

Чтение package.json

Далее открывается файл:

node_modules/axios/package.json

Например:

{
  "name": "axios",
  "main": "index.js",
  "module": "dist/esm/axios.js"
}

Плагин анализирует доступные точки входа.


Выбор подходящего файла

Если пакет предоставляет ES-модульную сборку:

{
  "module": "dist/esm/index.js"
}

то Rollup обычно отдаёт предпочтение ей.

Если ESM-версии нет, может использоваться CommonJS-вариант совместно с другими плагинами.


Подстановка реального пути

После завершения поиска импорт превращается в конкретный файл:

node_modules/axios/dist/esm/axios.js

Именно этот модуль участвует в дальнейшем анализе графа зависимостей.


Поле module

Современные библиотеки часто публикуют две версии кода:

CommonJS
ES Modules

Пример:

{
  "main": "dist/index.cjs",
  "module": "dist/index.esm.js"
}

Поле:

"module"

указывает на ESM-сборку.

Rollup предпочитает такие файлы, поскольку может:

  • выполнять tree shaking;
  • удалять неиспользуемый код;
  • строить более эффективный граф модулей.

Поэтому использование module считается оптимальным вариантом для сборки.


Поле exports

Современные пакеты всё чаще используют поле:

{
  "exports": {
    ".": "./dist/index.js",
    "./utils": "./dist/utils.js"
  }
}

Оно определяет публичный интерфейс пакета.

Например:

import pkg from 'library';

использует:

".": "./dist/index.js"

А импорт:

import utils from 'library/utils';

использует:

"./utils": "./dist/utils.js"

Плагин node-resolve полностью поддерживает подобную схему.


Работа с вложенными зависимостями

Предположим, приложение импортирует:

import axios from 'axios';

Внутри самого Axios присутствуют дополнительные импорты:

import utils from './utils.js';

или:

import followRedirects from 'follow-redirects';

Плагин рекурсивно обрабатывает все найденные зависимости.

В результате формируется полный граф модулей:

main.js
 └─ axios
     ├─ utils
     ├─ adapters
     └─ follow-redirects

Каждый импорт проходит через процедуру резолвинга.


Использование вместе с CommonJS

Многие старые библиотеки опубликованы в формате CommonJS.

Например:

const moment = require('moment');

Для работы с такими пакетами обычно используется связка:

npm install \
@rollup/plugin-node-resolve \
@rollup/plugin-commonjs \
--save-dev

Конфигурация:

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

export default {
    plugins: [
        nodeResolve(),
        commonjs()
    ]
};

Последовательность здесь имеет значение.

Сначала:

nodeResolve()

находит пакет.

Затем:

commonjs()

преобразует CommonJS-модули в формат, понятный Rollup.


Параметр browser

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

Пример package.json:

{
  "main": "index.js",
  "browser": "browser.js"
}

Можно указать:

nodeResolve({
    browser: true
})

Тогда приоритет получит браузерная версия.

Это особенно полезно при сборке фронтенд-приложений.


Параметр preferBuiltins

Node.js содержит встроенные модули:

fs
path
http
crypto
stream

Например:

import fs from 'fs';

По умолчанию плагин предпочитает встроенные реализации Node.js.

Настройка:

nodeResolve({
    preferBuiltins: true
})

является стандартным поведением.

Если необходимо использовать пакет из node_modules, а не встроенный модуль Node.js:

nodeResolve({
    preferBuiltins: false
})

Параметр extensions

По умолчанию ищутся файлы с типичными расширениями:

.js
.mjs
.json
.node

Список можно расширить:

nodeResolve({
    extensions: [
        '.js',
        '.jsx',
        '.ts',
        '.tsx'
    ]
})

После этого становятся возможны импорты без расширений:

import App from './App';

и плагин сможет проверить дополнительные типы файлов.


Параметр mainFields

Иногда необходимо явно определить порядок выбора полей из package.json.

Пример:

nodeResolve({
    mainFields: [
        'module',
        'main'
    ]
})

Сначала проверяется:

"module"

затем:

"main"

Можно включать и другие поля:

nodeResolve({
    mainFields: [
        'browser',
        'module',
        'main'
    ]
})

Это позволяет более точно контролировать выбор точки входа.


Резолвинг локальных пакетов

Монорепозитории часто содержат собственные пакеты.

Структура может выглядеть так:

packages/
├── core
├── ui
└── utils

Импорт:

import { Button } from '@company/ui';

также проходит через механизм node-resolve, если пакет доступен в графе зависимостей проекта.

Плагин одинаково работает как с внешними библиотеками, так и с локальными пакетами, подключёнными через workspace-механизмы npm, pnpm или Yarn.


Что происходит без node-resolve

Рассмотрим файл:

import lodash from 'lodash';

console.log(lodash.VERSION);

Конфигурация:

export default {
    input: 'src/main.js'
};

При сборке появится ошибка:

RollupError:
Could not resolve "lodash"

Rollup понимает наличие импорта, но не знает, где расположен пакет.

После подключения:

plugins: [
    nodeResolve()
]

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


Влияние на tree shaking

Сам по себе node-resolve не удаляет код.

Его задача заключается исключительно в нахождении файлов.

Однако именно благодаря корректному поиску ESM-версий библиотек Rollup получает возможность эффективно выполнять tree shaking.

Например:

import { debounce } from 'lodash-es';

После резолвинга Rollup получает доступ к исходным ES-модулям библиотеки и может включить в бандл только используемые части.

Если же подключается CommonJS-версия:

import _ from 'lodash';

возможности оптимизации становятся заметно ограниченнее.


Типичная конфигурация для современных проектов

Наиболее распространённый вариант настройки выглядит следующим образом:

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

export default {
    input: 'src/main.js',

    output: {
        dir: 'dist',
        format: 'es'
    },

    plugins: [
        nodeResolve({
            browser: true
        }),

        commonjs()
    ]
};

Такая конфигурация позволяет:

  • находить зависимости внутри node_modules;
  • использовать поле exports;
  • работать с ESM-пакетами;
  • подключать CommonJS-библиотеки;
  • корректно обрабатывать браузерные версии пакетов;
  • формировать полноценный граф зависимостей для дальнейшей оптимизации Rollup.