Frontend

Nowoczesny projekt webowy z Reactem i TypeScriptem przy użyciu CRA

Jednym z podstawowych elementów, które musi zapewniać nasz stos technologiczny, gdy chcemy, żeby był zdrowy i służył nam przez lata bez obaw o popadanie w przestarzałość, są narzędzia automatyzujące powtarzalne i “mało ciekawe” zadania. Kilka miesięcy temu światek JavaScriptowy głośno narzekał na “przemęczenie” nowymi narzędziami, ciągle zmieniającymi się trendami i ideami w konfiguracji i codziennej obsłudze naszych projektów. Każdego dnia pojawiały się nowe projekty, każdy z nich wymagał nauczenia się go i dodawał złożoności do naszego rozwiązania.


Adam Bar. Full-stack developer w Bright Inventions, gdańskim software housie specjalizującym się w rozwiązaniach mobilnych, IoT oraz Blockchainie. Doświadczony w kilku stosach technologicznych, ostatnio wierny TypeScriptowi. Pasjonat Mobile Weba, twórca What Web Can Do Today, strony o możliwościach Weba na urządzeniach mobilnych. Trener technologii front-endowych w Bottega IT Minds. I choć uwielbia proste struktury, proste reguły i porządek, których nieraz próżno w Webie szukać, to właśnie technologie webowe są jego programistyczną pasją.


Jak wystartować nowoczesny projekt webowy

Z tych skazanych na porażkę prób wyłoniło się na szczęście coś lepszego – Facebook na potrzeby Reacta przygotował rozwiązanie nowej generacji. create-react-app (często skracane do CRA) to zintegrowane narzędzie, w którym “z pudełka” dostajemy webpacka, który pakuje nasz kod, Babela dającego wsparcie dla ES6+, potężny serwer deweloperski udostępniający nam wszystkie współczesne programistyczne pomoce, środowisko do testów jednostkowych itd. W pakiecie dostajemy nawet gotowego Service Workera, by nasza aplikacja mogła stać się Progresywną Aplikacją Webową (PWA).

Całość spakowana jest w pojedynczej paczce NPM, którą dodajemy jako zwyczajną zależność do projektu. Paczka ta abstrahuje od nas wszystko, co nie jest faktycznym kodem naszej aplikacji. Jeśli nie musimy schodzić w głąb tej maszynerii, cały proces budowania jest przed nami schowany. Nie musimy konfigurować żadnych narzędzi, nie musimy ustawiać środowiska do testów, nie musimy pisać żadnego dodatkowego kodu zanim usiądziemy do naszej faktycznej aplikacji – od CRA dostajemy “gotowca”, w którym od razu możemy pisać kod odpowiedzialny za nasze funkcjonalności. CRA jest rozwiązaniem, na którym powinny bazować praktycznie wszystkie małe i średnie projekty Reactowe – dzięki temu cała zawiłość konfiguracji dla nas nie istnieje.

npx create-react-app my-app
cd my-app
npm start

Te trzy linijki to wszystko, czego potrzebujemy, żeby wystartować projekt i zobaczyć stronę “Hello World” serwowaną z lokalnego serwera deweloperskiego z automatycznym odświeżaniem przy każdej zmianie kodu źródłowego aplikacji.

“Hello World” od create-react-app

Tym prostym krokiem właśnie oszczędziliśmy sobie tygodnia zmagań i bólu głowy. Ale domyślna konfiguracja create-react-app nie daje nam wsparcia dla TypeScriptu. TypeScript to świetne rozwiązanie dla świata JavaScriptu. Pracując w nim od jakiegoś czasu mam wrażenie, że JavaScript bez TypeScripta jest jak ciasto bez czekolady – można je zjeść, ale gdzie ta przyjemność?

Na szczęście, nie jestem jedyny, który tak myśli a świat Open Source nie znosi próżni. Istnieje fork skryptów budujących create-react-app, który wymienia Babela na kompilator TypeScriptu. Zwie się on przewidywalnie: create-react-app-typescript. Zacznijmy więc jeszcze raz, tym razem z TypeScriptem:

npx create-react-app my-app --scripts-version=react-scripts-ts

cd my-app

npm start

