Вдали от Webpack, или Как мы в Dodo микрофронтенды на Vite переводили
В первой статье мы рассказывали, как вообще в Dodo появились микрофронтенды. Это продолжение.
В общем, мы спокойно сидели и пилили новый проект на нашем устоявшемся стеке React + TypeScript + Webpack + SingleSPA + SystemJS + Jest. Пока одним прекрасным утром не пришёл техлид и не сказал: «Чуваки, а давайте затащим Vitest!».
Чем же хорош Vitest
Во-первых, мы много слышали и читали о том, что Vitest быстрее Jest. Но проверить это на реальном проекте не было возможности.
Во-вторых, перейти на него можно почти «бесплатно» — привычный синтаксис, во многом повторяющий Jest, только с небольшими отличиями. Например, функции describe
, it
нужно импортировать из пакета vitest
, а не брать из глобальной области видимости. Ещё один пример: jest.fn()
, jest.spyOn()
заменяются на vi.fn()
, vi.spyOn()
. При этом vi
также нужно импортировать из vitest
.
Но это поведение можно изменить одной опцией globals: true
. Также мы решили, что явный импорт вспомогательных функций — более очевидный и правильный подход.
В любом случае, так как проект совсем новый и тестов в нём почти не было (10 штук), переезд бы нам ничего не стоил. А если что-то пойдёт не так, переехать обратно на Jest не составит особого труда
Переехали на Vitest? А теперь давайте перейдём на Vite!
Переезд на Vitest дался очень просто, буквально 15–20 минут. После того как мы закончили, прозвучала фраза, которая будто витала в воздухе. Глядя на созданный для Vitest vite.config.ts
, кто-то из нас сказал: «А может, тогда и на Vite переедем?»
Аргументы «за»:
esbuild, нативные модули в dev-режиме, супербыстрая скорость сборки.
Наш архитектор уже давно спрашивает, когда мы, наконец, переедем с Webpack на что-то более быстрое.
Мы хотели экспериментов!
Аргументов «против» у нас не имелось.
Ready Steady Go!
Как ни странно, при переезде с Webpack проблем почти не было. Конфиг Vite интуитивно понятный и простой, при этом общий конфиг сократился сократился со 120 до 50 строк (вместе с Vitest).
Здесь можно увидеть репозиторий, подготовленный в качестве демонстрации перехода на Vite. Каждый шаг перехода, описанный в данной статье, выделен в отдельный коммит.
Итоговый конфиг стал выглядеть примерно так:
import react from '@vitejs/plugin-react';
import process from 'node:process';
import path from 'path';
import { defineConfig, PluginOption } from 'vite';
import checker from 'vite-plugin-checker';
import tsconfigPaths from 'vite-tsconfig-paths';
const rootPath = process.cwd();
export default defineConfig(({ mode }) => {
const plugins: PluginOption[] = [
react(),
tsconfigPaths({
root,
}),
];
if (mode === `production`) {
plugins.push(checker({ typescript: true }));
}
const root = mode === `test` ? rootPath : path.join(rootPath, `./src/app`);
return {
root,
plugins,
build: {
emptyOutDir: false,
rollupOptions: {
input: path.join(rootPath, `./src/app/singleSpa.tsx`),
output: {
dir: path.join(rootPath, `./dist`),
entryFileNames: `app.[hash].js`,
format: `system`,
},
},
},
server: {
proxy: {
'/api': { target: `https://stand.dodois.dev`, secure: false, changeOrigin: true },
'/swagger': { target: `https://stand.dodois.dev`, secure: false, changeOrigin: true },
},
},
test: {
globals: true,
environment: `jsdom`,
},
}
});
За час мы смогли перевести приложение на Vite, запустили его в режиме dev-сервера, задеплоили билд на тестовый стенд. Но на стенде с первого раза увидели белый экран…
В предыдущей статье мы рассказывали, что appshell вместе с SingleSpa подтягивают наш микрофронтенд. При этом ожидают, что приложение будет экспортировать три функции: bootsrap
, mount
, unmount
. Но из-за минификации билда в prod-режиме имена данных функций заменились на сокращённые (b
, m
, u
). Мы решили это установкой опции vite.config.ts
preserveEntrySignatures
в значение strict
. Снова задеплоили на стенд и всё заработало!
Кажется, что на этом можно было заканчивать и расходиться. Но мы хотели заставить заработать import-map-overrides. Во-первых, чтобы была возможность подменить в prod-режиме бандл на локальный и что-нибудь протестировать. Во-вторых, не за горами была авторизация на стенде. И на localhost наш auth просто не проставит куку. Importmap override также решил бы и эту проблему.
Настало время включить import-map-overrides…
Самая главная часть идеологии Vite — использование нативных модулей ES6 в режиме разработки. Это позволяет не билдить при каждом изменении весь бандл, а только файл, в котором произошли изменения. Но всё наше решение с importmap построено на SystemJS, который никак не совместим с нативными модулями. Это значит, что мы не сможем просто так взять и подружить наш appshell (SystemJS-импорты и SystemJS-importmap) с Vite-сервером, который собирает нативные модули с нативными import/export.
Переходим с SystemJS на нативные модули и нативные importmap
Мы могли решить проблему в лоб: собирать локально бандл в один JS-файл с помощью rollup (который встроен в Vite) и использовать его как SystemJS-модуль. Но тогда мы бы получили тот же принцип, что и в Webpack, потеряв все преимущества локальной сборки.
Поэтому решили перевести наш appshell, который собирается с помощью Webpack и заточен на SystemJS, на нативные импорты и нативные importmap.
Как было до:
import { LifeCycles, registerApplication, start } from 'single-spa';
import 'systemjs';
import 'import-map-overrides';
registerApplication(
`app`,
() => System.import(app),
location => location.pathname.startsWith(/),
);
start();
Как стало после:
import { registerApplication, start } from 'single-spa';
import 'import-map-overrides';
registerApplication(
`app`,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
() => import(/* webpackIgnore: true */ app),
location => location.pathname.startsWith(/),
);
start();
webpackIgnore: true даёт Webpack понять, что здесь нет lazy-load и пытаться кодсплитить файл не нужно. @ts-ignore, чтобы TypeScript не ругался на отсутствие модуля app, который подгружается с помощью importmap.
В index.html
файле appshell мы заменили тип importmap с SystemJS на нативные.
До:
После:
А ещё установили опцию build.rollupOptions.output.format
в значение module
.
Проблема с external importmap
К сожалению, хоть importmap и является частью спецификации HTML, поддержка в браузерах довольно грустная. Поэтому первое, с чем мы столкнулись, открыв Chrome, — External import maps are not yet supported
. Это значит, что мы не можем загружать importmap через src
— только напрямую, описав внутри тела тега script
.
Окей, в теории мы можем загрузить importmap с помощью fetch
и поместить в DOM. Это нам Chrome не запретит. Главное, чтобы importmap была загружена до того, как произойдёт попытка импорта какого-либо модуля. Мы решили вернуться к этой проблеме после и получить сначала хотя бы какой-то рабочий вариант. Поэтому создаём script
тег через JS, но пока что без fetch
:
type
изменился с importmap
на overridable-importmap
.
И это сработало!
Проблема с множественными importmap
При попытке сделать override на локальный бандл, получаем следующую ошибку: Multiple import maps are not yet supported.
Действительно, в багтрекере Chrome уже три года висит issue с этой проблемой. И, кажется, исправят это не скоро, так как множественные importmap не поддерживаются по спецификации. Для нас это значит то, что мы не можем использовать вторую importmap на странице, которая переопределяет основную importmap и позволяет подключить произвольный локальный бандл.
К счастью, в пакете import-map-overrides это предусмотрели. По сути, изначально не создаётся ни одна importmap, а import-map-overrides склеивает оригинальную «заготовку» с переопределённой. По итогу код не сильно изменился:
Проблема с hot reload
Сразу после переопределения importmap мы получаем ошибку Uncaught Error: application 'app' died in status LOADING_SOURCE_CODE: @vitejs/plugin-react can't detect preamble. Something is wrong.
Эта проблема связана с тем, что наш локальный бандл пытается обратиться к локальному dev-серверу для работы hot module replacement. Мы решили эту проблему также оставить на потом. Пока просто отключили HMR в vite.config для React-плагина:
const options: Options =
mode === `dev-single-spa`
? {
exclude: /.*/,
}
: {}
// ...
plugins: [react(options)],
Получение importmap с бэка
Снова всё вроде бы заработало, но теперь нам нужно закрыть техдолг, созданный выше. Для начала получать importmap с настоящего cdn с помощью fetch:
Получился такой код в HTML appshell:
const im = document.createElement('script');
im.type = 'overridable-importmap';
const loadImportmap = async () => {
const result = await fetch(`http://cdn.dodois.dev/importmap.json>`);
const importmap = await result.json();
im.textContent = JSON.stringify(importmap);
document.currentScript.after(im);
}
loadImportmap();
Проблема с очередью загрузки скриптов
И сразу натыкаемся ещё на две проблемы.
Во-первых, document.currentScript.after
нельзя использовать в коллбэке. ¯\(ツ)/¯
Во-вторых, существует вероятность, что importmap загрузится до бандла с appshell. Тогда при попытке импорта браузер не будет знать, откуда грузить наш микрофронтенд. Поэтому скрипт внутри HTML ещё больше увеличился в размерах и стал выглядеть так:
const loadImportmap = async () => {
const result = await fetch('');
const importmap = await result.json();
const imScript = document.createElement('script');
imScript.type = 'overridable-importmap';
imScript.textContent = JSON.stringify(importmap);
document.head.append(imScript);
const imOverrideScript = document.createElement('script');
imOverrideScript.src = '/importmapOverride.bundle.js';
imOverrideScript.onload = () => {
const appShellScript = document.createElement('script');
appShellScript.type = 'module';
appShellScript.defer = true;
appShellScript.src = '/appshell.bundle.js';
document.head.append(appShellScript);
}
document.head.append(imOverrideScript);
}
loadImportmap()
Здесь importmapOverride.bundle.js
— это отдельный бандл, в котором хранятся скрипты пакета import-map-overrides
. А в appshell.bundle.js
непосредственно код appshell.
Всё! Всё работает (кроме HMR), но снова приходит техлид и спрашивает: «А в Safari работает?».
В Safari не работают importmap вообще — используем es-module-shims
Да… Мы забыли про Safari. При том, что мы делаем приложение конкретно под iOS Safari.
Как и ожидалось, Safari даже в урезанном виде не поддерживает нативные importmap. Начинаем искать полифил… И находим в виде es-module-shim. Подключаем его в наш appshell. Теперь index.html выглядит так:
Получается, круг замкнулся и мы пришли примерно к тому, что начали. Только теперь у нас вместо SystemJS es-module-shim.
Но при этом мы можем использовать нативные модули. И только appshell будет знать о полифиле. Основное же приложение можно спокойно билдить с target: module
и всё будет работать.
Также es-module-shim умеет работать в двух режимах: полифила и, собственно, shim.
В первом варианте полифилятся нативные importmap, если они полностью не поддерживаются в браузере. Если же поддерживаются, то просто ничего не происходит. Но такой вариант не подходит, если мы захотим поддерживать Сhrome, так как у нас снова перестанут работать external importmap, multiple importmap и снова придётся писать костыли.
Так как мы используем полифил, могут возникнуть вопросы к производительности — с этим, судя по бенчмаркам, у es-module-shim тоже всё достаточно хорошо.
Исправляем HMR, рефакторим
Всё работает, кроме HMR. Возвращаемся к нему. Чтобы заставить работать HMR, добавляем следующий скрипт в index.html appshell:
window.addEventListener("import-map-overrides:init", () => {
const isEnabledOverride = !window.importMapOverrides.isDisabled('app');
if (isEnabledOverride) {
const overrideMap = window.importMapOverrides.getOverrideMap();
const appModuleUrl = overrideMap.imports.app;
const { origin } = new URL(appModuleUrl);
const viteClientScript = document.createElement('script');
viteClientScript.src = `${origin}/@vite/client`;
viteClientScript.type = 'module-shim';
document.head.append(viteClientScript);
const reactHmrScript = document.createElement('script');
reactHmrScript.type = 'module-shim';
reactHmrScript.innerHTML = `
import RefreshRuntime from '${origin}/@react-refresh';
RefreshRuntime.injectIntoGlobalHook(window);
window.$RefreshReg$ = () => {};
window.$RefreshSig$ = () => (type) => type;
window.__vite_plugin_react_preamble_installed__ = true;
`
document.head.append(reactHmrScript);
}
});
Здесь мы определяем, включен ли importmap-overrides для нашего бандла. И если да, добавляем скрипты, необходимые Vite для работы HMR в режиме dev-сервера.
Чтобы данный код не засорял HTML, мы выделили его в отдельный файл и билдим вместе с shim-полифилом и import-map-overrides. В дальнейшем этот код будет выделен в отдельный пакет и использоваться новыми микрофронтендами (точнее, их appshell).
Бонус: подключаем SWC
Когда мы занимались активным переездом на Vite, наткнулись на новость о супербыстром TurboPack. Во всех бенчмарках разработчики сравнивают его с Vite и говорят о том, насколько его опережают. Эван Ю в своей ответке говорит о том, что данные сравнения не релевантны, так как они используются стандартный плагин для сборки React в dev-режиме. А у него под капотом Babel. Вот если бы разработчики TurboPack использовали для теста Vite с SWC… Ну, мы и прикрутили ещё и SWC. Dev-сборка стала ещё быстрее, но бенчмарки и сравнения мы не делали, так как проект ещё совсем маленький и посмотреть разницу хотим потом.
Как жить со всем этим богатством
Все новые микрофронтенды будем стартовать на связке appshell с нативными импортами плюс сборка микрофронтенда на Vite. Для этого мы реализовали библиотеки-аналоги create-react-app и react-scripts, только с Vite и заточенные под создание каркаса микрофронтенда. В эти библиотеки включены все необходимые плагины и конфиги, поэтому создание нового проекта происходит максимально быстро, а разработчиком-фуллстекам не нужно погружаться в нюансы тулинга. А это важно, если само приложение маленькое. При этом написать библиотеки удалось очень быстро, за пару дней. С Webpack у нас было очень много проблем (в частности с Yarn PnP), JavaScript API сложное и негибкое, код получался громоздким и костыльным.
Если же appshell обслуживает сразу несколько микрофронтендов, переписывать их на Vite затратно, а новое приложение в этот appshell добавить нужно, то даём возможность билдить новое приложение в формате SystemJS и использовать Vite в режиме preview, когда нужен import-map-overrides
. По сути, даже в dev-режиме бандл билдится в один файл, поднимается HTTP-server, который этот файл хостит. HMR работать не будет, билд в разы медленнее. Но для мелких приложений из одной формочки подойдёт. Если же import-map-overrides
не нужен, то используется обычная супербыстрая сборка.
В целом с Vite нам очень понравилось работать. После Webpack это как будто глоток свежего воздуха. Ничего лишнего, почти всё необходимое доступно из коробки, скорость сборки молниеносная. Иногда, правда, веет какой-то магией — подключил плагин, никаких конфигов, всё работает. Из-за этого происходит недоверие, ждёшь подвоха. И они действительно бывают — проблемы, описанные выше в статье, невозможность использования Yarn PnP вместе с SWC (из-за этого есть проблемы с SWC-плагинами). Но все эти проблемы решаемы достаточно очевидным способом без костылей.
В будущем расскажем о библиотеках для создания и сборки микрофронтендов, которая у нас получилась. Хотели бы вы увидеть их в открытом доступе?