Привет, друзья! Вам когда-нибудь хотелось узнать, как работают сборщики модулей (module bundlers) JavaScript типа Webpack или Parcel, что называется, под капотом. Если хотелось, тогда эта статья для вас. В данном туториале мы разработаем простой сборщик модулей на Node.js, который будет компилировать все модули приложения в один минифицированный файл с кодом, соответствующим стандарту ES5. Источник вдохновения. Репозиторий с кодом. Как правило, сборщики компилируют небольшие части кода в один большой файл, который доставляется в браузер для выполнения. Эти небольшие части кода — просто JS-файлы, зависимости между которыми представлены в виде системы модулей. Сборщики используют концепцию входного (entry) файла. Вместо того, чтобы подключать несколько скриптов в разметке для их выполнения браузером, мы сообщаем сборщику о том, какой файл является входной точкой (entrypoint) приложения, т.е. о том, какой файл является главным или основным файлом приложения. В этом файле происходит формирование (bootstrap) кода всего приложения. Сборщик начинает с основного файла и пытается определить, от каких файлов он зависит. Затем он пытается "понять", на каких файлах основаны зависимые файлы. Так продолжается до тех пор, пока сборщик не вычислит все модули приложения, а также связи между ними. Данный процесс называется формированием графа зависимостей (dependency graph). Мы создадим граф зависимостей и используем его для упаковки (package) всех модулей в одну сборку (bundle). Обратите внимание: наш сборщик будет очень простым. Это означает, что при его разработке мы оставим без внимания такие важные вещи, как предотвращение циклических зависимостей (cyclic dependencies), кеширование экспорта модулей, однократный разбор (parsing) каждого модуля и т.д. Для разработки сборщика будут использоваться следующие npm-пакеты: Я не буду останавливаться на том, что такое компиляция кода и из каких этапов состоит данный процесс. Почитать об этом можно здесь и здесь. Создаем директорию, переходим в нее, инициализируем Node.js-проект и устанавливаем зависимости: Определяем команды для выполнения сборки и запуска кода в файле package.json: Как видите, код сборщика будет находиться в файле bundle.js, а сама сборка будет записываться в файл index.js, находящийся в директории build. Создаем файл bundler.config.json с настройками для сборщика: Здесь определяется входная точка (основной файл) приложения (src/main.js) и название директории для сборки (build). Создаем директорию src с тремя файлами: Содержимое этих файлов будет следующим: Логика работы приложения следующая: Обратите внимание: для работы с модулями (их экспорта/импорта) используется синтаксис ESM, который был введен стандартом ECMAScript2015 (ES6). Итак, наша задача состоит в том, чтобы компилировать код модулей из ES6 в ES5 и собрать модули в один минифицированный JS-файл (build/index.js). При этом, разумеется, приложение должно остаться полностью работоспособным. Создаем файл bundle.js. Импортируем пакеты: Определяем "генератор" идентификатора модуля: Определяем функцию для чтения содержимого файла и извлечения его зависимостей: Читаем содержимое файла как строку: Определяем зависимые модули. Это можно сделать посредством поиска в файле ключевого слова import, но такой подход является не слишком надежным. Поэтому используем JS-парсер. Парсеры — это инструменты, которые умеют читать и понимать JS-код. Они генерируют AST. Вот отличный онлайн-инструмент для представления любого JS-кода в виде AST. AST содержит большое количество информации о коде, которая позволяет, в частности, определить, что данный код "пытается сделать". Определяем массив для относительных путей зависимых модулей: Перебираем (traverse) узлы (nodes) AST и определяем зависимые модули. Для этого выполняется поиск инструкций импорта (import statements). При наличии такой инструкции, импортируемое значение добавляется в массив зависимостей: Генерируем уникальный идентификатор модуля: Мы используем ESM и другие возможности JS, которые могут поддерживаться не всеми браузерами. Для обеспечения выполнения кода сборки всеми браузерами транспилируем код модуля с помощью Babel: Настройка presets — это набор правил транспиляции кода. babel-preset-env используется для транспиляции исходного кода (ES6) в код, поддерживаемый большинством браузеров (ES5). Возвращаем всю информацию о модуле: Определяем функцию для построения графа зависимостей: В теле этой функции мы извлекаем зависимости каждого модуля, начиная с основного файла. Затем извлекаем зависимости каждой зависимости. Этот процесс повторяется до тех пор, пока не будут определены все модули приложения и их связи между собой. В результате получаем граф зависимостей. Разбираем основной файл: Для разбора зависимостей каждого модуля используется очередь: Перебираем элементы очереди. Изначально в очереди имеется только один элемент, но в процессе перебора в нее помещаются новые элементы. Цикл заканчивается, когда очередь становится пустой: Возвращаем граф зависимостей — массив всех модулей целевого (target) приложения: Определяем функцию для генерации сборки: Функция принимает граф зависимостей — объект с информацией о каждом модуле приложения. Наша сборка будет содержать единственную автоматически вызываемую функцию (Immediately Invoked Function Expression, IIFE): (function () {})(). Определяем переменную для модулей: Формируем объект, передаваемый в тело IIFE. Обратите внимание: генерируемая строка оборачивается в фигурные скобки ({}), поэтому для каждого модуля добавляется строка в формате key: value: Определяем IIFE. Начинаем с создания функции require: она принимает id модуля и выполняет его поиск в объекте modules. Применяем деструктуризацию массива для извлечения обертки-функции и объекта зависимостей модуля. Код модуля вызывает require() с относительным путем вместо id модуля. Однако функция require ожидает id модуля. Кроме того, 2 модуля могут require() одинаковый относительный путь, но разные модули. Для решения этих задач при запросе модуля создаем новую функцию require для модуля. Эта функция будет специфичной для данного модуля и будет способна преобразовывать относительные пути в id с помощью объекта mapping. mapping — это объект, представляющий связи между относительными путями и id модулей для данного модуля. Наконец, в случае с CommonJS, при запросе модуль может выставлять (expose) значения (делать их доступными для внешнего мира) путем мутирования объекта exports. После модификации кодом модуля объект exports возвращается из функции require. Возвращаем результат: Определяем основную функцию сборщика: Читаем файл с настройками и выполняем несколько проверок: Создаем граф зависимостей, генерируем сборку и минифицируем код: Записываем сборку в соответствующую директорию: Вызываем функцию: Отлично, мы закончили разработку нашего сборщика модулей. Осталось убедиться в его работоспособности. Находясь в корневой директории проекта (nodejs-bundler), выполняем команду yarn build или npm run build: 
 Выполнение данной команды приводит к генерации файла build/index.js следующего содержания: Это минифицированнуая ES5-версия всего кода нашего приложения. Выполняем команду yarn start или npm start: 
 Получаем приветствие "Привет, незнакомец!" в терминале. Передаем имя приветствуемого в терминале — yarn start Игорь или npm start Игорь: 
 Получаем приветствие "Привет, Игорь!". Код сборки работает, как ожидается. Пожалуй, это все, о чем я хотел рассказать вам в данной статье. Надеюсь, вы узнали что-то новое и не зря потратили время. Благодарю за внимание и happy coding!  JavaScript Developer