Стоит ли игра свеч? Кратко о Single SPA (часть 1)

У вас возникнет вопрос, а почему вообще стоит уделить свое внимание данному фреймворку? Давайте разбираться!

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

Особенно проблемы возникают при работе с огромной пачкой легаси-кода, который остался с далеких времен нам в наследие от ушедших из проекта разработчиков…

К чему это все?

Большинство программ на сегодняшний день подобны египетским пирамидам из миллиона кирпичиков друг на друге и без конструктивной целостности — они просто построены грубой силой и тысячами рабов.

— Alan Kay

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

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

И тут нам пришлось немного прикурить…

Вариантов было несколько:

  • Пилить весь функционал нового проекта прямо внутри монолита.

  • Рассмотреть вариант встраивания приложения в виде Iframe.

  • Вынести проекты в отдельные модули при помощи Module Federation.

  • Разбиение модулей на NPM-репозитории.

  • Форкать git-репозитории модулей в хост.

  • Разбить проекты на независимые сервисы при помощи Single SPA.

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

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

Почему в итоге был выбран Single SPA?

Из названия статьи многие догадались, что выбрали мы последний вариант, но все-таки почему?

Нам данный фреймворк понравился тем, что можно:

  1. Разделить команды на свои зоны ответственности.

  2. Полностью изолировать модули друг от друга.

  3. Хранить каждый модуль децентрализованно.

  4. Создавать модули на любом frontend-стеке.

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

  6. Обработать случай недоступности микрофронтенда.

  7. Сократить размер бандла каждого из микрофронтендов при их корректном проектировании.

  8. Объединить общие зависимости всех модулей через importmap.

  9. Обеспечить гибкость архитектуры и масштабировать продукт до огромных масштабов без потери ресурсов и скорости.

Многие скажут: а в чем принципиальное различие от Module Federation? Какой смысл танцевать с бубнами Single SPA, если есть незаменимый плагин MF?

Об этом хорошо написал автор статьи »гайд по микрофронтендам на single-spa, или Как уже наконец-то уйти от монолита во фронтенде» (пунктуация сохранена):

Если кратко — это чуть разные вещи для разных задач.

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

Module federation — больше про шаринг модулей одного фреймворка. Допустим у тебя большое приложение на Vue, но ты его хочешь раздробить и распределить по отдельным репам. После настройки MF ты сможешь это сделать и главное будешь подключать код из этих реп как обычные компоненты (звучит оч удобно и круто)

Вообще эти подходы можно даже комбинировать. Ты тащишь себе в проект single spa, выстраиваешь архитектуру, а module federation тебе позволяет шарить какой-то общий код.

НО

Чтобы использовать MF сразу для разных фреймворков, нужно заморочиться и писать свои доп обертки, чтобы дружить проекты между собой и там, на самом деле, вытекает много подводных камней. И тут возникает вопрос, а зачем тогда накручивать все это на MF, если есть готовый single-spa

Суммируя текст выше — наша задача «перейти на новый фреймворк и сделать независимые приложухи», и мы выбираем single-spa, тк именно на этом он и специализируется

В целом, я солидарен с его мнением, Module Federation действительно больше подходит для разделения конкретно функций и компонентов, в то время как Single SPA предлагает проектировать максимально независимые друг от друга сервисы.

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

Главное в этом вопросе соблюдать грань, так как оверинжинирг приведет вас к тому же, от чего вы пытаетесь уйти — плохой поддержке кода, сложности добавления новых фич и пр.

Как это работает?

Пример архитектуры микрофронтендов, построенной на основе Single SPA

Пример архитектуры микрофронтендов, построенной на основе Single SPA

На самом деле логика довольно простая: есть хост для микрофронтендов — его называют UI Shell (далее «App Shell»), он нужен для того, чтобы объединить наши микроинтерфейсы в одно приложение. С другой стороны есть сами модули —  обычная статика в виде набора js файлов, которые можно подключить при помощи входной точки, так называемого аналога remoteEntry.js Module Federation плагина.

Пример собранного сборщиком файла remoteEntry.js у микрофронта Single SPA:

import {hg as s,hh as n,hi as u} from"./assets/euphoria-admin-DMV-Awf4.js";

import "react";
import "effector";
import "react-dom";
import "effector-react";
import "react-router";
import "react-router-dom";

export {s as bootstrap,n as mount,u as unmount};

В зависимости от формата сборки вашего приложения код будет отличаться, еще один пример:

System.register(["react","./assets/euphoria-tickets-HpqSaihu.js",
"react-dom","react-router-dom","effector",
"effector-react","react-router"],
function(e,r){"use strict";
return{setters:[null,t=>{e({bootstrap:t.bL,mount:t.bM,unmount:t.bN})},
null,null,null,null,null],execute:
function(){}}});

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

Теперь остается вопрос: как связать наш App Shell с микрофронтендами?

Тут нам на помощь приходят импортмапы! В нашем случае они помогут подключить входные точки микрофронтендов к нашему App Shell.

Про карты импортов

Карты импортов (Import maps) — механизм, позволяющий получить контроль над поведением JavaScript-импортов.

Про сам механизм работы importmap можно кратко ознакомиться в данной статье.

Цитата автора статьи »‎Микровселенная безумия, или Как устроены микрофронтенды в Dodo»:

Микрофронтенды деплоятся и достаются из blob-storage. Соответственно, импортировать их через относительные пути (с текущего хоста) не получится. Нужно запрашивать их динамически и лениво.

Мы не знаем заранее, где будут храниться бандлы, но хотим их импортировать не по путям, а просто по названиям. С этим может помочь Importmap. Это скрипт с картами импортов, у которых ключи в роли идентификатора (название приложения) и значениями в роли относительных или абсолютных путей на его физическое расположение (какой-нибудь blob-storage, например, Azure).

Здесь нас больше интересует другой вопрос — функционал нативных importmap не поддерживается некоторыми браузерами (проверить актуальность поддержки можно тут).

Также на момент написания статьи есть возможность добавить только одну карту импорта. Если добавить вторую, то это приведёт к ошибке. В Chrome вы увидите следующее:

Multiple import maps are not yet supported.

Про полифилы

Для решения вышеописанных проблем к нам на помощь приходят две замечательные библиотеки — инструменты, связанные с загрузкой модулей JavaScript и совместимостью с ES-модулями:

  1. SystemJS.

  2. ES Module Shims.

Давайте разберем каждую из них.

SystemJS

SystemJS — динамический загрузчик модулей, который поддерживает различные форматы модулей, включая ES-модули, CommonJS, AMD и глобальные скрипты. Был разработан для загрузки модулей способом, совместимым как со старыми, так и с новыми средами JavaScript.

SystemJS позволяет загружать модули «на лету» и может обрабатывать сложные деревья зависимостей модулей.

Ключевые особенности:

  • Поддерживает несколько форматов модулей.

  • Обеспечивает динамическую загрузку модулей.

  • Позволяет настраивать пользовательские загрузчики и расширенную настройку.

  • Может использоваться для поддержки модулей ES с множественным заполнением в средах, которые изначально не поддерживают его.

Use cases:

  • Когда вам нужно поддерживать несколько форматов модулей.

  • Для приложений, требующих динамической загрузки модулей.

  • В средах, где поддержка модулей ES недоступна в полном объеме и требуется многоразовое заполнение.

  • Для сложных приложений со сложными требованиями к загрузке модулей.

ES Module Shims

es-module-shims — легкий polyfill, который специально разработан для добавления функций в загрузчик модулей ES в средах, поддерживающих модули ES, но лишенных некоторых расширенных функций.

В первую очередь направлен на обеспечение поддержки importmap.

Ключевые особенности:

  • Поддержка импортмап в браузерах, которые изначально их не поддерживают.

  • Фокусируется исключительно на функциях модуля ES и не поддерживает другие форматы модулей.

  • Минималистичный и легкий, разработан максимально приближенно к нативному поведению.

Use cases:

  • При работе в средах, которые уже поддерживают модули ES, но не имеют расширенных функций, таких как импортмап.

  • Для приложений, которые в основном используют модули ES и не нуждаются в поддержке других форматов модулей.

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

Когда что использовать?

Используйте SystemJS, если вам нужна поддержка нескольких форматов модулей (ES modules, CommonJS, AMD) и требуются возможности динамической загрузки модулей. Он подходит для устаревших проектов или сложных приложений с различными зависимостями от модулей.

es-module-shims, если вы работаете с современными модулями ES и вам необходимо использование таких функций, как импортмапы. Это лучший выбор для более простых приложений или современных кодовых баз, где вам нужно только расширить возможности модуля ES без дополнительных затрат на поддержку других форматов.

Для подробного ознакомления рекомендуется к прочтению:

  1. https://superdevelopment.com/2016/03/16/a-dive-into-systemjs-part-1/

  2. https://guybedford.com/es-module-shims-production-import-maps