Zwróćmy uwagę na coś istotnego – nie zastąpiliśmy create-react-app’a całkowicie i nie wywołujemy skryptu z forka bezpośrednio. CRA jest zaprojektowane w sposób otwarty na tego typu modyfikacje bez konieczności przywiązywania się do forka, który może w każdej chwili zostać porzucony przez autora. Nadal używamy bezpośrednio create-react-app, wymieniamy tylko część jego wewnętrznej machinerii (react-scripts) na klona, który lubi się z TypeScriptem (react-scripts-ts). Taka konstrukcja zapewnia, że kiedy create-react-app zostanie zaktualizowane, jesteśmy nadal jego użytkownikami i nasz proces dewelopmentu, budowania i testowania może nadal zostać zaktualizowany za pomocą prostego npm update.

A oto, co nam wyszło z tego połączenia – w zasadzie to, co poprzednio, tylko z TSX (TypeScriptową odmianą JSX-a).

”Hello World” od create-react-app-typescript

A co, jeśli nasze wymagania przerosną to, co oferuje create-react-app i musimy zagłębić się na poziom zawiłości webpacka albo zrekonfigurować runner testów jest? Mamy taką możliwość, ale niestety w takim momencie jesteśmy zmuszeni pożegnać się z tą przyjemną abstrakcją, która chroniła nas przed zajmowaniem się około trzydziestoma zależnościami i około dwunastoma plikami konfiguracyjnymi. Kiedy uruchomimy polecenie npm run eject, nasz projekt zostanie przepisany automatycznie w taki sposób, że wszystkie te elementy trafią bezpośrednio do naszych zależności i nie będziemy mogli więcej użyć npm update, żeby zaktualizować całą deweloperską machinerię. Zanim to zrobimy, powinniśmy być przekonani, że nie ma innej drogi. A w znacznej części przypadków, dopóki nasze wymagania nie odbiegają od standardu, nie powinniśmy mieć takiej potrzeby.

Co nam daje TypeScript w Reactcie?

Gdy mamy już nowy projekt z TypeScriptem, automatycznie korzystamy z jego dobrodziejstw i jesteśmy chronieni przed całą klasą problemów związanych z literówkami i złymi typami. Ale TypeScript w Reactcie daje nam jeszcze więcej.

Na początek, jak w przypadku każdej biblioteki, dla której chcemy korzystać z wsparcia TypeScriptu, musimy mieć w projekcie referencję do deklaracji typów. Jak zwykle, użyjemy do tego projektu DefinitelyTyped i dostarczanych przez niego definicji:

npm install --save-dev @types/react @types/react-dom

Podstawy – Komponenty

W większości przypadków, komponenty w Reactcie są klasami ES6, które rozszerzają dostarczoną przez Reacta klasę Component. TypeScript dodaje do niej dwa generyczne argumenty – pierwszy definiuje jakie komponent przyjmuje propsy, drugi opisuje jego wewnętrzny stan (state).

interface HelloProps {
   greeting: string
}

interface HelloState {
   wasDisplayed: boolean
}

class HelloWorldComponent extends React.Component<HelloProps, HelloState> {}

Dodając te dwie definicje, przede wszystkim zyskujemy mądre podpowiadanie w naszym IDE. Ale nie tylko. W powyższym przykładzie oczekujemy właściwości greeting i oznaczyliśmy ją jako obowiązkowa (nie ma znaku “?” przed dwukropkiem ani unii typów akceptującej wartość undefined). Kompilator umie w tym momencie zapobiec używaniu tego komponentu bez przekazania wartości właściwości greeting.

Kompilator zapewnia kompletność propsów.

(Nie)mutowalność propsów i stanu

Ale to nie wszystko. React oczekuje, że zarówno propsy, jak i stan są niemutowalne – nigdy nie modyfikujemy ich wartości bezpośrednio. Propsy służą tylko do dostarczania danych do komponentu a stan możemy modyfikować tylko używając dedykowanej metody setState. Dzięki temu, że propsy i stan w TypeScriptowej deklaracji klasy Component opisane są typem Readonly<>, jesteśmy chronieni przed przypadkowym modyfikowaniem ich wartości:

