Neuroróżnorodność w miejscu pracy
26.09.20235 min
Kamil Doroszewicz
Tpay

Kamil DoroszewiczSenior Frontend DeveloperTpay

Jak zmigrować frontend legacy - strangler fig pattern

Sprawdź, jak zmienić front w legacy i nie zwariować. To wszystko z użyciem wzorca strangler fig.

Jak zmigrować frontend legacy - strangler fig pattern

Dług technologiczny

Marzeniem każdego programisty jest praca w greenfieldowych projektach; z najnowszymi technologiami w młodych, dynamicznych zespołach 😏. Rzeczywistość szybko weryfikuje te marzenia i trafiamy do projektu legacy „rozwijanego” od wielu lat. Piszę „rozwijanego” w cudzysłowie, bo ten rozwój objawia się prawie ekskluzywnie przez dodawanie nowych funkcjami za pomocą starych, mało optymalnych metoda i łataniem na bieżąco występujących błędów - bez martwienia się o dług technologiczny.

Mimo smutnej rzeczywistości jeszcze nie wszystko stracone - nawet bardzo zaniedbane aplikacje możemy odratować, a pomoże nam w tym wzorzec migracyjny strangler fig.

Wzorzec strangler fig

Wzorzec ten bierze swoją nazwę od grupy roślin tropikalnych potocznie nazywanych "strangler fig". Rośliny te rosną wokół istniejących drzew stopniowo je obrastając aż do momentu, kiedy te - odcięte od substancji odżywczych - obumierają. Analogicznie, w kontekście projektów frontendowych, wzorzec strangler fig pozwala na stopniową modernizację starej aplikacji, wdrażając nowe technologie i frameworki z zachowaniem starego, działającego kodu do momentu, aż już nie będzie potrzebny.

Zastosowanie w praktyce

Na przykładzie symulowanej aplikacji legacy opartej o jQuery przyjrzymy się jak praktycznie bezboleśnie zmigrować jej część do rozwiązania opartego o framework React.

Nasza przykładowa aplikacja legacy posiada 2 ekrany - stronę główną oraz stronę z formularzem kontaktowym. Ich początkowy HTML jest zwracany z serwera. Przejścia pomiędzy ekranami wykonane są w stylu pseudo SPA - backend zwraca fragment strony, którą jQuery podmienia bez jej przeładowywania. Przy naszej migracji nie chcemy początkowo ingerować we wspomniany mechanizm - w prawdziwej aplikacji może on obejmować dziesiątki lub setki ekranów i różnych interakcji pomiędzy nimi. Przepisywanie ekranów na nowe technologie pojedynczo jest tutaj bezpieczniejszym rozwiązaniem.

Załóżmy, że dostaliśmy z Działu Produktu projekt pięknego nowego formularza i to od niego zaczniemy migrację. W tym momencie zaczyna się ta przyjemna część - możemy założyć nowy projekt i tworzyć go całkowicie w izolacji od starego kodu.

Inicjujemy projekt React, w którym będą powstawały nowe ekrany:

npm create vite@latest frontend -- --template react


Tworzymy w nim kod naszego nowego formularza kontaktowego:

const Contact = () => {
  const handleSubmit = (e) => {
    e.preventDefault();
    alert("Formularz wysłany poprawnie!");
  };

  return (
    <div>
      <p>Nowy formularz kontaktowy</p>
      <form onSubmit={handleSubmit}>
        <input type="email" required placeholder="twó[email protected]" />
        <textarea placeholder="Wpisz wiadomość"></textarea>
        <div>
          <input type="checkbox" id="consent" name="consent" />
          <label htmlFor="consent">Zgoda na przetwarzanie danych</label>
        </div>
        <button type="submit">Wyślij</button>
      </form>
    </div>
  );
};

export default Contact;


Jak już wcześniej wspominałem, chcemy systematycznie “udusić” starą aplikację, więc teraz nie ingerujemy w obecne tam jQuery i mechanizm przechodzenia pomiędzy ekranami. Chcemy tylko wpiąć nasz nowo utworzony widok (a w przyszłości kolejne).

Żeby to osiągnąć, w React zastosujemy kawałek kodu, który:

  • załaduje się razem z aktualnymi stronami aplikacji,
  • zacznie obserwować stronę przez MutationObserver,
  • zamontuje nasz nowy interfejs w odpowiednim momencie w wybranym miejscu.


Podczas pracy z kodem legacy zazwyczaj nazywam ten pomocniczy komponent Mounter:

import { Fragment, createElement, lazy, useEffect, useState } from "react";
import { createPortal } from "react-dom";