Немного о сборке микрофронтов

После отчаянного выбора Single SPA как фреймворка для проектирования микросервисной архитектуры перед нами встал вопрос о выборе сборщика. После прочтения документации мы запилили тестовые стенды микрофронтов, собранные при помощи Webpack. App Shell приложение мы тоже собрали Webpack’ом, а для решения вышеописанных проблем с importmap выбрали подключение скриптов с type="systemjs-importmap". Настройка самих модулей сложности не вызвала, по сути, при выборе Webpack за нас всю магию сборки делает библиотека webpack-config-single-spa-react:

const { merge } = require('webpack-merge')
const singleSpaDefaults = require('webpack-config-single-spa-react')

module.exports = (webpackConfigEnv) => {
  const defaultConfig = singleSpaDefaults({
    orgName: 'organization',
    projectName: 'example-react',
    webpackConfigEnv
  })

  return merge(defaultConfig, {
    // customizations can go here
  })
}

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

import React from 'react'
import ReactDOMClient from 'react-dom/client'
import singleSpaReact from 'single-spa-react'
import Root from '@/app/providers'

export const { bootstrap, mount, unmount } = singleSpaReact({
  React,
  ReactDOMClient,
  rootComponent: Root,
  // @ts-ignore
  errorBoundary(err, info, props) {
    console.log('err:', err)
    console.log('info:', info)
    return null
  },
})

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

Тут мы столкнулись с проблемой: SystemJS не совместим с нативными модулями. Данная проблема также присутствовала у автора замечательной статьи »‎Вдали от Webpack, или Как мы в Dodo микрофронтенды на Vite переводили»:

Самая главная часть идеологии Vite — использование нативных модулей ES6 в режиме разработки. Это позволяет не билдить при каждом изменении весь бандл, а только файл, в котором произошли изменения. Но всё наше решение с importmap построено на SystemJS, который никак не совместим с нативными модулями. Это значит, что мы не сможем просто так взять и подружить наш appshell (SystemJS-импорты и SystemJS-importmap) с Vite-сервером, который собирает нативные модули с нативными import/export.

Мы могли решить проблему в лоб: собирать локально бандл в один JS-файл с помощью rollup (который встроен в Vite) и использовать его как SystemJS-модуль. Но тогда мы бы получили тот же принцип, что и в Webpack, потеряв все преимущества локальной сборки.

Поэтому решили перевести наш appshell, который собирается с помощью Webpack и заточен на SystemJS, на нативные импорты и нативные importmap.

В связи с вышеперечисленными проблемами SystemJS мы вслед за Dodo также решили перейти на нативные импорты.

При этом es-module-shims может использовать нативные модули. И только App Shell будет знать о полифиле. Основное же приложение можно спокойно билдить с format: module и всё будет работать.

Также es-module-shims умеет работать в двух режимах: полифила и, собственно, shim.

В первом варианте полифилятся нативные importmap, если они полностью не поддерживаются в браузере. Если же поддерживаются, то просто ничего не происходит. Но такой вариант не подходит, если мы захотим поддерживать Сhrome, так как перестанут работать external importmap,  multiple importmap и снова придётся писать костыли.

В итоге конфигурация нашего микрофронтенда стала такой:

/// 
import react from '@vitejs/plugin-react-swc'
import { always, equals, ifElse, propEq, replace } from 'ramda'
import { defineConfig, loadEnv } from 'vite'
import svgr from 'vite-plugin-svgr'

const getProxyConfig = ifElse(
  propEq('LOCAL', 'VITE_CURRENT_ENV'),
  // @ts-ignore
  (env: ImportMeta['env']) => ({
    [env.VITE_PROXY]: {
      target: env.VITE_TICKETS_BASE_API_URL,
      changeOrigin: true,
      cookieDomainRewrite: 'localhost',
      secure: false,
      cookiePathRewrite: '/',
      rewrite: replace(/^\/[^/]+/, '')
    }
  }),
  always(undefined)
)

const externalDependencies = [
  'react',
  'react-dom',
  'react-router-dom',
  'react-router',
  'effector',
  'effector-react',
  'patronum'
]

const plugins = [
  react(),
  svgr({
    svgrOptions: {
      exportType: 'named',
      ref: true,
      svgo: false,
      titleProp: true
    },
    include: '**/*.svg?react'
  })
]

