Федерация модулей Webpack — это потрясающий инструмент, который обеспечивает потрясающую оптимизацию для крупномасштабных интерфейсных приложений. Благодаря способности добавлять новые JS-модули во время выполнения многие назвали его микро-интерфейсом, изменившим правила игры. И я не мог не согласиться.

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

Присоединяйтесь ко мне в коротком путешествии, где я объясню, как максимизировать преимущества совместного использования модулей и предотвратить его возможное негативное влияние на производительность.

Что такое федерация модулей веб-пакета?

Позвольте автору Зака Джексона дать нам краткое описание:

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

Общие модули

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

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

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

Обычно конфигурация приложения React может выглядеть примерно так:

new ModuleFederationPlugin({
  shared: {
    ...deps,
    react: {
      eager: true,
      singleton: true
    },
    'react-dom': {
      eager: true,
      singleton: true
    },
  },
});

Если вы когда-либо искали примеры объединения модулей, вы, вероятно, видели что-то подобное. На первый взгляд, ничего страшного в этом нет. Но бывают случаи, когда что-то подобное может резко увеличить размер вашего пакета. Я смотрю на тебя …deps . Давайте обсудим, почему.

Подумайте дважды, прежде чем делиться зависимостью

Какими зависимостями вы должны поделиться тогда? В идеале все. В идеальной среде ни один модуль не будет загружен дважды. Независимо от того, сколько «приложений» запущено. Дело не в том, чем делиться, а в том, как мы этим делимся.

С некоторыми зависимостями решение простое. Зависимости, подобные react и react-dom, являются основными зависимостями, которые мы должны использовать совместно, поскольку нам нужно от них все.

А вот с другими все не так просто. Давайте использовать @patternfly/react-icons в качестве примера. Если вы не в курсе, Patternfly — это система проектирования пользовательского интерфейса, которая также имеет привязки к библиотеке React. Одна из привязок — библиотека иконок. Он предоставляет очень большую библиотеку значков SVG. На момент написания это было 1731. При распакованном размере 7,8 Мб. Это может быть размер всего внешнего пакета. Большой. Это не значит, что зависимость плохая. Просто у него много модулей. Обычно вы будете использовать лишь несколько SVG. И размер будет всего несколько КБ. Встряхивание дерева гарантирует, что мы избавимся от всех неиспользуемых модулей JS. Или так и будет?

Давайте создадим два супер упрощенных приложения. В одном мы не будем использовать зависимость @patternfly/react-icons, а в другом — будем.

Это исходный код:

import React from 'react'
import { createRoot } from 'react-dom/client';import { Icon } from '@patternfly/react-core'
import { CircleIcon } from '@patternfly/react-icons'

const App = () => {
  return (
    <div>
      <h1>
        React app
      </h1>
      <Icon>
        <CircleIcon />
      </Icon>
    </div>
  )
}
const container = document.getElementById('app');
const root = createRoot(container);
root.render(<App tab="home" />);

Такое приложение должно быть крошечным. Зависимости реагирования составят большую часть окончательного пакета JS. Анализатор пакетов веб-пакетов соглашается. Размер пакета @patternfly/react-icons составляет 3,52 КБ.

Давайте сделаем еще одну сборку. На этот раз мы поделимся зависимостью значков. Мы добавим новый плагин веб-пакета в конфигурацию сборки:

const { container: { ModuleFederationPlugin } } = require('webpack')
...
new ModuleFederationPlugin({
    shared: {
        '@patternfly/react-icons': {
            version: '^5.0.0-prerelease.9'
        }
    }
}),

После того, как мы запустили сборку с этими настройками, вот результат. Имейте в виду, мы вообще не трогали код. У нас по-прежнему есть только один компонент, импортированный из зависимости от значков.

Как видите, в нашем комплекте гораздо больше «штуков». Размер @patternfly/react-icons увеличился с ничтожных 3,52 КБ до 7,13 МБ. Как это возможно?

Совместное использование модулей меняет правила встряхивания деревьев

Webpack и все сборщики модулей обычно довольно хорошо справляются с удалением неиспользуемых JS-модулей. Если бы это было не так, наши приложения имели бы размер node_modules.

Итак, что случилось? Почему простое изменение конфигурации оказывает огромное негативное влияние на наш пакет? Совместное использование модулей должно помочь нам, а не отправить наше приложение в каменный век. Верно?

Ответ довольно прост! Webpack не знает, что вытрясти из дерева из-за зависимости значка. Почему? Представьте себе этот сценарий. У нас есть два приложения. Оба требуют зависимости @patternfly/react-icons. Каждый использует свой значок. Мы решили поделиться пакетом. Мы также каким-то образом убедили Webpack перетрясти отдельные сборки.

