WarsawJS Workshop 45 - Własny React
Zbudowanie narzędzia do renderowania aplikacji, na podstawie biblioteki React. Podczas pracy zachowywać będziemy rzeczywistą architekturę i nazewnictwo. Pozwoli to zrozumieć działanie oryginalnej biblioteki oraz jak tworzą oprogramowanie aktualnie największe firmy.
Po sklonowaniu repozytorium zmienić branch na workshop lub workshop-js (jeśli preferujesz środowisko bez typów) oraz zainstalować zależności wykorzystując yarn lub npm.
Aby rozpocząć pracę, należy skorzystać ze skryptu dev (yarn dev lub npm run dev).
Wszystkie funkcje, które będę potrzebne do pracy, zostały napisane w postaci pustej funkcji z argumentami.
Podczas warsztatu kolejno będziemy implementować wszystkie funkcje, aż do uzyskania kodu, który potraci wyrenderować JSX w postaci drzewa DOM.
Do pracy została przygotowana prosta aplikacja. Jej kodu nie modyfikujemy.
Wszystkie funkcje do implementacji znajdują się w pliku lib/OwnReact.[ts/js].
Z tego artykułu zapożyczony został sposób wykonania pętli i częściowo rekoncyliacji. Build your own React - Rodrigo Pombo
Build your own React in 90 lines of JavaScript
Rozpoczniemy od zamiany JSX na wywołania React.
Gdy webpackw połączeniu ze standardowym pluginem do translacji JSX na wywołania React (@babel/plugin-transform-react-jsx), napotka fragment kodu napisy w JSX (<App /> albo <div>...</div>), zamienia ostre nawiasy na wywołanie funkcji React.createElement.
Właśnie dlatego w każdym pliku, który wykorzystuje JSX, musi być zaimportowany React.
Przykładowo poniższy fragment:
<div>
<span>test1</test>
<button>test2</button>
</div>zamieni na:
React.createElement(
'div',
null,
React.createElement('span', null, 'test1'),
React.createElement('button', null, 'test1'),
)Funkcja React.createElement jako argumenty dostaje:
- type — typ elementu React. Może to być nazwa elementu DOM albo funkcja komponentu.
- props - propsy elementu React użyte do renderowania
- pozostałe kolejne argumenty — dzieci elementu React
Zwraca element React. Jest to struktura danych opisująca element aplikacji. Każdy element React składa się z 2 podstawowych pól:
- type — typ elementu React. Może to być nazwa elementu DOM albo funkcja komponentu.
- props - propsy elementu
interface ReactElement {
type: Function | string; // typ elementu React, może to być funkcja, która jest komponentem albo string z nazwą elementu DOM
props: Object; // propsy elementu React
}Zaimplementować funkcję createElement.
Jako wynik zwraca element React. Wszystkie dzieci elementu należy zamienić na tablicę i stworzyć propsa children i zmergować z propsami przekazanymi jako drugi argument funkcji.
W konsoli widzimy, że funkcja render dostała w argumencie poprawny element React reprezentujący wejście do aplikacji (<App /> w pliku src/index.tsx)
Do abstrakcji pracy do wykonania React wykorzystuje tak zwany Fiber. Jest to struktura opisująca pracę do wykonania na danym elemencie React. Fiber posiada rodzaj, typ i propsy elementu React, z którym jest związany. Każdy Fiber może posiadać powiązanie do innych Fiberów:
- rodzica
- dziecka
- rodzeństwo
Każdy Fiber posiada co najmniej jedno powiązanie. Dzięki temu powstaje powiązana lista elementów, po której można wydajnie iterować.
W naszej implementacji React będziemy mieć 3 rodzaje Fiberów:
- związanego z kontenerem aplikacji (
<div id="root" />) - związanego ze zwykłym elementem DOM (
div,span, etc.) - związanego z komponentem funkcyjnym (
App)
interface Fiber {
tag: number; // typ Fibera
stateNode: HTMLElement | null; // element DOM, z którym jest związany Fiber
type: Function | string; // typ elementu React, z którym jest związany Fiber
props: Object; // propsy Elementu React, z którym jest związany Fiber
return: Fiber | null; // powiązanie do Fibera, który jest rodzicem dla tego Fibera
sibling: Fiber | null; // powiązanie do Fibera, który jest rodzeństwem dla tego Fibera
child: Fiber | null; // powiązanie do Fibera, który jest bezpośrednim dzieckiem dla tego Fibera
}Zaimplementować funkcję createFiber oraz render.
- Zaimplementować funkcję
createFiber- dostaje na wejściu obiekt z polami:
element- element, dla którego tworzony jest Fibertag- rodzaj Fibera (FunctionComponentlubHostRootlubHostComponent)parentFiber- Fiber rodzicastateNode- element DOM związany z Fiberem
- powinna zwrócić obiekt zgodny z interfejsem.
- pola
siblingorazchildinicjalne mają wartośćnull. - resztę pól zainicjować odpowiednio, wykorzystując argument funkcji.
- dostaje na wejściu obiekt z polami:
- Dodać implementację funkcji
render- dostaje dwa argumenty:
element- element React wykorzystany jako wejście do aplikacjicontainer- element DOM wykorzystany jako kontener aplikacji
- wewnątrz funkcji stworzyć Fiber związany z kontenerem aplikacji, a następnie ustawić go jako referencję do:
workInProgressRoot- reprezentująca Fiber związany z kontenerem aplikacjiworkInProgress- reprezentująca aktualny Fiber
- do stworzenia Fibera wykorzystać
tag- flagaHostRootstateNode- referencja do kontenera aplikacjielement- element React bez typu, który posiada jednego propsa:children, który jest jednoelementową tablicą z elementem React, przekazanym w argumencie funkcjirender.
- dostaje dwa argumenty:
Funkcja render po wywołaniu tworzy pierwszy Fiber, reprezentujący początek pracy do wykonania i zapisuje referencję reprezentujące Fiber związany z kontenerem aplikacji oraz aktualną jednostkę pracy do wykonania.
Aby nasza biblioteka działała, potrzebuje mechanizmu nieskończonej pętli, która będzie na bieżąco sprawdzała, czy jest jakaś praca do wykonania.
W najnowszych przeglądarkach dostępny jest mechanizm pozwalający wykorzystać czas bezczynności przeglądarki.
window.requestIdleCallback() MDN
Wykorzystamy go, aby rozpocząć pracę nad Fiberami w momencie, gdy przeglądarka będzie bezczynna.
Funkcja przyjmuje funkcję do wykonania w pierwszym czasie bezczynności.
Punktem wejściowym naszej aplikacji jest funkcja performSyncWorkOnRoot - te funkcję wykorzystamy do nieskończonej pętli aplikacji.
Zaimplementować funkcję performSyncWorkOnRoot oraz wykorzystać requestIdleCallback do stworzenia pętli aplikacji.
- wywołać
requestIdleCallbacki przekazaćperformSyncWorkOnRoot - dodać ciało funkcji
performSyncWorkOnRoot- dodać
if, który sprawdza, czy jest jakaś praca do wykonania (workInProgress)- jeśli jest jakaś praca rozpocząć pętlę
while, która będzie pracować tak długo aż jest jakaś praca do wykonania- wewnątrz pętli
whilewywołujemy funkcjęperformUnitOfWorki przekazujemy jejworkInProgress - wynik zapisujemy do
workInProgress
- wewnątrz pętli
- jeśli jest jakaś praca rozpocząć pętlę
- dodać
W konsoli widzimy, że wykonała się funkcja performUnitOfWork, po czym nieskończona pętla się zatrzymała, ponieważ performUnitOfWork na razie zwraca null.
Teraz zajmiemy się rozpoczęciem wykonania pracy.
Funkcja beginWork jest odpowiedzialna za przygotowanie Fibera do procesu rekoncyliacji.
Dla komponentów niefunkcyjnych wystarczy po prostu przekazać do funkcji reconcileChildren dzieci, czyli props children Fibera.
Jednak dla komponentów funkcyjnych należy wywołać odpowiednio ten komponent, aby uzyskać jego dzieci.
Na koniec funkcja powinna zwrócić wartość pola child.
W procesie rekoncyliacji, jeśli Fiber posiada jakieś dzieci, wartość pola child zostanie zainicjowana.
Tym zajmiemy się w kolejnym zadaniu.
Zaimplementować funkcję beginWork i rozpocząć wykonywanie pracy w funkcji performUnitOfWork.
- W funkcji
performUnitOfWork- stworzyć zmienną (
let) o nazwienexti zainicjować jej wartość wywołaniem funkcjibeginWork - zwrócić zmienną
next
- stworzyć zmienną (
- W funkcji
reconcileChildrendodać zwracanienull- implementacją zajmiemy się w następnym zadaniu - Implementacja funkcji
beginWork- funkcja jako argument (
unitOfWork) dostaje Fiber - wewnątrz funkcji w zależności od wartości pola
tagwywołujemy odpowiednio funkcjęreconcileChildren- jako pierwszy argument przekazujemy Fiber dostępny w domknięciu funkcji
beginWork. - jako drugi przekazujemy tablicę elementów, które są dziećmi.
- dla komponentów funkcyjnych (
FunctionComponent) musimy wywołać komponent funkcyjny, który znajduje się pod polemtypeFibera przekazanego do funkcjibeginWork- funkcja komponentu zwraca element React.
- do funkcji komponentu przekazujemy propsy znajdujące się w polu
propsFibera przekazanego do funkcjibeginWork
- dla komponentów związanych z kontenerem aplikacji lub elementem DOM (
HostRootlubHostComponent)
- dla komponentów funkcyjnych (
- jako pierwszy argument przekazujemy Fiber dostępny w domknięciu funkcji
- na koniec zwracamy dziecko, które znajduje się w polu
childFibera przekazanego do funkcjibeginWork
- funkcja jako argument (
Brak różnic z poprzednim etapem. W konsoli widzimy tylko, że wywołana została funkcja reconcileChildren.
Teraz zajmiemy się kluczowym mechanizmem, czyli procesem rekoncyliacji.
Proces ten polega na ustaleniu zależności między elementami.
Dla każdego Fibera, który posiada jakieś dzieci, zostaną utworzone nowe Fibery i zapisane odpowiednie powiązania.
Pierwszym powiązaniem jest child reprezentujący pierwsze dziecko.
Drugim jest sibling reprezentującym sąsiedni Fiber — rodzeństwo.
Ostatnim jest return reprezentującym rodzica Fibera.
Słowo return odnosi się do tego, w jaki sposób będziemy się poruszać po stworzonej sieci zależności.
Zaimplementować funkcję reconcileChildren
- Stworzyć
if, który będzie sprawdzał, czy argumentchildrenjest tablicą bądź obiektem- jeśli nie jest — ustawić wartość pola Fibera
childnanull - jeśli jest:
- stworzyć zmienną (
let), która będzie przechowywać referencję do ostatnio iterowanego elementu- zainicjować wartością
null
- zainicjować wartością
- stworzyć stałą (
const) na tablicę elementów- w zależności od tego, czy argument
childrenjest tablicą czy obiektem, inaczej inicjujemy wartość stałej- jeśli argument
chidlrenjest obiektem, tworzymy z niego jednoelementową tablicę i przypisujemy do stałej - jeśli jest tablicą, przypisujemy wartość tablicy do stałej.
- jeśli argument
- w zależności od tego, czy argument
- po stworzeniu tablicy elementów iterujemy po niej.
- dla każdego elementu musimy stworzyć Fiber, wykorzystując wcześniej napisaną funkcję
createFiber.- do tego potrzebujemy wyliczyć wartość pola
tagdla nowo tworzonego Fibera- w zależności od tego, czy typem pola
typejest funkcja czy nie możemy w prosty sposób ustalić, czy mamy do czynienia z komponentem funkcyjnym, czy związanym z elementem DOM (FunctionalComponentlubHostComponent).- użyj
typeof.
- użyj
- w zależności od tego, czy typem pola
- jako
parentFiberdajemy Fiber dostępny w domknięciu funkcji - jako
elementelement, który jest iterowany
- do tego potrzebujemy wyliczyć wartość pola
- po stworzeniu nowego Fibera w zależności od tego, czy iterujemy pierwszy element, czy nie:
- dla pierwszego iterowanego elementu zapisujemy referencję do nowego Fibera w polu
childFibera dostępnego w domknięciu funkcji - dla każdego kolejnego iterowanego elementu zapisujemy referencję do nowego Fibera w polu
siblingpoprzednio iterowanego Fibera
- dla pierwszego iterowanego elementu zapisujemy referencję do nowego Fibera w polu
- na koniec zapisujemy referencję do stworzonego Fibera w zmiennej przechowującej referencję do ostatnio stworzonego Fibera.
- dla każdego elementu musimy stworzyć Fiber, wykorzystując wcześniej napisaną funkcję
- stworzyć zmienną (
- jeśli nie jest — ustawić wartość pola Fibera
Utworzone zostają Fibery dla wszystkich elementów z odpowiednim powiązaniem.
Następnie zajmiemy się zakończeniem pracy na Fiberze. W procesie tym dla Fiberów związanych z elementem DOM zostaną utworzone te elementy.
Zaimplementować funkcję completeUnitOfWork i wykorzystać w funkcji performUnitOfWork
- W funkcji
performUnitOfWorkpo wywołaniubeginWorkjeśli wartość, na którą wskazuje zmiennanexttonullwywołać funkcjęcompleteUnitOfWork.- Wynik wywołania zapisać w zmiennej
next
- Wynik wywołania zapisać w zmiennej
- Dodać implementację funkcji
completeUnitOfWork- argument
unitOfWorkfunkcji to Fiber - na początek funkcja ustawia aktualnie wykonywaną jednostkę pracy (
unitOfWork) w globalnej zmiennejworkInProgress. - następnie tworzymy pętlę
do { ... } while (), która pracuje tak długa ażworkInProgressjest różne odnull - wewnątrz pętli:
- sprawdzamy, czy aktualnie ustawiona jednostka pracy
workInProgressjest Fiberem typuHostComponent- jeśli jest tworzymy element DOM wykorzystując
document.createElementi jako argument przekazując wartość polatype
- jeśli jest tworzymy element DOM wykorzystując
- następnie sprawdzamy, czy aktualnie ustawiona jednostka pracy
workInProgressposiada jakieś rodzeństwo (polesibling).- jeśli posiada — przerywamy pętlę i zwracamy rodzeństwo, które stanie się następną aktualną jednostką pracy.
- sprawdzamy, czy aktualnie ustawiona jednostka pracy
- argument
Odpowiednie Fibery posiadają zapisaną referencję do stworzonych elementów DOM.
Po zakończeniu pracy musimy pokazać jej wyniki. W tym celu przejdziemy po wcześniej stworzonej strukturze i dodamy elementy DOM do kontenera aplikacji aby zostały wyrenderowane przez przeglądarkę. Dzięki wcześniej stworzonym powiązaniom, rekurencyjne przejście po wszystkich węzłach w odpowiedniej kolejności będzie proste.
Zaimplementować funkcję commitWork i wykorzystać w funkcji performSyncWorkOnRoot do rozpoczęcia procesu dodawania elementów DOM do kontenera aplikacji.
- W funkcji
performSyncWorkOnRootjeśli pętlewhilezakończy pracę, wywołujemy funkcjęcommitWork- jako argument przekazujemy dziecko Fibera związanego z kontenerem aplikacji (
workInProgressRoot).
- jako argument przekazujemy dziecko Fibera związanego z kontenerem aplikacji (
- Dodanie implementacji funkcji
commitWork- jako argument dostaje Fiber
- na początek sprawdzamy, czy przekazany Fiber posiada w polu
stateNodereferencję do elementu DOM- jeśli posiada — szukamy najbliższego rodzica związanego z elementem DOM.
- tworzymy zmienną przechowującą referencję do rodzica i inicjujemy ją referencją rodzica Fibera przekazanego do funkcji.
- następnie wykorzystujemy referencję do rodzica zapisaną w polu
return.- sprawdzamy, czy rodzic posiada referencję do elementu DOM w polu
stateNode- jeśli nie posiada, zapisujemy referencję do rodzica rodzica i powtarzamy proces, aż znajdziemy Fiber z elementem DOM.
- sprawdzamy, czy rodzic posiada referencję do elementu DOM w polu
- po znalezieniu rodzica z elementem DOM wykorzystujemy metodę elementu DOM
appendChild.- jako argument przekazujemy element DOM związany z Fiberem, który jest argumentem funkcji.
- jeśli posiada — szukamy najbliższego rodzica związanego z elementem DOM.
- na koniec sprawdzamy, czy Fiber, który jest argumentem funkcji, ma dziecko lub rodzeństwo.
- jeśli ma dziecko — wykonujemy dla dziecka zagłębienie rekurencyjne, przekazując do funkcji
commitWorkdziecko (polechild). - jeśli ma rodzeństwo — wykonujemy dla rodzeństwa zagłębienie rekurencyjne, przekazując do funkcji
commitWorkrodzeństwo (polesibling).
- jeśli ma dziecko — wykonujemy dla dziecka zagłębienie rekurencyjne, przekazując do funkcji
Struktura DOM widoczna.
Ostanim zadaniem jest aktualizacja właściwości elementów DOM. Elementy powinny wyświetlać tekst, nadawać style inline oraz dodawać nasłuchiwanie na zdarzenia.
Zaimplementować funkcję updateProperties i wykorzystać w funkcji commitWork
- Wywołać
updatePropertiesw funkcjicommitWork- jako argument przekazać Fiber z domknięcia funkcji
commitWork
- jako argument przekazać Fiber z domknięcia funkcji
- Dodać implementację funkcji
updateProperties- jako argument dostaje Fiber
- napisać funkcję pomocniczą sprawdzającą, czy dany prop jest zdarzeniem.
- funkcja jako argument powinna przyjmować nazwę propa i sprawdzać, czy zaczyna się on znakami
on.
- funkcja jako argument powinna przyjmować nazwę propa i sprawdzać, czy zaczyna się on znakami
- napisać funkcję pomocniczą sprawdzającą, czy dany prop jest obiektem styli.
- funkcja jako argument powinna przyjmować nazwę propa i sprawdzać, czy równa się
style.
- funkcja jako argument powinna przyjmować nazwę propa i sprawdzać, czy równa się
- napisać funkcję pomocniczą sprawdzającą, czy dany prop jest tekstem.
- funkcja jako argument powinna przyjmować wartość propa i sprawdzać, czy jest on łańcuchem znaków lub liczbą.
- wewnątrz funkcji rozpocząć iterowanie po polach obiektu propsów dostępnego w polu
props- Wykorzystać
Object.keyslubObject.entries - podczas iteracji po propsach wykorzystujemy wcześniej napisane funkcje pomocnicze do sprawdzania iterowany prop.
- jeśli to tekst — ustawiamy wartość pola
textContentw elemencie DOM związanym z Fiberem (polestateNode). - jeśli to zdarzenie:
- nazwę propa zamieniamy na małe litery i usuwamy 2 pierwsze, aby uzyskać nazwę zdarzenia.
- wykorzystując metodę elementu DOM
addEventListenerdodajemy na elemencie nasłuchiwanie na zdarzenie- jako pierwszy argument przekazujemy nazwę zdarzenia.
- jako drugi wartość iterowanego propa.
- jeśli to obiekt styli
- iteruj się do wszystkich elementach obiektu styli
- dla każdego stylu zaktualizuj wartość pola
styleelementu DOMObject.entries(prop).forEach(([cssProperty, value]) => { fiber.stateNode.style[cssProperty] = value; });
- dla każdego stylu zaktualizuj wartość pola
- iteruj się do wszystkich elementach obiektu styli
- jeśli to tekst — ustawiamy wartość pola
- Wykorzystać
Wyrenderowana aplikacja, która reaguje na kliknięcia w przyciski (logi w konsoli).