Kompilator zapewnia niemutowalność propsów.

Nie mamy także możliwości dopisywania do nich nowych właściwości:

Kompilator zabezpiecza propsy przed dopisywaniem.

Musimy być jednak świadomi pewnego ograniczenia. Deklaracja Readonly<> nie działa wgłąb (nie jest rekurencyjna) – zapewnia nam jedynie ochronę na najwyższym poziomie obiektu i TypeScript nie chroni nas przed przypadkową modyfikacją obiektów niżej w drzewie propsów lub stanu:

interface State {
   inner: {stuff: string}
}

// to jest wciąż możliwe

this.state.inner.stuff = "wewnętrzne właściwości są wciąż mutowalne"

Dopóki deklaracje Reacta nie zaimplementują wsparcia dla rekurencyjnego Readonly (które jest możliwe począwszy od TypeScripta 2.8), najlepsze co możemy (i powinniśmy) zrobić, to zapewnić niemutowalność po naszej stronie, oznaczając wszystkie właściwości propsów i stanu typem Readonly<> (a także ich własne właściwości, w nieskończoność…).

interface State {
   inner: Readonly<{stuff: string}>
}

// jesteśmy znów bezpieczni
this.state.inner.stuff = "ten kod się już nie skompiluje"

Poprawność setState

Kolejną klasą błędów, przed którą chroni nas TypeScript automatycznie, są wywołania setState z niewłaściwym obiektem. Pierwszy parametr tej funkcji opisany jest dość zawiłą deklaracją typu:

state: ((prevState: Readonly<S>, props: P) => (Pick<S, K> | S | null)) | (Pick<S, K> | S | null),

Rozbierając tę deklarację na drobne elementy, możemy zobaczyć, że musimy przekazać albo funkcję zwracającą wartość typu Pick<S, K> | S | null, albo podać taką wartość bezpośrednio. A zapis Pick<S, K> | S | null oznacza – czytając od tyłu – że możemy podać null, pełen obiekt stanu (S) albo obiekt skonstruowany z podzbioru kluczy stanu (Pick<S, K>). Upraszczając, nie mamy możliwości podania nowego obiektu stanu, który nie pasuje do naszej definicji stanu. Oto, jak wtedy zareaguje kompilator TypeScriptu:

Kompilator zapewnia poprawne typowanie obiektu stanu

Komponenty bezstanowe

Doświadczeni programiści Reacta znają zapewne najprostszą z dostępnych form komponentów – bezstanowe funkcje. Są to funkcje “czyste” (pure), otrzymujące propsy na wejściu i zwracające element JSX. Z punktu widzenia typowania, zazwyczaj wystarczy traktować je jak każde inne funkcje – specyfikując typy parametrów i opcjonalnie także typ zwracany:

interface InputProps {
   value: any
   onChanged: () => void
}

function Input(props: InputProps) {
   return <input type="text" onChange={props.onChanged} value={props.value}/>
}

Mamy tu jednak drobny problem. Jeśli chcielibyśmy wyspecyfikować wartości propTypes albo defaultProps dla naszego bezstanowego komponentu, TypeScript będzie narzekał, gdyż zwykłe funkcje nie mają takich właściwości.

Nie możemy dodać deklaracji PropTypes do zwykłej funkcji.

Możemy obejść ten problem deklarując nasz komponent w trochę inny sposób:

const Input: React.StatelessComponent<InputProps> = function (props) {
   return <input type="text" onChange={props.onChanged} value={props.value}/>
}

Input.propTypes = {
   value: PropTypes.any.isRequired
}

Ale zatrzymajmy się na chwilę i zastanówmy się, co próbujemy osiągnąć. Czy TypeScript sam w sobie nie daje nam tego samego (albo lepszego) poziomu bezpieczeństwa, jednocześnie oferując dużo przyjemniejszą składnię? Osobiście nie widzę sensu stosowania deklaracji propTypes w projekcie TypeScriptowym.

Zdarzenia

Następnym przystankiem w świecie Reactowym, gdzie możemy wesprzeć się silnym typowaniem są zdarzenia. Zajmujemy się nimi za każdym razem, gdy nasz komponent ma reagować na działania użytkownika. Spójrzmy ponownie na nasz przykładowy komponent Input:

