Alfa Brain

Webpack Module Federation в NextJS

Алексей ВечкановАлексей Вечканов   

Мне тут понадобилось подключить в приложение NextJS и я столкнулся с большими проблемами. В этой статье я постараюсь коротко и емко на примере показать как подключить Webpack Module Federation (Далее MF) компоненты на ваше приложение NextJS (на клиентской стороне).

Проблема

До недавнего времени, официальным решением внедрения MF модулей в NextJS была библиотека @module-federation/nextjs-mf.

Плагин @module-federation/nextjs-mf — это специальный модуль, который упрощает интеграцию Webpack Module Federation в приложения на Next.js. Он создан командой, которая разрабатывает сам Module Federation, и решает множество проблем, связанных с SSR, shared-зависимостями и конфигурацией Next.js.

Next.js по умолчанию не поддерживает Module Federation “из коробки”, особенно в части SSR (Server Side Rendering). Этот плагин:

- Упрощает настройку Webpack Module Federation в Next.js
- Добавляет поддержку SSR и динамической загрузки remote компонентов
- Обеспечивает корректную работу shared зависимостей (например, React)
- Позволяет использовать dynamic() из next/dynamic с federation-компонентами
- Работает как на клиенте, так и на сервере
- Позволяет делать federated middleware (например, отданные с getServerSideProps)
- Позволяет делать hot-reload remote модулей в dev-среде

Но вот незадача, она deprecated.

Команда разрабатывающая решение MF более не развивает плагин и вскоре прекратит поддержку.

Стоит отметить что в последнем сообщении Zack Jackson (ментейнер MF) отмечает:

- Поддержка Module Federation в Next.jsвероятна (~80%), но не гарантирована.
- Вектор развития положительный, технические и организационные барьеры уменьшаются.
- Плагин nextjs-mf получит частичное восстановление и поддержку App Router в ближайшем будущем.

Надежда умирает последней...

Решение

Но не стоит отчаиваться. NextJS собирается Webpack, а значит под капотом все те модули, а значит можно просто использовать плагин ModuleFederationPlugin из самого webpack.

Далее в статье мы создадим Remote приложение (без NextJS) для раздачи виджетов, и Host на основе NextJS и подключим к нему модули WMF из Remote

Я подготовил два репозитория:

