January 11, 2022

Абсолютные импорты в JavaScript

Так уж сложилось, что в JavaScript импорты относительные. Но в любом более-менее крупном проекте это быстро превращается в ад (relative imports hell).

Существует множество способов сделать абсолютные импорты - это зависит от окружения (фронтенд или бекенд), используете ли вы бандлеры, есть ли у вас TypeScript и т.д.

Самое простое, если вы используете webpack для сборки. У него есть опция resolve.alias которая позволяет это сделать. У других бандлеров есть аналогичные опции. Есть бандлеры, которые заботятся о вас чуть больше чем другие. Например, Parcel сразу поддерживает пути с / и ~.

Веселье начнётся когда вы захотите абсолютные пути в require на Node.js. Вот, например, неполный список (последний раз обновлялся в 2018-ом году) возможных вариантов решения: использовать какой-нибудь npm пакет, который хакает require, похакать require самому, создавать вручную симлинки, переопределять переменную окружения NODE_PATH и так далее. У всех этих решений есть недостатки: либо они плохо поддерживается другими инструментами (например, большинство из этих способов не будут нормально работать в IDE, потребуются дополнительные приседания при запуске тестов и т.д.) либо требуют ручной работы. Но в комментариях веселье продолжается до сих пор и там можно найти неплохое решение, которым мы в своём проекте пользовались пару лет - это link протокол в yarn. Выглядеть это будет как-то так:

{ "dependencies": { "@backend": "link:apps/backend/src", "@frontend": "link:apps/frontend/src" } }

Смысл в том, что вы прямо в package.json задаёте маппинг в виде обычных зависимостей, а дальше yarn install создаёт ссылки на указанные директории в node_modules. Решение кросс платформенное и работает надёжно. Работает с первой версии yarn. Поддерживается всеми инструментами, потому что для всех это выглядит как импорт обычного пакета из node_modules и не требует никаких дополнительных настроек. К тому же с таким решением вам не нужно настраивать псевдонимы в своём сборщике. То есть это и более универсальное решение. Единственный минус - нужно использовать yarn для управления зависимостями. Если можете, то используйте этот способ - он почти идеален.

Какой-бы способ вы не выбрали, но если вы пишите на TypeScript вам потребуется ещё сделать настройку и для него. Тут всё просто, нужно настроить свойство paths в tsconfig.json. После этого tsc не только перестаёт ругаться на неизвестные модули, но и начинают работать все фишки с автоматическими импортами в IDE. Получается, что маппинг дублируется в двух местах, но с этим уже ничего поделать нельзя. Хотя подождите, веселье ещё не закончилось.

Теперь представим, что мы хотим наш бекенд на Node.js писать на TypeScript и продолжать использовать абсолютные импорты. Если использовать вариант с симлинками, то возникает новая проблема. Так как теперь исходные файлы на ts нужно скомпилировать в js (с помощью tsc или babel), чтобы их смогла использовать Node.js, то они окажутся в какой-то другой папке (например, в dist) и наш маппинг для yarn (или какой там способ вы выбрали) теперь указывает не туда куда надо. В нашем случае мы использовали хак в Dockerfile - вместо папки src подключали папку dist в итоговом образе. Решение простое, но если про него не знать, то можно незабываемо провести время пытаясь понять, как же всё это работает.

Но можно сделать по другому - если уж всё равно пошли по пути компиляции исходных файлов для Node.js то можно пойти до конца и собирать полноценный бандл с помощью webpack. А для разработки и тестирования использовать ts-node. И тут нас ждёт неожиданный поворот - решение с симлинками в node_modules теперь не работает. Потому что собрать бандл для Node.js не тоже самое что собрать бандл для браузера. В случае с Node.js в бандл собирается только исходный код приложения без зависимостей из node_modules. Потому что эти модули могут быть бинарными или они могут в рантайме читать что-то из файловой системы рядом с собой и т.д. В этом случае нам придется пойти по пути задания псевдонимов в конфиге webpack. И тут нам поможет пакет tsconfig-paths-webpack-plugin, который как раз будет читать пути из tsconfig.json и добавлять их как псевдонимы в webpack. Если вам кажется что для такой простой задачи как прочитать json файл вам не нужен дополнительный npm пакет, то не забывайте что TypeScript поддерживает наследование конфигов. А в режиме разработки с ts-node можно использовать пакет tsconfig-paths, на основе которого работает вышеупомянутый webpack плагин. В результате мы получим один источник правды для абсолютных импортов - tsconfig.json. Но и ещё больше работы. Потому что теперь, без симлинок в node_modules, у нас отвалился резолвинг модулей в Jest и Cypress! Как же всё было просто с симлинками и маленьким хаком в Dockerfile. Починить Jest не сложно: нужно задать свойство moduleNameMapper. О, вот и ещё одно место куда надо продублировать маппинг. Но если вы используете ts-jest, то у них есть соответствующий хелпер, а тут ребята уже не стали заморачиваться и предлагают брать paths напрямую из tsconfig.json. С Cypress не совсем понятно почему сломалось ведь они вроде бы тоже используют tsconfig-paths и я даже видел радостные посты людей у которых работали абсолютные импорты в тестах написанных на TypeScript, но в моём случае они так и не заработали. Поэтому я воспользовался пакетом с прекрасным названием @cypress/webpack-batteries-included-preprocessor. Но правда и он заработал не с первого раза. Этот препроцессор под капотом так же использует tsconfig-paths-webpack-plugin, но с дефолтовыми настройками и не даёт их менять. А по умолчанию этот плагин считает, что у вас весь проект на TypeScript, а если нет (как в нашем случае, часть кода всё ещё на js), то нужно поменять опцию extensions. Поэтому для этого препроцессора пришлось ещё написать свою обёртку до тех пор пока не перепишем весь код на TypeScript.

И на последок про светлое (но это не точно) будущее. Ребята в веб стандартах тоже не сидят сложа руки и уже с 2018 года разрабатывают стандарт Import Maps, который позволит управлять процессом резолвинга модулей в браузерах, а это позволит делать абсолютные импорты нативными средствами. В Deno поддержка есть из коробки. В Node.js есть открытая задача. В самом репозитории Import Maps можно посмотреть список инструментов, которые уже поддерживают эту спеку. Осталось дождаться пока он будет всеми принят и поддержан.