Простое должно быть простым: враги импорта

0f68c08663f266368ec49f889a737b3b

За время работы разработчиком, меня постоянно преследовала мысль: «Что с этим миром не так? Почему приходится тратить огромное количество времени на очевидно элементарные вещи?». Чаша наполнялась и ожидаемо переполнилась со временем. Как следствие не смог не излить возмущение…

Начну с очевидного тезиса «Общий код нужно переиспользовать».

Очевидно? Не о чем спорить? Все и так ему следуют?

Все не так радужно, как хотелось бы. Причем палки в колеса на пути повторного использования кода, вставляют авторы популярных инструментов разработки: Gradle, React\NextJS и т.д., вместо того, чтобы служить образцом для подражания. В описанных инструментах можно использовать компоненты\библиотеки\плагины, но самый простой вариант, их авторы упрямо игнорируют.

Попробуем разобрать ситуацию на парочке примеров, объединенных одной задачей. Во всех случаях, было несколько проектов на одном инструментарии в фазе активной разработки. Этим проектам требовался некий общий функционал, который также ожидал усиленной отладки и правки в процессе разработки. Все проекты и общий код живут в моно репозитории.

Дальнейший текст подразумевает некоторый уровень владения описанным инструментарием.

Эпизод первый: Gradle

В одном из проектов мы использовали Gradle, на который перешли с дремучих олдскульных систем сборки. Как водится, проектов было более одного, и понадобилось заметное количество кода для сборки\отладки\публикации продукта.

Сразу возник вопрос: как вынести общую функциональность из файлов сборки build.gradle.kts каждого проекта в отдельный файл common.gradle.kts.

Механизм локальных плагинов, показался подходящим: никаких лишних манипуляций не нужно, просто создать файл с общим кодом. Вроде все как в том же TypeScript, только другой синтаксис:

apply("../common.gradle.kts")

вместо

import coolStuff from '../common.gradle'

…и код из файла common.gradle.kts будет выполнен в текущем файле вместе с остальным… Бинго? Нет.

А давайте прервемся на секунду, и чтобы прочувствовать драматизм ситуации, представим, будто пишем на листе бумаги банальное «Мама мыла раму». Да, руками. Да, простым карандашом. Пишем, слово «Мама»…, а при переходе к следующему слову, карандаш исчезает, и голос свыше объявляет:

— «Глаголы пишем только красным карандашом!».

Очевидный ответ: «Но нас устраивает простой карандаш!»

— «Не спорь, мне виднее! Красным!»

Для ясности: мне это тоже кажется странным.

После простых тестов, приступил к переносу кода в файл common.gradle.kts. Перенес какие-то задачи (tasks), проверил, что все работает. То есть просто перенес код задач в общий файл, без каких либо изменений, и он заработал.

Но это был простой карандаш.

Далее, в одной из задач была такая строка:

val sourcesMain = sourceSets.main.get()

Но при переезде в плагин, она огорошила заявлением:

«Unresolved reference: sourceSets».

Наверное, плагин, который подключен посредством метода apply к проекту, не догадывается, что он подключен к проекту… Ну ладно, мы не гордые, «подскажем»:

val sourcesMain = project.sourceSets.main.get()

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

Выдохнул, загуглил красный карандаш правильный вариант:

val sourcesMain = project.the()["main"]

Действительно, все логично! Как только сразу не догадался?

Следующей проблемной конструкцией было:

val paths = configurations.runtimeClasspath.get().map{ ... }

Абсолютно естественно runtimeClasspath не нашелся в configurations, зато в sourcesMain из предыдущего примера — легко:

val paths = sourcesMain.runtimeClasspath.map{ ... }

Справедливости ради, стоит отметить, что такой вариант работает и в build.gradle.kts.

Перешел к следующей неприемлемой для плагинов конструкции…

Разумеется, как у всего в этом мире, у этой трансформации есть свои причины. Так же как и у нескольких других изменений, которые пришлось сделать. И у тех проблем с переносом кода, которые пока не удалось решить. Вот только никак не получается придумать вразумительную причину, по которой разработчики Gradle, не снизошли до банального импорта файлов. Чтобы просто работало. Одним карандашом. Без изменений правил и контекста.

Ну, разве что: «По мелочам не размениваемся, если общий код, то пошли гуглить и переписывать!»

Эпизод второй: React + NextJS

Для свежего стартапа GeekLoad, решено было сделать сайт и приложение с web интерфейсом на общей компонентной базе. Чтобы идентично выглядело. Чтобы меньше совокупной кодовой базы.

Фронт сайта решили оформить в цветах NextJS 13. Ибо SEO без SSR хромает.

UX приложения напрашивался в виде чистого React 18, из-за нежелания тянуть с собой node.js.

Задумано было, чтобы в одном каталоге рядом лежало 3 подкаталога: web-ux, site-front и r-lib.

R-lib, соответственно, это набор компонентов с общим стилем и функционалом двух проектов.

Начал с сайта: попробовал просто импортировать компоненты по относительному пути:

import Box from '../r-lib/Box'

но не тут то было:

…/r-lib/Box.tsx

Module parse failed: Unexpected token (4:7)

You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders

Как так? Компонент был предварительно отлажен в составе тестового проекта и точно был синтаксически корректен.

Какой loader?! А дело было не в загрузчиках. Внезапно.

Оказывается, на простой импорт из каталога вне проекта, нужно особое ЭКСПЕРИМЕНТАЛЬНОЕ одобрение в файле next.config.js:

module.exports = {
    experimental: {
        externalDir: true
    }
}

На этом адаптация проекта NextJS может быть закончена. Не сказать, чтобы это было проблемой, но зачем это ограничение, которое приходится обходить?

Если не нравятся относительные пути, да еще с разным уровнем вложенности в импортах, можно зайти с другой стороны: подключить внешний каталог с общими кастомными компонентами в package.json проекта site-front:

"dependencies": {
    "r-lib": "file:../r-lib"
}

В результате каталог node_modules проекта, пополнится ссылкой на каталог с компонентами, а импорт выше можно переписать так:

import Box from 'r-lib/Box'

Но, придется добавить в каталог компонентов свой package.json и следить еще за актуальностью его зависимостей. В итоге остановился на этом варианте.

Теперь посмотрим, как подружить второй проект (который на чистом React) с r-lib:

Для начала сразу подключим r-lib в package.json и импортируем компонент как описано выше. Результат:

ERROR in …/r-lib/Action.tsx 12:20

Module parse failed: Unexpected token (12:20)

File was processed with these loaders:

 — ./node_modules/@pmmmwh/react-refresh-webpack-plugin/loader/index.js

 — ./node_modules/source-map-loader/dist/cjs.js

You may need an additional loader to handle the result of these loaders.

А что вы хотели? Опять загрузчик-незагрузчик.

И, наверное, вы вспомнили, что где-то выше, упоминалось решение и оно подойдет? Нет.

Может здесь заработает вариант с простым импортом по относительному пути без модификации package.json?

ERROR in ./src/index.tsx 6:0–37

Module not found: Error: You attempted to import …/…/r-lib/Box which falls outside of the project src/ directory. Relative imports outside of src/ are not supported.

You can either move it inside src/, or add a symlink to it from project’s node_modules/

На простые варианты запрет. «Not supported» — теперь так называют искусственное ограничение.

Гуглим: оказывается то, что Next.js делает под капотом опцией externalDir, в простом React делается как то так:

«Нужно в webpack настроить транспиляцию TypeScript модулей не только основного каталога исходников проекта, но и дополнительно подключенных каталогов. Для этого в файле webpack.config.js нужно добавить…»

Изумительно. Особенно в свете того, что для сокращения количества конфигурационного кода, был использован create-react-app в котором нет файла webpack.config.js.

Снова гуглим: для конфигурации webpack в нашем случае советуют пару библиотек react-app-rewired и customize-cra.

Вы еще не забыли, что задача — просто импорт файлов из каталога вне проекта? Нет?

Тогда добавляем в package.json рекомендуемые библиотеки:

 "devDependencies": {
    "customize-cra": "1.0.0",
    "react-app-rewired": "2.2.1"
  }

…и меняем в секции Scripts этого файла react-scripts на react-app-rewired.

Далее, создаем файл config-overrides.js в корне проекта:

const path = require('path');
const {override, babelInclude} = require('customize-cra');

module.exports = function (config, env) {
    return Object.assign(
        config,
        override(
            babelInclude([
                path.resolve('src'),
                path.resolve('../r-lib')
            ])
        )(config, env)
    );
};

Поздравляю! Мы реализовали подобие опции externalDir из NextJS.

Пробуем:

Warning: Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons:

1. You might have mismatching versions of React and the renderer (such as React DOM)

2. You might be breaking the Rules of Hooks

3. You might have more than one copy of React in the same app

See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem.

Уже не вызывает удивления?

Так как с хуками все в порядке, проблема ушла в область двух копий React, которые возникли из-за того, что и сам проект и библиотека r-lib имеет зависимость от React.

Еще раз гуглим:

«Нужно явно указать alias, на который будут ссылаться все упоминания библиотеки»

Alias? Легко, только добавим еще одну библиотеку в package.json:

 "devDependencies": {
    "customize-cra": "1.0.0",
    "react-app-rewired": "2.2.1",
    "react-app-rewire-alias": "1.1.7"
  }

… и перепишем config-overrides.js следующим образом:

const path = require('path');
const {override, babelInclude} = require('customize-cra');
const {alias} = require('react-app-rewire-alias')

module.exports = function (config, env) {

    return Object.assign(
        config,
        override(
            babelInclude([
                path.resolve('src'),
                path.resolve('../r-lib')
            ]),
            alias({
                /* Fix several clones of React (https://reactjs.org/warnings/invalid-hook-call-warning.html) */
                'react': 'node_modules/react'
            })
        )(config, env)
    );
};

Обратите внимание, что в случае наличия одинаковых зависимостей у проекта и локальной библиотеки вне каталога проекта, подобная ситуация может приключиться не только с React. Тогда просто добавляйте alias для этой зависимости, подобно строке 16.

И вот в этот момент, наконец все заработало:

Два проекта, один — NextJS, другой — чистый React стали использовать общую локальную библиотеку кастомных компонентов.

На всякий случай еще раз озвучу суть:

Весь текст выше, должен был уместиться в строке вроде этой:

import myExternalComponent from '…/myExternalComponent'

Все. Остальное, включая потраченное время и нервы — на совести странных людей, считающий, что локальных библиотек компонентов не существует.

P.S.

В итоге я прихожу к мысли, что стоит только выйти за пределы уютного мира «Hello word», как сразу принцип «Простое должно быть простым, сложное — возможным» нарушается направо и налево.

Наверное, простое делать простым… сложно?

© Habrahabr.ru