1) Host (приложение NextJS) в который будем подключать приложение (https://github.com/Hydrock/wmf-nextjs)

2) Remote - отсюда будем отдавать компонент (https://github.com/Hydrock/wmf-remote)

Скачайте оба репозитория в удобную для вас директорию и запустите (Смотрите инструкцию).

В NextJS приложении на http://localhost:3000 должен подружаться удаленный компонент из Remote приложения.


Теперь посмотрим как это сделано.

Разбор

В приложении wmf-nextjs откройте конфиг next.config.ts

1const { container } = require('webpack');
2const { ModuleFederationPlugin } = container;
3import type { NextConfig } from "next";
4
5const nextConfig: NextConfig = {
6  webpack(config, { isServer }) {
7    if (!isServer) {
8      console.log('✅ Webpack client config is used');
9
10      config.plugins.push(
11        new ModuleFederationPlugin({})
12      );
13    }
14
15    return config;
16  },
17};
18
19module.exports = nextConfig;
20

Тут мы просто подключили плагин ModuleFederationPlugin из Webpack. Не указывали remote и exposes параметры - все это будет делаться динамически в компоненте. Мы же хотим в будущем загружать модули из разных источников.

Файл components/RemoteWrapper.tsx - обертка над загружаемым компонентом. Обратите внимание на 'use client'- мы рендерим/загружаем модуль только на клиенте/в браузере.

Файл RemoteWidget.tsx - сам загрузчик удаленного модуля. В идеале, конечно, все это нужно оформить в отдельную библиотеку загрузчик с обработкой ошибок, но я специально этого не делал для простоты примера.

1// components/RemoteWidget.tsx
2'use client';
3
4import React, { useEffect, useState } from 'react';
5
6function injectScript(url: string, scope: string): Promise<void> {
7  return new Promise((resolve, reject) => {
8    // eslint-disable-next-line
9    // @ts-ignore
10    if (window[scope]) return resolve(); // уже есть
11
12    const existingScript = document.querySelector(`script[src="${url}"]`);
13    if (existingScript) return resolve(); // уже загружен
14
15    const script = document.createElement('script');
16    script.src = url;
17    script.type = 'text/javascript';
18    script.async = true;
19
20    script.onload = () => {
21      const checkInterval = setInterval(() => {
22        // eslint-disable-next-line
23        // @ts-ignore
24        if (window[scope]) {
25          clearInterval(checkInterval);
26          resolve();
27        }
28      }, 20);
29
30      // если через 3 сек не появился контейнер — ошибка
31      setTimeout(() => {
32        clearInterval(checkInterval);
33        reject(new Error(`🛑 ${scope} не появился в window после загрузки`));
34      }, 3000);
35    };
36
37    script.onerror = () => reject(new Error(`❌ Ошибка загрузки ${url}`));
38    document.head.appendChild(script);
39  });
40}
41
42const RemoteWidget = () => {
43  const [Comp, setComp] = useState<React.ComponentType | null>(null);
44
45  // INFO: при первом вызове useEffect container равен undefined
46  // но при втором он успевает инициализироваться
47  // но вот что заставляет запустить useEffect второй раз - не знаю
48  useEffect(() => {
49    const load = async () => {
50      const remoteUrl = 'http://localhost:8082/remoteEntry.js';
51      const scope = 'remoteApp';
52      const module = './RemoteComponent';
53
54      await injectScript(remoteUrl, scope);
55
56      // @ts-ignore — Webpack runtime
57      await __webpack_init_sharing__('default');
58
59      // @ts-ignore
60      const container = window[scope];
61      if (!container) throw new Error(`Remote container ${scope} не найден в window`);
62
63      // 🔧 если нет shared, передай пустой объект
64      // eslint-disable-next-line
65      // @ts-ignore
66      await container.init(typeof __webpack_share_scopes__ !== 'undefined'
67        // eslint-disable-next-line
68        // @ts-ignore
69        ? __webpack_share_scopes__.default
70        : {}
71      );
72
73      const factory = await container.get(module);
74      const Module = factory();
75
76      setComp(() => Module.default || Module);
77    };
78
79    load().catch(console.error);
80  }, []);
81
82  if (!Comp) return <div>Загрузка виджета...</div>;
83  return <Comp />;
84};
85
86export default RemoteWidget;

В этом загрузчике есть баг, почему то вставка скрипта remoteEntry.js из удаленного модуля, и последующая инициализация  scope происходит позже, чем webpack попытается его использовать - поэтому в консоль выподает ошибка. Но, при повторном рендере компонента все работает хорошо. Я надеюсь это починить в коде и исправить статью, до того как вы это прочитаете. В любом случае, вариант рабочий.

В коде есть функция injectScript. Его задача, добавить тег script на страницу с remoteEntry.js файлом из удаленного приложения. Когда этот файл загружается, он "сообщает" webpack-у какие удаленные модули есть в наличии и как их загрузить.

Когда скрипт загружен, срабатывает событие script.onload - запускается интервал и в нем проверяется, что scope инициализирован на объекте window (window[scope]). Все таки скрипту, нужно некоторое время для инициализации. После этого созданный выше промис резолвится.

В компоненте RemoteWidget мы ждем когда scope будет точно инициализирован - await injectScript(remoteUrl, scope);

Далее четь разберемся подробнее.

Функция __webpack_init_sharing__ — это внутренняя часть Webpack Module Federation Runtime, и она играет ключевую роль в том, как работает механизм shared (разделяемых) зависимостей между host и remote.

Когда вы вызываете await __webpack_init_sharing__('default'); вы инициализируете share scope с именем 'default', где Webpack:

- создаёт или подключается к глобальному контейнеру зависимостей (обычно __webpack_share_scopes__)
- определяет, какие модули доступны как shared
- обеспечивает, чтобы singleton модули (например, react) были реально одинаковыми
- запускает механизм сравнения версий, если указаны requiredVersion и strictVersion

Технически…

В примере мы шарим (remote приложение):

1shared: {
2    react: {
3        eager: true,
4        singleton: true,
5    },
6}

__webpack_init_sharing__('default') создаёт глобальный скоуп (если его ещё нет)

туда помещается react как singleton

при загрузке remote, его container.init() синхронизируется с этим скоупом

Это ключевой механизм, который позволяет host и remote использовать одну и ту же копию React, и избежать ошибок типа:Invalid hook calluseContext(null)Cannot read properties of undefined (reading 'useLayoutEffect')

Далее, в нашем коде:

1const container = window[scope];
2
3await container.init(typeof __webpack_share_scopes__ !== 'undefined'
4  ? __webpack_share_scopes__.default
5  : {}
6);

получаем remote-контейнер, зарегистрированный Webpack-ом как self[scope] (например, remoteApp, как у нас в примере)

он должен содержать методы init() и get()

вызываем init, чтобы контейнер подключился к глобальному набору shared-зависимостей (это обязательно, если ты используешь shared)

если ты не вызовешь init(), и в remote объявлены shared, произойдёт ошибка

container.init(...) обязан быть вызван перед get(), иначе remote не сможет синхронизировать зависимости.

1const factory = await container.get(module);
2const Module = factory();

container.get('./RemoteComponent')  - это асинхронный вызов, который:

находит нужный модуль внутри remoteEntry.js

загружает его chunk (если нужно)

возвращает фабрику (factory), то есть функцию, которая создаёт модуль

Важно: модуль не возвращается напрямую, а через factory() — это особенность Webpack runtime

factory() возвращает экспортированное содержимое модуля

это либо объект Module, содержащий default экспорт, либо просто сам компонент

Ну а далее у нас установка модуля/компонента в стейт и последующее его использование.

1setComp(() => Module.default || Module);

Вообщем все сложно... Но мне хотелось немного разъяснить, что тут происходит.

Заключение

Хоть официальная библиотека поддержки MF в NextJS теперь deprecated - использование MF в NextJS все же возможно. Да, только на клиентской стороне, да, SSR придется реализовывать/придумывать самому, но - это реально.

Если вы заходите улучшить пример - создайте Issue в одном из репозиториев или можете сразу делать PR.

Спасибо за внимание!



Поделиться: