Рекурсивные зависимости на фронтенде

Хочу рассказать о том, какие проблемы при разработке создают рекурсивные зависимости на фронте. И рассмотрим способы решения зависимостей и их обнаружение.

О проблеме

Рекурсивные зависимости на фронтенде могут возникать если модули ссылаются друг на друга напрямую или косвенно.

Когда возникает циклическая зависимость (рекурсивная зависимость) при сборке, сборщик (будь то Webpack, Vite или любой другой инструмент) не будет бесконечно выполнять сборку. Вместо этого он попытается разрешить зависимости и, столкнувшись с циклом, может завершить процесс сборки с ошибкой или отложить некоторые зависимости, что приведет к неполной загрузке модулей.

Как это проявляется? Тесты на jest могут падать с ошибкой, что какая-та переменная не определена. Во время сборки проекта (но не стоит надеяться на сборщик, т.к. он не всегда прерывает сборку) или у пользователя на сайте что-то не будет грузится корректно или выполняться.

Про микрофронты

Микрофронтенды могут перестать грузится корректно.

Вообще с микрофронтендами нужно быть осторожными, если shared-модули отличаются версиями, может быть такая ситуация, что у нас что-то не работает в приложении. Поэтому нужно следить за актуальными версиями микрофронтов (я решал эту проблему с помощью nx.dev, это инструмент со своим графов зависимостей и CI/CD настраивал таким образом, чтобы соответствующие микрофронты обновлялись если эти изменения затрагивают их).

Решить проблему рекурсивных зависимостей микрофронтов можно изменив сам подход в разработке. Можно общий код выносить в «library», тогда может и уберется зависимость между микрофронтами.

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

Да и сами авторы nx.dev советуют использовать динамические импорты при создании микрофронтов.

Прямая рекурсивная зависимость

// A.ts
import { B } from './B';
export const A = () => B();

// B.ts
import { A } from './A';
export const B = () => A();

В данном примере у нас есть файл A, который импортирует файл B, а файл B импортирует A. Возникает рекурсия, которая не имеет конца.

Но если вдруг вы не воспользуетесь в обоих файлах зависимостями, то сборщик (будь то webpack или vite) просто не будут эту зависимость включать в конечную сборку. И проблемы с зависимостью не будет. Это просто предупреждение, что потенциально зависимость есть, но возникнет она когда вы вызываете то, что импортируете. Если более сложным языком, то это tree-shaking.

Косвенная рекурсивная зависимость

// A.ts
import { B } from './B';
export const A = () => B();

// B.ts
import { C } from './C';
export const B = () => C();

// C.ts
import { D } from './D';
export const C = () => D();

// D.ts
import { A } from './A';
export const D = () => A();

Как схематически выглядит зависимость

Как схематически выглядит зависимость

Пути решения

  1. Динамический импорт загружает модули по запросу, и если рекурсия не вызывается до этого момента, можно избежать проблем на этапе загрузки.

// A.ts
export const A = () => {
  import('./B').then(({ B }) => B());
};

// B.ts
export const B = () => {
  import('./A').then(({ A }) => A());
};

Здесь модули A и B не будут загружаться одновременно. Они будут инициализироваться только тогда, когда до них «дойдёт» выполнение.

  1. Вынести общий код в модуль, который будет использоваться между двумя этими файлами, но не будет ссылки на них.

// common.ts
export const valueFromCommon = "Value from common module";

export const commonFunction = () => {
  console.log("This is a common function.");
};

// A.ts
import { commonFunction, valueFromCommon } from './common';

export const A = () => {
  console.log("Calling common function from A");
  commonFunction(); // Вызов функции из общего модуля
  return valueFromCommon; // Используем значение из общего модуля
};

// B.ts
import { commonFunction, valueFromCommon } from './common';

export const B = () => {
  console.log("Calling common function from B");
  commonFunction(); // Вызов функции из общего модуля
  return valueFromCommon; // Используем значение из общего модуля
};
  1. Реорганизация структуры проекта

    Частая ошибка встречаемая в проекте:

// components/index.ts
export * from './button'
export * from './style'

// components/button/index.ts:
import { styleColors } from '@/components'
const redColor = styleColors.red

// components/style/index.ts
export const styleColors = {
   red: '#fee';
}

Как это выглядит

Как это выглядит

В этом примере происходит следующее:

  • components/index.ts экспортирует все из button и style.

  • components/button/index.ts импортирует styleColors из components/index.ts, что означает, что он фактически ссылается на styleColors из style/index.ts.

  • components/style/index.ts экспортирует styleColors, но в момент, когда components/button/index.ts пытается получить доступ к styleColors, если index.ts еще не завершил свою инициализацию, это может привести к тому, что styleColors окажется undefined.

Решение: в файле components/button/index.ts: использовать или импорт сразу требуемого модуля import { styleColors } from '@/components/style', либо относительный (но он лучше применим на уровне одного модуля).

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

// components/index.ts
export * from './button'

// components/button/style.css
   ...

// components/button/Button.tsx
import style './style.css';

// components/button/index.ts
export * from './Button.tsx';
export * from './style.css'

В файле Button.tsx мы импортируем style.css относительно, благодаря этому у нас нет рекурсии. Но если мы бы сделали так:

// components/index.ts
export * from './button'

// components/button/style.css
   ...

// components/button/Button.tsx
import style '@/components';

// components/button/index.ts
export * from './Button.tsx';
export * from './style.css'

То файл Button.tsx был с рекурсией, т.к. components/index.ts содержит button, а Button.tsx обращается обратно в файл components/index.ts

Плюс в этом примере нет необходимости файл style.css export наружу осуществлять, лучше изолировать логику и удалить этот export из файла button/index.ts

Инструменты

  1. В своей время использовал eslint-plugin-import для обнаружения рекурсивных зависимостей, но будьте осторожны, т.к. на крупных проектах может быть долго запускаться проверка. Можно поиграться с кэшированием, а также можно использовать eslint_d, вместо стандартного eslint для быстрого запуска проверок.

    При подключении eslint-plugin-import может быть такое, что у вас не будут обнаруживаться (хотя вы воссоздали рекурсивную зависимость) рекурсивные зависимости в проекте, это означает, что скорее всего у вас некорректно настроена конфигурация tsconfig в eslint.

  2. Webpack с плагином circular-dependency-plugin, но у него есть проблемы с анализом сложный рекурсивных зависимостей. Поэтому полагаться на него лучше не стоит. А также стоит предусмотреть запуск этого плагина опционально, чтобы не влиять на основной этап сборки. Обычно я такие проверки запускают при создании merge request.

Выводы

Разрешение рекурсивных зависимостей хорошее дело для стабильности и чистоты кода. Проводил тестирование сборки проекта при большом кол-ве рекурсивных зависимостей и без них, разницы не заметил, т.к. сборщики умеют хорошо эти проблемы «обходить».

Про микрофронты еще немного

Еще кстати у нас на проекте для сборки SWC compiler, он был настроен таким образом, что он хорошо разрешал рекурсивные зависимости и проблем не было в клиентском коде. Затем я перевел проект на babel, и приложение началось ломаться из-за рекурсивных зависимостей. Пришлось в package.json файлах микрофронтендов указывать sideEffects

Свойство sideEffects в package.json с установкой true указывает, что в ваших модулях могут быть побочные эффекты. Это поведение может влиять на разрешение циклических зависимостей в Webpack.

В случае циклических зависимостей, когда два модуля (например, A и B) ссылаются друг на друга, наличие sideEffects: true может предотвратить проблемы, связанные с частичной инициализацией. Если один из модулей имеет побочные эффекты, Webpack будет сохранять его в сборке, что может помочь избежать ситуации, когда один из модулей инициализируется неполностью.

Но использование sideEffects: true не означает что вам не нужно избавляться от рекурсивных зависимостей, это возможность избежать неполной инициализации, пока вы исправляете рекурсивные зависимости.

© Habrahabr.ru