interface InputProps {
   value: any
   onChanged: () => void
}

function Input(props: InputProps) {
   return <input type="text" onChange={props.onChanged} value={props.value}/>
}

Właściwość onChange, jak każdy handler zdarzeń, jako swój jedyny parametr przyjmuje obiekt zdarzenia. Zapiszmy to w definicji naszego interfejsu InputProps. Mamy do dyspozycji typ Event definiowany bezpośrednio przez standard HTML – spróbujmy go użyć w naszym przykładzie:

onChanged: (event: Event) => void

Niestety, to nie wygląda jak typ zdarzenia, który nas interesuje:

React nie używa natywnych zdarzeń HTML. 

Ten dość zawiły błąd podaje nam między innymi oczekiwany przez Reacta typ zdarzenia – spójrzmy na ostatnią linię. Obiekt przekazany przez Reacta jest typu ChangeEvent<HTMLInputElement>, który to nie rozszerza typu Event ze standardu HTML. To jest celowe, gdyż React nie używa natywnych zdarzeń bezpośrednio, tylko swojego własnego zamiennika – zdarzeń syntetycznych.

Kiedy zmienimy typ zdarzenia w naszej deklaracji na syntetyczne zdarzenie wyznaczone przez rodzaj zdarzenia i rodzaj elementu, na którym to zdarzenie zaszło, TypeScript jest zadowolony:

onChanged: (event: React.ChangeEvent<HTMLInputElement>) => void

Taki zapis daje nam najwyższy możliwy poziom szczegółowości. Ogranicza on jednak elastyczność. Nie możemy już użyć tego samego handlera dla zdarzeń wywołanych na różnych elementach HTML (np. <input> i <select>):

Typy zdarzeń na różnych elementach HTML nie są kompatybilne.

Dostajemy błąd, z którego możemy wyczytać, że nie da się przypisać obiektu typu HTMLSelectElement do HTMLInputElement. Cóż, faktycznie są to inne typy a my zaplanowaliśmy nasz handler jedynie do obsługi tego pierwszego, więc nie możemy go użyć wprost. Podobny problem wystąpi gdy będziemy chcieli ponownie użyć handler do zdarzeń różnego typu (np. change, click itd.) – ChangeEvent<T> i MouseEvent<T> nie są kompatybilne.

Na szczęście TypeScript dostarcza szeroki zestaw funkcjonalności, które mogą nam pomóc w tej sytuacji. Podstawową techniką może być użycie wspólnego przodka w hierarchii dziedziczenia obu typów – w tym przypadku będzie to SyntheticEvent. Gorzej sytuacja wygląda z generycznym parametrem opisującym typ elementu HTML, na którym zdarzenie zostało wywołane. Możemy próbować użyć bazowego typu HTMLElement – i w wielu przypadkach nam to wystarczy. Ale często używamy generycznych handlerów zdarzeń w celu wspólnej obsługi wielu pól formularza. W takiej sytuacji potrzebujemy dostępu do atrybutu value, a HTMLElement takiego nie posiada, nie ma też żadnego typu bazowego dla typów kontrolek z formularzy. Mamy co najmniej dwie drogi obejścia tego problemu. Pierwszy to zdać się na łaskę unii typów i wyspecyfikować wszystkie typy, których się spodziewamy, a atrybuty dostępne we wszystkich spośród nich będą dla nas dostępne:

onGenericEvent: (event: React.SyntheticEvent<HTMLSelectElement | HTMLInputElement>) => void

To wygodne i dość jasne w zapisie, ale nie skaluje się dobrze jeśli chcielibyśmy obsłużyć więcej niż kilku typów elementów naraz. Drugie rozwiązanie używa strukturalnej kompatybilności – kolejnego niezwykle sprytnego rozwiązania zastosowanego w systemie typów TypeScriptu, które pozwala nam na definiowanie i porównywanie typów wyłącznie po ich strukturze, a nie nazwach. W naszym przypadku, żeby odczytać wartość atrybutu value dowolnego obiektu, wystarczy jawnie wyspecyfikować w typie parametru, że tylko ta wartość nas interesuje:

onGenericEvent: (event: React.SyntheticEvent<{value: string}>) => void

System typów TypeScriptu pozwala nam wybrać jaki poziom kompromisu między elastycznością, a specyficznością jest właściwy w naszym przypadku.

Ciężki przypadek generycznego setState

Niestety nie wszystko jest usłane różami. W typowym scenariuszu obsługi formularzy w Reactcie ustawiamy właściwości stanu w komponencie na podstawie wartości elementów formularza w momencie, gdy zajdzie ich zdarzenie change:

<input type="text" name="firstName"

      onChange={event => this.setState({firstName: event.currentTarget.value})} />

Możemy próbować generalizować taki kod używając nazwy elementu input i zakładając, że jest ona identyczna, jak przeznaczony na wartość tego elementu klucz w obiekcie stanu. Z pomocą przychodzi nam ES6 i możliwość definiowania nazw właściwości obiektu dynamicznie za pomocą kwadratowych klamr:

<input type="text" name="firstName"

      onChange={event => this.setState({[event.currentTarget.name]: event.currentTarget.value})} />

Jak wcześniej widzieliśmy, TypeScript zapewnia, że klucze obiektu, który przekazujemy do setState pasują do faktycznych kluczy stanu naszego obiektu. Tutaj jednak kompilator TypeScriptu (przynajmniej w wersji 2.6.1) nie jest wystarczająco cwany, żeby wywnioskować jakie wartości może przybrać pole event.currentTarget.name, mimo że my doskonale wiemy, że w tym przypadku może to być tylko i wyłącznie “firstName”. Dla TypeScripta jest to po prostu string, a taki typ jest niestety zbyt szeroki, żeby można było go użyć jako poprawnego klucza obiektu przekazanego do naszego wywołania setState:

TypeScript nie potrafi określić dynamicznie, że przekazujemy właściwy obiekt do setState.

Możemy obejść ten problem informując kompilator TypeScriptu za pomocą rzutowania, jaki jest zakres wartości, których możemy się spodziewać jako wartości event.currentTarget.name (zakładając, że typ State jeden do jednego reprezentuje pola naszego formularza). Konstrukcja keyof State informuje kompilator, że wartości będą ograniczały się do tych zdefiniowanych przez strukturę interfejsu State:

<input type="text" name="firstName"

      onChange={e => this.setState({[e.currentTarget.name as keyof State]: e.currentTarget.value})}/>

Alternatywnie, jeśli nie chcemy rzutowania, możemy uspokoić kompilator dbając o to, żeby zawsze przekazywać cały obiekt stanu (włącznie z zamierzonymi modyfikacjami). Używamy wtedy innej funkcjonalności niż częściowa aktualizacja stanu, ale zazwyczaj nie powinno to mieć funkcjonalnego znaczenia:

<input type="text" name="firstName"

      onChange={e => this.setState({...this.state, [e.currentTarget.name]: e.currentTarget.value})}/>

Użyłem tu jeszcze niestandardowego spread operatora na obiekcie. Tworzy on kopię obiektu this.state i zamienia (lub dodaje) pojedyncze właściwości do tej kopii – w naszym przypadku ustawi właściwości firstName wartość odczytaną z atrybutu value z inputa, która to odzwierciedla co użytkownik wpisał do naszego pola tekstowego.

I co jeszcze?

Jak można zauważyć, wszystkie elementy HTML mają zdefiniowane odpowiadające ich atrybutom typy HTML*Element. Możemy ich używać kiedykolwiek operujemy na elementach HTML. W podobny sposób w interfejsie CSSProperties opisana jest znaczna część własności CSS-owych. Możemy z tego interfejsu skorzystać, jeśli stosujemy jakąkolwiek formę inline’owych stylów w naszych komponentach. Dostaniemy w zamian podpowiadanie kodu w IDE, a w przypadku niektórych własności także walidację poprawności wartości:

TypeScript pomoże w sprawdzeniu poprawności CSS-a.

TypeScript w Reduksie

Skoro wiemy już co daje nam użycie TypeScriptu w Reactcie, pójdźmy dalej standardową ścieżką, którą zazwyczaj podążają obecnie projekty front-endowe i dołączmy Reduksa. Niestety, jeśli się o to specjalnie nie zatroszczymy, Redux sam w sobie nie jest silnie typowany a implementacja reducerów sprowadza się do instrukcji switch i akcji o dowolnej strukturze, które w TypeScripcie najprościej wyrazić typem any, skutecznie dławiącym całą moc TypeScriptu. Ale przy odrobinie wysiłku możemy tę moc okiełznać także w przypadku Reduksowych reducerów, dzięki czemu nasz będzie nie tylko silnie typowany, ale i czystszy i bardziej czytelny.

Podstawowe typy

Rozłóżmy na czynniki pierwsze klasyczny przykład listy zadań znany z wprowadzającego opisu „Core Concepts” ze strony Reduksa. Globalny stan aplikacji oryginalnie wyglądał w ten sposób:

{
 todos: [{
   text: 'Eat food',
   completed: true
 }, {
   text: 'Exercise',
   completed: false
 }],
 visibilityFilter: 'SHOW_COMPLETED'
}

Stworzenie definicji typów dla takiego obiektu stanu jest dość proste:

interface Todo {
   text: string
   completed: boolean
}

type VisibilityFilter = 'SHOW_COMPLETED' | 'SHOW_ALL'

interface AppState {
   todos?: Todo[]
   visibilityFilter?: VisibilityFilter
}

Zauważmy, że właściwości AppState opisaliśmy jako opcjonalne, jako że początkowo stan aplikacji będzie pusty. Musimy obsłużyć tę sytuację w kodzie i odzwierciedlić ją w definicji typów.

Idźmy dalej. Czas na akcje, które pierwotnie wyglądały następująco:

{ type: 'ADD_TODO', text: 'Go to swimming pool' }
{ type: 'TOGGLE_TODO', index: 1 }
{ type: 'SET_VISIBILITY_FILTER', filter: 'SHOW_ALL' }

Na początek użyjmy definicji typu dostarczonego przez samą bibliotekę redux, konkretnie AnyAction. Typ ten jedynie wymusza, aby właściwość type była ustawiona:

const actions: AnyAction[] = [
   { type: 'ADD_TODO', text: 'Go to swimming pool' },
   { type: 'TOGGLE_TODO', index: 1 },
   { type: 'SET_VISIBILITY_FILTER', filter: 'SHOW_ALL' }
]

Lepsze to niż nic. Nie możemy teraz zdefiniować pustej akcji, za to możemy do akcji włożyć dowolne inne właściwości.

Skoczmy teraz do reducerów. W oryginalnym przykładzie wyglądają następująco:

function visibilityFilter(state = 'SHOW_ALL', action) {
 if (action.type === 'SET_VISIBILITY_FILTER') {
   return action.filter
 } else {
   return state
 }
}
function todos(state = [], action) {
 switch (action.type) {
   case 'ADD_TODO':
     return state.concat([{ text: action.text, completed: false }])
   case 'TOGGLE_TODO':
     return state.map(
       (todo, index) =>
         action.index === index
           ? { text: todo.text, completed: !todo.completed }
           : todo
     )
   default:
     return state
 }
}

// top-level reducer
function todoApp(state = {}, action) {
 return {
   todos: todos(state.todos, action),
   visibilityFilter: visibilityFilter(state.visibilityFilter, action)
 }
}

Jako pierwszy krok, użyjmy naszych zdefiniowanych już typów dla akcji i stanu:

function visibilityFilter(state: VisibilityFilter = 'SHOW_ALL', action: AnyAction): VisibilityFilter {
 if (action.type === 'SET_VISIBILITY_FILTER') {
   return action.filter
 } else {
   return state
 }
}

​function todos(state: Todo[] = [], action: AnyAction): Todo[] {
 switch (action.type) {
   case 'ADD_TODO':
     return state.concat([{ text: action.text, completed: false }])
   case 'TOGGLE_TODO':
     return state.map(
       (todo, index) =>
         action.index === index
           ? { text: todo.text, completed: !todo.completed }
           : todo
     )
   default:
     return state
 }
}

// top-level reducer
function todoApp(state: AppState = {}, action: AnyAction): AppState {
 return {
   todos: todos(state.todos, action),
   visibilityFilter: visibilityFilter(state.visibilityFilter, action)
 }
}

Zauważmy, że wszystkie reducery, niezależnie na którym poziomie zagłębienia w drzewie stanu działają, mają identyczną generyczną sygnaturę:

type Reducer<S> = (state: S, action: AnyAction) => S;

Dokładnie taki typ dostarcza nam Redux. Jeżeli potrzebujemy, możemy go użyć bezpośrednio w naszej aplikacji.

Czy nie da się lepiej?

To, co dotąd zrobiliśmy, sprowadza się do dodania kilku definicji typów, które mogą zapobiec niektórym literówkom i brakom. Ale kiedy spojrzymy na reducery, widzimy, że wciąż nam daleko do prawdziwego silnego typowania, gdyż akcje otypowane jako AnyAction – tak jak „any” w nazwie sugeruje – nie daje kompilatorowi TypeScriptu żadnych podpowiedzi jaką zawartość powinna nieść konkretna instancja akcji. Nadal możemy więc zrobić literówkę gdy odwołujemy się do właściwości akcji i TypeScript nie ma żadnego sposobu, żeby nas przed tym ostrzec.

function visibilityFilter(state: VisibilityFilter = 'SHOW_ALL', action: AnyAction): VisibilityFilter {
   if (action.type === 'SET_VISIBILITY_FILTER') {
       return action.fliter // literówka. TypeScript nie jest w stanie wykryć tu problemu
   } else {
       return state
   }
}

Ale nie wszystko stracone. TypeScript ma niesamowitą funkcjonalność zwaną Discriminated Unions. Choć jej nazwa ta brzmi jakby dotyczyła jakiejś organizacji zrzeszającej mniejszość społeczną, będzie nam tu bardzo pomocna.

Stwórzmy szczegółowe definicje typów dla każdej akcji, którą chcemy obsługiwać:

interface AddTodoAction extends Action {
   type: 'ADD_TODO'
   text: string
}

interface ToggleTodoAction extends Action {
   type: 'TOGGLE_TODO'
   index: number
}

interface SetVisibilityFilterAction extends Action {
   type: 'SET_VISIBILITY_FILTER'
   filter: VisibilityFilter
}

Tym razem rozszerzamy typ Action (również dostarczony już przez Reduksa), gdyż on ogranicza się tylko do wymagania właściwości type. AnyAction, którego dotąd używaliśmy, pozwalał na używanie dowolnych właściwości typu any, więc zabijał nasze bezpieczeństwo typów. Kolejnym elementem wartym zauważenia jest sposób, w jaki określamy typ właściwości type – nie jest to dowolny string, a konkretna, sztywna wartość wyspecyfikowana jako literał. Tak określony typ stanowi świetną kandydaturę na właściwość, która będzie służyła do rozróżnienia typów w ramach naszej unii. A oto owa unia:

type TodoAppAction = AddTodoAction | ToggleTodoAction | SetVisibilityFilterAction

Czas zamienić w reducerach AnyAction na naszą unię. Spójrzmy, co teraz się stało:

function visibilityFilter(state: VisibilityFilter = 'SHOW_ALL', action: TodoAppAction): VisibilityFilter {
   if (action.type === 'SET_VISIBILITY_FILTER') {
       return action.filter
   } else {
       return state
   }
}

function todos(state: Todo[] = [], action: TodoAppAction): Todo[] {
   switch (action.type) {
       case 'ADD_TODO':
           return state.concat([{text: action.text, completed: false}])
       case 'TOGGLE_TODO':
           return state.map(
               (todo, index) =>
                   action.index === index
                       ? {text: todo.text, completed: !todo.completed}
                       : todo
           )
       default:
           return state
   }
}

function todoApp(state: AppState = {}, action: TodoAppAction): AppState {
   return {
       todos: todos(state.todos, action),
       visibilityFilter: visibilityFilter(state.visibilityFilter, action)
   }
}

