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

О проблеме

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

Когда возникает циклическая зависимость (рекурсивная зависимость) при сборке, сборщик (будь то 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 не означает что вам не нужно избавляться от рекурсивных зависимостей, это возможность избежать неполной инициализации, пока вы исправляете рекурсивные зависимости.

Комментарии (3)


  1. flancer
    23.10.2024 04:46

    Смутился из-за названия "рекурсивные зависимости". Для меня, в базисе, рекурсия - самовызов функции. Вполне себе легитимный с точки зрения программирования подход. Разумеется, для этого у функции должно быть условие выхода из самовызова.

    Рекурсивный подход вполне себе может применяться и для зависимостей на фронте (опять же, при наличии условия выхода из цепочки вызовов):

    // ./one.mjs
    import two from './two.mjs';
    
    export default function one(x) {
        console.log(x);
        return (x > 0) ? two(x - 1) : 0;
    }
    // ./two.mjs
    import one from './one.mjs';
    
    export default function two(x) {
        console.log(x);
        return (x > 0) ? one(x - 1) : 0;
    }
    // ./index.html
    <script type="module">
        import one from './one.mjs';
    
        one(4);
    </script>

    Пример синтетический, но рабочий:

    результат выполнения рекурсии
    результат выполнения рекурсии

    Когда возникает циклическая зависимость (рекурсивная зависимость) при сборке

    IMHO, "циклическая зависимость" лучше отображает суть того, о чём пишет автор.


    1. ko22012 Автор
      23.10.2024 04:46

      я вас удивлю, но даже мои примеры с циклическими зависимостями корректно отрабатывает сборщик. Но когда у нас более сложные случаи, тогда сборщик не может обработать корректно импорт. Например кейс с enum при использовании ts-jest: https://github.com/kulshekhar/ts-jest/issues/281

      Другие рабочие кейсы к сожалению не могу найти с ходу с данной проблемой.


  1. ALapinskas
    23.10.2024 04:46

    Разрешение рекурсивных зависимостей хорошее дело для стабильности и чистоты кода.

    Циклические зависимости усложняют работу если вы работаете, например, с фремворком, не понятно, где начало, где конец и откуда плясать.
    Решением может быть применение принципов SOLID при построении приложения.