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.