Jeśli używamy IDE, które wspiera TypeScripta (np. WebStorm), zauważymy, że właściwości naszych akcji są teraz kolorowane w ten sam sposób, co inne znane właściwości i uzupełnianie kodu działa dokładnie tak, jak możemy sobie wymarzyć. Tylko w ramach akcji typu ADD_TODO dostępna jest właściwość text, nie jest za to dostępna żadna inna; podobnie z pozostałymi typami akcji. Spróbujmy jeszcze raz naszego przykładu z literówką:

Czyż nie jest to zwycięstwo dla ludzkości?

Podłączone komponenty

Jeszcze jedno miejsce, w którym możemy się poczuć oszukani przez Reduxa w kwestii silnego typowania, to podłączanie komponentów do store’a. Spójrzmy ponownie na (odrobinę uproszczony) przykład ze strony Reduksa:

import { connect } from 'react-redux'
import { setVisibilityFilter } from '../actions'
// sam komponent
const Link = ({active, children, onClick}) => {
 if (active) {
   return <span>{children}</span>
 }
​
 return <a href="" onClick={onClick}>
   {children}
 </a>
}

// podłączanie reduksa

const mapStateToProps = (state, ownProps) => {
 return {
   active: ownProps.filter === state.visibilityFilter
 }
}
​
const mapDispatchToProps = (dispatch, ownProps) => {
 return {
   onClick: () => {
     dispatch(setVisibilityFilter(ownProps.filter))
   }
 }
}

const FilterLink = connect(mapStateToProps, mapDispatchToProps)(Link)

Zdefiniujmy typy dla komponentu Link w “zwyczajny” sposób:

interface LinkProps {
   active: boolean
   onClick: () => void
}

const Link: React.StatelessComponent<LinkProps> = props => {
 if (props.active) {
   return <span>{props.children}</span>
 }

 return <a href="" onClick={props.onClick}>
     {props.children}
 </a>
}

Takie rozwiązanie działa dobrze, ale nie zapewnia silnego typowania na poziomie funkcji mapStateToProps i mapDispatchToProps, gdyż te funkcje budują nasz finalny obiekt propsów z niezależnych części. Możemy albo pozostawić je bez definicji zwracanego typu, albo stosować obejścia typu Partial<LinkProps>, żeby uzyskać chociaż podstawowe sprawdzanie typów:

Użycie typu Partial może odrobinę pomóc przy mapStateToProps.

Nie mamy wciąż właściwej definicji typu dla parametru ownProps, “dla wygody” użyliśmy any, ewentualnie moglibyśmy również użyć Partial<LinkProps>.

Kłopot z takim podejściem do typowania jest taki, że nic nie stoi na przeszkodzie, żeby zwrócić właściwość onClick z mapStateToProps, kiedy w rzeczywistości oczekujemy go tylko i wyłącznie z mapDispatchToProps. Opiszmy to więc wprost – podzielmy nasz typ propsów na trzy osobne typy: jeden dla własnych propsów (otrzymanych bezpośrednio od komponentu-rodzica), jeden dla propsów wywiedzionych ze stanu i jeden dla propsów dispatchujących akcje:

interface LinkOwnProps {
   filter: VisibilityFilter
}

interface LinkStateProps {
   active: boolean
}

interface LinkDispatchProps {
   onClick: () => void
}

type LinkProps = LinkOwnProps & LinkStateProps & LinkDispatchProps

Teraz nasz kod podłączający komponenty do Reduksa jest w pełni silnie typowany:

const mapStateToProps = (state: AppState, ownProps: LinkOwnProps): LinkStateProps => {
   return {
       active: ownProps.filter === state.visibilityFilter
   }
}
const mapDispatchToProps = (dispatch: Dispatch<AppState>, ownProps: LinkOwnProps): LinkDispatchProps => {
   return {
       onClick: () => {
           dispatch(setVisibilityFilter(ownProps.filter))
       }
   }
}

Dla mnie osobiście bezpieczeństwo typów, które dać nam może starannie zaprojektowana struktura definicji TypeScriptowych sprawia, że Redux na nowo nabiera przyjemnego kształtu.

baner

Podobne artykuły

[wpdevart_facebook_comment curent_url="https://justjoin.it/blog/nowoczesny-projekt-webowy-reactem-typescriptem-przy-uzyciu-cra" order_type="social" width="100%" count_of_comments="8" ]