export default ({ mode }: { mode: string }) => {
  // @ts-ignore
  const env = loadEnv(mode, process.cwd(), '') as ImportMeta['env']

  return defineConfig({
    test: {
      globals: true,
      environment: 'jsdom',
      setupFiles: './setupTests.ts',
      css: true
    },
    base: ifElse(
      equals('production'),
      always('/react-app/'),
      always('/')
    )(mode),
    define: {
      'process.env': env
    },
    plugins,
    server: {
      port: 3000,
      strictPort: true,
      proxy: getProxyConfig(env)
    },
    envPrefix: 'VITE_',
    resolve: {
      alias: [{ find: '@', replacement: '/src' }]
    },
    build: {
      cssCodeSplit: true,
      assetsDir: 'assets',
      emptyOutDir: false,
      rollupOptions: {
        input: 'src/example-react.tsx',
        external: externalDependencies,
        output: {
          entryFileNames: '[name].js',
          assetFileNames: 'assets/[name].[ext]',
          format: 'module'
        },
        preserveEntrySignatures: 'strict'
      }
    }
  })
}

Давайте разберем данный конфиг. В нем нас больше всего интересует настройки сборщика Rollup.

Для корректной работы нашего микрофронта значение preserveEntrySignatures должно быть обязательно установлено в значение 'strict'. Подробнее с работой данного параметра можно ознакомиться тут.

Также в rollupOptions указывается параметр input c указанием пути к вашей входной точки микрофронта.

В output, соответственно, указываем конфигурацию для выходных файлов после билда нашего приложения. В нашем случае в entryFileNames мы прописали статичное название '[name].js', поэтому на выходе наш собранный проект (далее »‎dist»‎) будет содержать один файл (тот самый example-react.js, который мы сможем подключить к нашему App Shell).

Параметр external указывает, какие зависимости мы можем выпилить при сборке нашего приложения, по сути, они просто не попадают в node_modules. Далее эти зависимости можно расшарить между всеми микрофронтами при помощи importmap (подробнеебудет разобрано во второй части цикла статей о Single SPA, где мы научимся создавать корректные импорты и scopes под наши зависимости).

Также, указав строку assetFileNames: 'assets/[name].[ext]', наш dist будет содержать папку assets, в которой будут находиться все статичные файлы нашего приложения, начиная от всех lazy chunks с уникальным хешом в названии, заканчивая статикой в виде шрифтов и css (эти файлы будут с названием без хеша).

Пример собранного микрофронта на Vite

Пример собранного микрофронта на Vite

На самом деле самой интересной строкой в конфигурации rollupOptions является format. Данный параметр подразумевает формат, в котором наше приложение будет собрано.

Rollup поддерживает несколько выходных форматов:

export type InternalModuleFormat = 'amd' | 'cjs' | 'es' | 'iife'
  | 'system' | 'umd';

export type ModuleFormat = InternalModuleFormat | 'commonjs'
  | 'esm' | 'module' | 'systemjs';

Давайте подробно разберем каждый из них:

  • amd (Asynchronous Module Definition)

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

  • cjs (CommonJS)

  • es (ECMAScript Module)

  • iife (Immediately Invoked Function Expression)

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

  • system (SystemJS)

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

  • umd (Universal Module Definition)

    • Универсальный формат, который работает как с AMD, так и с CommonJS. Он также может быть загружен как глобальный скрипт в браузере. Это делает его подходящим для библиотек, которые должны работать в различных окружениях.

  • commonjs

  • esm

  • module

  • systemjs

Так как мы выявили проблематику поддержки нативных импортов у SystemJS, для корректной работы всех микрофронтов на Vite оставляем format в значении module.

Здесь можно увидеть репозиторий, в котором присутствуют две ветки с примерами форматов system и module (systemjs и es-module-shims соответственно)

Возвращаемся к App Shell

Теперь нам осталось связать наши микрофронты в единое приложение… А это уже совсем другая история, которая будет разобрана во второй части статьи о Single SPA.

Также подробно будут разобраны такие вопросы, как:

  1. Локальная разработка и HMR.

  2. Shared dependencies через importmap.

  3. Тестирование сего великолепия.

  4. CI/CD.

  5. Кеширование браузером и работа с Service Worker API.

  6. Выводы с указанием преимуществ и недостатков к подходу по проектированию микросервисной архитектуры Single SPA.

P.S. Перед прочтением второй части статьи, которая выйдет в ближайшее время, рекомендуется ознакомиться с подготовленным в качестве примера репозиторием App Shell, а также бегло пройтись по документации Single SPA.

До встречи в следующей статье!

До встречи в следующей статье!

Habrahabr.ru прочитано 2278 раз