// Importujemy nasz nowy ekran w sposób pozwalający wydzielić go podczas budowania do oddzielnego pliku .js
const ContactForm = lazy(() => import("./Contact"));

// Tworzymy mapę wskazującą na klasę elementu, na którym powinien pojawić się nasz formularz. W przyszłości możemy dodać tutaj kolejne komponenty.
const classToComponentMap = new Map([["contact-form-root", ContactForm]]);

const Mounter = () => {
  // Stan pozwalający wymusić przerenderowanie komponentu i zamontowanie naszych ekranów
  const [, setForceRerender] = useState("");

  // Efekt uruchamiający się przy zamontowaniu komponentu Mounter
  useEffect(() => {
    // Konfiguracja MutationObserver, obserwujemy zmiany elementu i jego wszystkich elementów podrzędnych
    const config = {
      childList: true,
      subtree: true,
    };

    // Funkcja wykonująca się w momencie wykrycia przez MutationObserver zmian w obserwowanym elemencie
    const callback = (mutationList) => {
      for (const mutation of mutationList) {
        if (mutation.type === "childList") {
          for (const selectorClass of classToComponentMap.keys()) {
            const observedElements = document.querySelectorAll(
              `.${selectorClass}`
            );

            if (observedElements) {
              setForceRerender(new Date().toISOString());
            }
          }
        }
      }
    };

    // Zainicjowanie nowego MutationObservera
    const observer = new MutationObserver(callback);

    // Uruchomienie obserwacji body strony
    observer.observe(document.body, config);

    return () => {
      // Sprzątanie po odmontowaniu komponentu Mounter
      observer.disconnect();
    };
  }, []);

  // Zebranie wszystkich punktów montowania naszych komponentów, w tym przypadku będzie to tylko <div class="contact-form-root"></div>
  const elementsToMount = Array.from(classToComponentMap.keys()).map(
    (selectorClass) =>
      Array.from(document.querySelectorAll(`.${selectorClass}`))
  );

  // Renderowanie naszych widoków. Dzięki zastosowaniu `createPortal` zamiast `createRoot` nasze
  // widoki pozostają w tym samym drzewie Reactowym co umożliwia w przyszłości stosowanie np. kontekstów
  return elementsToMount.flat().map((el, index) => {
    return createPortal(
      <Fragment key={index}>
        {createElement(classToComponentMap.get(el.className))}
      </Fragment>,
      el
    );
  });
};

export default Mounter;


Kod formularza i pomocniczy komponent Mounter są gotowe. Wystarczy zwrócić nasz nowy komponent w plik App.jsx i możemy zbudować naszą nową aplikację:

import Mounter from "./Mounter";

const App = () => {
  return <Mounter />;
};

export default App;
npm run build


Wynik budowania kopiujemy do naszego projektu legacy do folderu public a następnie dodajemy nowe tagi <script>, które załadują naszą aplikację Reactową przy załadowaniu strony a następnie nowy interfejs strony kontaktu, kiedy na nią przejdziemy. W aplikacji legacy możemy usunąć cały stary formularz i zamiast niego zostawiamy tam punkt montowania dla nowego formularz.

       …
        <!-- 
        <form>
            <input type="email" placeholder="twó[email protected]" />
            <textarea placeholder="Wpisz wiadomość"></textarea>
            <button type="submit">Wyślij</button>
        </form>
        -->
        <div class="contact-form-root"></div>
        …

Co dalej?

Wprowadzając systematycznie kolejne widoki do aplikacji, stopniowo przechylamy szalę w stronę tzw. "nowego frontu". W miarę jak nowe funkcjonalności stają się dominującą częścią projektu, nadchodzi odpowiedni moment na coraz bardziej zaawansowane ingerencje w warstwę UI. 

To wtedy możemy rozważyć dodanie np. frontendowego routingu, co ułatwi nawigację w aplikacji, zapewniając użytkownikom bardziej intuicyjny i płynny sposób poruszania się po nowym interfejsie. 

Z czasem z naszej aplikacji legacy zostanie tak mało, że wręcz trywialnym będzie jej finalnie wyłączenie i uroczyste zarchiwizowanie jej repozytorium.

Podsumowanie

To podejście pomaga w bezpieczny i przewidywalny sposób zaktualizować frontend w aplikacjach legacy, korzystając z nowoczesnych i skalowalnych rozwiązań. Dzięki niemu unikamy problemów związanych z awariami i jednocześnie umożliwiamy dalszy rozwój projektu, dostosowując go do współczesnych standardów i potrzeb użytkowników. To praktyczna strategia, która pomaga zachować wartość istniejącej aplikacji, jednocześnie przygotowując ją na przyszłe wyzwania.

Kod jest dostępny pod tym linkiem.

<p>Loading...</p>