Скажем, наши приложения выглядят так:

// Application A
import { CircleIcon } from '@patternfly/react-icons'
// Application B
import { AdIcon }

Больше ничего в этих приложениях нет. Только один значок. Обратите внимание, что сборки являются отдельными с одной и той же конфигурацией общего доступа. Просто напоминание. Мы также каким-то образом убедили Webpack перетрясти отдельные сборки. Webpack работает не так, но нам нужно это предположение, чтобы объяснить причину, по которой встряхивание дерева не работает для общих модулей.

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

Общие модули извлекаются и сохраняются в «кеше», называемом областью общего доступа к веб-пакету. Всякий раз, когда загружается модуль, помеченный для совместного использования, Webpack сначала проверяет кеш и пытается получить модули оттуда. Если он его не найдет или требования к версии не соблюдены, вебпак загрузит модуль из исходной сборки, внедрив в браузер новый скрипт. Теоретически, если все общие зависимости уже загружены в кеш, Webpack не будет загружать новый JS в браузер. Экономия драгоценных ресурсов для наших пользователей.

А теперь представьте, что сборки древовидны. Приложение Aзагружает только CircleIcon. @patternfly/react-icons нет в кеше. Приложение A инициализирует его. Единственный значок, который будет в кэше значков, — это CircleIcon. Остальное дерево потрясло. Прошло некоторое время, и теперь Приложение B готово к загрузке. Он также попытается извлечь ресурсы из кеша. В отличие от приложения А, @patternfly/react-icons уже находится в кеше. Webpack извлекает модуль из кеша и отправляет его в приложение B.

Но AdIcon нигде не найдено в кэшированном модуле. В сборке Приложения A это было потрясено. Только тогда CircleIcon будет там. Если бы мы попытались запустить этот код, мы бы увидели ошибку, подобную этой

Warning: React.createElement: type is invalid -- expected a string (for built-in components) or a class/function (for composite components) but got: undefined.
Check the render method of `App`.
    at App

И вот почему Webpack не может расшатывать общие зависимости. Во время сборки Webpack не знает, какие модули из зависимости будут использоваться во время выполнения. Он не может ничего удалить, потому что не знает, что может потребоваться. И именно поэтому в сборку включены все 1,7 тыс. иконок. Хотя мы использовали только один.

Каково решение?

Привыкайте к абсолютным путям импорта

Мы должны помочь Webpack с расшатыванием дерева. Как мы это делаем? Мы используем пути импорта, которые указывают непосредственно на реальный модуль JS. Не каждая библиотека имеет сборку, поддерживающую это. К счастью, сборка @patternfly/react-icons была настроена для поддержки абсолютных путей импорта.

Webpack использует путь импорта в качестве идентификатора модуля в кеше Webpack.

Изменение пути импорта

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

- import { CircleIcon } from '@patternfly/react-icons'
+ import CircleIcon from '@patternfly/react-icons/dist/dynamic/icons/circle-icon'

Фактический путь зависит от проекта. Для этого типа вывода сборки не существует стандартизации.

Изменение общего доступа к модулю

Поскольку Webpack использует пути импорта в качестве идентификатора кэша, нам также необходимо изменить конфигурацию общего доступа.

new ModuleFederationPlugin({
    shared: {
-       '@patternfly/react-icons': {
+       '@patternfly/react-icons/dist/dynamic/icons/circle-icon': {
            version: '^5.0.0-prerelease.9'
        },

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

Улучшилась ли производительность сборки?

Да. Это результат скорректированной конфигурации и импорта кода.

Размер 13 КБ может немного разочаровать. Но это вызвано тем, что Webpack по-прежнему не может все перетрясти. Но это не означает, что каждая иконка добавит в вашу сборку 13 КБ ресурсов. Первоначальная стоимость всегда немного выше. Но любое другое добавление модуля будет иметь гораздо меньшее влияние. После того, как я добавил второй значок, его размер составил 16 КБ, что и ожидалось.

Таким образом, есть некоторые накладные расходы, но 10 КБ накладных расходов лучше, чем 7 МБ.

Заключение

И вот оно. Пример совместного использования модулей, который не заставит пользователей загружать половину Интернета, когда они смотрят нашу работу.

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

Более серьезная проблема может быть связана с нашими зависимостями. Некоторые из них могут не иметь той сборки, которую мы ищем, чтобы правильно ими поделиться. Если вам интересно, как узнать, имеет ли ваша зависимость правильную сборку, или вам интересно, как это сделали модули Patternfly, следите за обновлениями.

[1] https://ineepse.dev/posts/1173/webpack-5-module-federation-a-game-changer-in-javascript-architecture