Lepsze reduktory z Immerem

Opublikowany: 2022-03-10
Szybkie podsumowanie ↬ W tym artykule dowiemy się, jak używać Immera do pisania reduktorów. Pracując z Reactem utrzymujemy duży stan. Aby dokonać aktualizacji naszego stanu, musimy napisać wiele reduktorów. Ręczne pisanie reduktorów skutkuje rozdętym kodem, w którym musimy dotykać prawie każdej części naszego stanu. Jest to żmudne i podatne na błędy. W tym artykule zobaczymy, jak Immer uprości proces pisania reduktorów stanów.

Jako programista React powinieneś już znać zasadę, że stan nie powinien być bezpośrednio mutowany. Możesz się zastanawiać, co to oznacza (większość z nas miała to zamieszanie, kiedy zaczynaliśmy).

Ten samouczek odda to sprawiedliwość: zrozumiesz, czym jest niezmienny stan i jaka jest jego potrzeba. Dowiesz się również, jak używać Immera do pracy ze stanem niezmiennym i jakie są korzyści z jego używania. Kod można znaleźć w tym artykule w tym repozytorium Github.

Niezmienność w JavaScript i dlaczego to ma znaczenie

Immer.js to niewielka biblioteka JavaScript napisana przez Michela Weststrate, którego misją jest umożliwienie „pracy ze stanem niezmiennym w wygodniejszy sposób”.

Ale zanim zagłębimy się w Immer, szybko przypomnijmy sobie o niezmienności w JavaScript i dlaczego ma to znaczenie w aplikacji React.

Najnowszy standard ECMAScript (aka JavaScript) definiuje dziewięć wbudowanych typów danych. Spośród tych dziewięciu typów sześć jest określanych jako wartości/typy primitive . Tych sześć prymitywów to undefined , number , string , boolean , bigint i symbol . Proste sprawdzenie za pomocą operatora typeof w JavaScript ujawni typy tych typów danych.

 console.log(typeof 5) // number console.log(typeof 'name') // string console.log(typeof (1 < 2)) // boolean console.log(typeof undefined) // undefined console.log(typeof Symbol('js')) // symbol console.log(typeof BigInt(900719925474)) // bigint

primitive to wartość, która nie jest obiektem i nie ma metod. Najważniejszym dla naszej obecnej dyskusji jest fakt, że wartości prymitywu nie można zmienić po jego utworzeniu. Tak więc mówi się, że prymitywy są immutable .

Pozostałe trzy typy to null , object i function . Możemy również sprawdzić ich typy za pomocą operatora typeof .

 console.log(typeof null) // object console.log(typeof [0, 1]) // object console.log(typeof {name: 'name'}) // object const f = () => ({}) console.log(typeof f) // function

Te typy są mutable . Oznacza to, że ich wartości można zmienić w dowolnym momencie po ich utworzeniu.

Możesz się zastanawiać, dlaczego mam tam tablicę [0, 1] . Cóż, w JavaScriptland tablica jest po prostu specjalnym typem obiektu. W przypadku, gdy zastanawiasz się również nad null i czym różni się od undefined . undefined oznacza po prostu, że nie ustawiliśmy wartości dla zmiennej, podczas gdy null jest szczególnym przypadkiem dla obiektów. Jeśli wiesz, że coś powinno być obiektem, ale obiektu tam nie ma, po prostu zwracasz null .

Aby zilustrować prostym przykładem, spróbuj uruchomić poniższy kod w konsoli przeglądarki.

 console.log('aeiou'.match(/[x]/gi)) // null console.log('xyzabc'.match(/[x]/gi)) // [ 'x' ]

String.prototype.match powinien zwrócić tablicę, która jest typem object . Gdy nie może znaleźć takiego obiektu, zwraca null . Powrót undefined też nie miałby sensu.

Dość tego. Wróćmy do omówienia niezmienności.

Więcej po skoku! Kontynuuj czytanie poniżej ↓

Zgodnie z dokumentacją MDN:

„Wszystkie typy z wyjątkiem obiektów definiują wartości niezmienne (to znaczy wartości, których nie można zmienić).”

Ta instrukcja zawiera funkcje, ponieważ są one specjalnym typem obiektu JavaScript. Zobacz definicję funkcji tutaj.

Rzućmy okiem na to, co w praktyce oznaczają zmienne i niezmienne typy danych. Spróbuj uruchomić poniższy kod w konsoli przeglądarki.

 let a = 5; let b = a console.log(`a: ${a}; b: ${b}`) // a: 5; b: 5 b = 7 console.log(`a: ${a}; b: ${b}`) // a: 5; b: 7

Nasze wyniki pokazują, że chociaż b jest „pochodne” z a , zmiana wartości b nie wpływa na wartość a . Wynika to z faktu, że kiedy silnik JavaScript wykonuje instrukcję b = a , tworzy nową, oddzielną lokalizację w pamięci, umieszcza tam 5 i wskazuje b w tej lokalizacji.

A co z przedmiotami? Rozważ poniższy kod.

 let c = { name: 'some name'} let d = c; console.log(`c: ${JSON.stringify(c)}; d: ${JSON.stringify(d)}`) // {"name":"some name"}; d: {"name":"some name"} d.name = 'new name' console.log(`c: ${JSON.stringify(c)}; d: ${JSON.stringify(d)}`) // {"name":"new name"}; d: {"name":"new name"}

Widzimy, że zmiana właściwości name za pomocą zmiennej d zmienia ją również w c . Wynika to z faktu, że kiedy silnik JavaScript wykonuje instrukcję c = { name: 'some name ' } , silnik JavaScript tworzy przestrzeń w pamięci, umieszcza obiekt wewnątrz i wskazuje na nią c . Następnie, gdy wykonuje instrukcję d = c , silnik JavaScript po prostu wskazuje d to samo położenie. Nie tworzy nowej lokalizacji w pamięci. Zatem wszelkie zmiany w elementach w d są domyślnie operacją na elementach w c . Bez większego wysiłku możemy zobaczyć, dlaczego jest to problem.

Wyobraź sobie, że tworzysz aplikację React i gdzieś chcesz pokazać nazwę użytkownika jako some name , czytając ze zmiennej c . Ale gdzieś indziej wprowadziłeś błąd w swoim kodzie, manipulując obiektem d . Spowodowałoby to pojawienie się nazwy użytkownika jako new name . Gdyby c i d były prymitywami, nie mielibyśmy tego problemu. Jednak prymitywy są zbyt proste dla typów stanów, jakie musi utrzymywać typowa aplikacja React.

Oto główne powody, dla których ważne jest utrzymywanie niezmiennego stanu w aplikacji. Zachęcam do zapoznania się z kilkoma innymi uwagami, czytając tę ​​krótką sekcję z pliku README Immutable.js: przypadek niezmienności.

Po zrozumieniu, dlaczego potrzebujemy niezmienności w aplikacji React, przyjrzyjmy się teraz, jak Immer rozwiązuje ten problem za pomocą funkcji produce .

Funkcja produce Immera

Podstawowe API Immera jest bardzo małe, a główną funkcją, z którą będziesz pracować, jest funkcja produce . produce po prostu przyjmuje stan początkowy i wywołanie zwrotne, które określa, w jaki sposób stan powinien zostać zmutowany. Samo wywołanie zwrotne otrzymuje wersję roboczą (identyczną, ale wciąż kopię) kopii stanu, do którego dokonuje wszystkich zamierzonych aktualizacji. Na koniec produce nowy, niezmienny stan ze wszystkimi zastosowanymi zmianami.

Ogólny wzorzec tego rodzaju aktualizacji stanu to:

 // produce signature produce(state, callback) => nextState

Zobaczmy, jak to działa w praktyce.

 import produce from 'immer' const initState = { pets: ['dog', 'cat'], packages: [ { name: 'react', installed: true }, { name: 'redux', installed: true }, ], } // to add a new package const newPackage = { name: 'immer', installed: false } const nextState = produce(initState, draft => { draft.packages.push(newPackage) })

W powyższym kodzie po prostu przekazujemy stan początkowy i wywołanie zwrotne, które określa, w jaki sposób chcemy, aby zachodziły mutacje. To takie proste. Nie musimy dotykać żadnej innej części stanu. Pozostawia initState nietkniętym i strukturalnie dzieli te części stanu, których nie dotknęliśmy, między stanem początkowym i nowym. Jedną z takich części w naszym stanie jest tablica pets . nextState produce niezmiennym drzewem stanów, które zawiera wprowadzone przez nas zmiany oraz części, których nie modyfikowaliśmy.

Uzbrojeni w tę prostą, ale przydatną wiedzę, przyjrzyjmy się, jak produce może pomóc nam uprościć nasze reduktory React.

Pisanie reduktorów za pomocą Immer

Załóżmy, że mamy zdefiniowany poniżej obiekt stanu

 const initState = { pets: ['dog', 'cat'], packages: [ { name: 'react', installed: true }, { name: 'redux', installed: true }, ], };

Chcieliśmy dodać nowy obiekt, a w kolejnym kroku ustawiliśmy jego installed klucz na true

 const newPackage = { name: 'immer', installed: false };

Gdybyśmy zrobili to w zwykły sposób z obiektami JavaScripts i składnią rozprzestrzeniania tablicy, nasz reduktor stanów mógłby wyglądać tak, jak poniżej.

 const updateReducer = (state = initState, action) => { switch (action.type) { case 'ADD_PACKAGE': return { ...state, packages: [...state.packages, action.package], }; case 'UPDATE_INSTALLED': return { ...state, packages: state.packages.map(pack => pack.name === action.name ? { ...pack, installed: action.installed } : pack ), }; default: return state; } };

Widzimy, że jest to niepotrzebnie gadatliwe i podatne na błędy dla tego stosunkowo prostego obiektu stanu. Musimy też dotknąć każdej części państwa, co jest niepotrzebne. Zobaczmy, jak możemy to uprościć za pomocą Immera.

 const updateReducerWithProduce = (state = initState, action) => produce(state, draft => { switch (action.type) { case 'ADD_PACKAGE': draft.packages.push(action.package); break; case 'UPDATE_INSTALLED': { const package = draft.packages.filter(p => p.name === action.name)[0]; if (package) package.installed = action.installed; break; } default: break; } });
Za pomocą kilku linijek kodu znacznie uprościliśmy nasz reduktor. Ponadto, jeśli wpadniemy w przypadek domyślny, Immer po prostu zwróci stan roboczy bez konieczności robienia czegokolwiek. Zwróć uwagę, jak mniej jest szablonowego kodu i eliminacja rozprzestrzeniania się stanu. W Immer zajmujemy się tylko tą częścią stanu, którą chcemy zaktualizować. Jeśli nie możemy znaleźć takiego elementu, jak w akcji `UPDATE_INSTALLED`, po prostu przechodzimy dalej, nie dotykając niczego innego. Funkcja produkcji nadaje się również do curryingu. Przekazywanie wywołania zwrotnego jako pierwszego argumentu do `produce` ma służyć do curryingu. Podpis „produktu” curry to
 //curried produce signature produce(callback) => (state) => nextState
Zobaczmy, jak możemy zaktualizować nasz wcześniejszy stan o produkty z curry. Nasze curry wyglądałyby tak:
 const curriedProduce = produce((draft, action) => { switch (action.type) { case 'ADD_PACKAGE': draft.packages.push(action.package); break; case 'SET_INSTALLED': { const package = draft.packages.filter(p => p.name === action.name)[0]; if (package) package.installed = action.installed; break; } default: break; } });

Funkcja curried produce przyjmuje funkcję jako swój pierwszy argument i zwraca curried product, która dopiero teraz wymaga stanu, z którego wytworzy następny stan. Pierwszym argumentem funkcji jest stan draft (który zostanie wyprowadzony ze stanu, który ma zostać przekazany podczas wywoływania tego produktu z curry). Następnie następuje każda liczba argumentów, które chcemy przekazać do funkcji.

Wszystko, co musimy teraz zrobić, aby skorzystać z tej funkcji, to przejść w stanie, z którego chcemy wytworzyć następny stan i obiekt akcji w ten sposób.

 // add a new package to the starting state const nextState = curriedProduce(initState, { type: 'ADD_PACKAGE', package: newPackage, }); // update an item in the recently produced state const nextState2 = curriedProduce(nextState, { type: 'SET_INSTALLED', name: 'immer', installed: true, });

Zauważ, że w aplikacji React, używając useReducer , nie musimy jawnie przekazywać stanu, jak to zrobiłem powyżej, ponieważ zajmuje się tym.

Możesz się zastanawiać, czy Immer dostałby hook , jak wszystko w dzisiejszych czasach w React? Cóż, jesteś w towarzystwie dobrych wieści. Immer posiada dwa haki do pracy ze stanem: useImmer i useImmerReducer . Zobaczmy, jak działają.

Korzystanie z useImmer i useImmerReducer

Najlepszy opis useImmer pochodzi z samego pliku README use-immer.

useImmer(initialState) jest bardzo podobny do useState . Funkcja zwraca krotkę, pierwsza wartość krotki to aktualny stan, druga to funkcja aktualizująca, która przyjmuje funkcję producenta immer, w której draft można dowolnie mutować, dopóki producent nie zakończy pracy i nie zostaną wprowadzone zmiany niezmienne i stać się kolejnym stanem.

Aby korzystać z tych haczyków, należy je zainstalować osobno, oprócz głównej biblioteki Immer.

 yarn add immer use-immer

W kategoriach kodu haczyk useImmer wygląda jak poniżej

 import React from "react"; import { useImmer } from "use-immer"; const initState = {} const [ data, updateData ] = useImmer(initState)

I to takie proste. Można powiedzieć, że to useState Reacta, ale z odrobiną sterydu. Korzystanie z funkcji aktualizacji jest bardzo proste. Otrzymuje stan roboczy i możesz go dowolnie modyfikować, jak poniżej.

 // make changes to data updateData(draft => { // modify the draft as much as you want. })

Twórca Immera dostarczył przykład codeandbox, z którym możesz się pobawić, aby zobaczyć, jak to działa.

useImmerReducer jest podobnie prosty w użyciu, jeśli korzystałeś z hooka useReducer z useReducer . Ma podobny podpis. Zobaczmy, jak to wygląda w kategoriach kodu.

 import React from "react"; import { useImmerReducer } from "use-immer"; const initState = {} const reducer = (draft, action) => { switch(action.type) { default: break; } } const [data, dataDispatch] = useImmerReducer(reducer, initState);

Widzimy, że reduktor otrzymuje stan draft , który możemy dowolnie modyfikować. Jest tu również przykład codeandbox, z którym możesz poeksperymentować.

I tak proste jest zastosowanie haczyków Immer. Ale jeśli nadal zastanawiasz się, dlaczego powinieneś używać Immera w swoim projekcie, oto podsumowanie niektórych z najważniejszych powodów, dla których używam Immera.

Dlaczego powinieneś używać Immer

Jeśli przez jakiś czas pisałeś logikę zarządzania stanami, szybko docenisz prostotę, jaką oferuje Immer. Ale to nie jedyna korzyść, jaką oferuje Immer.

Kiedy używasz Immera, kończysz pisanie mniej standardowego kodu, jak widzieliśmy w przypadku stosunkowo prostych reduktorów. Dzięki temu głębokie aktualizacje są stosunkowo łatwe.

Dzięki bibliotekom takim jak Immutable.js musisz nauczyć się nowego interfejsu API, aby czerpać korzyści z niezmienności. Ale z Immerem możesz osiągnąć to samo z normalnymi Objects JavaScript, Arrays , Sets i Maps . Nie ma nic nowego do nauczenia.

Immer domyślnie zapewnia również udostępnianie strukturalne. Oznacza to po prostu, że kiedy wprowadzasz zmiany w obiekcie stanu, Immer automatycznie udostępnia niezmienione części stanu między stanem nowym i poprzednim.

Dzięki Immerowi otrzymujesz również automatyczne zamrażanie obiektów, co oznacza, że ​​nie możesz wprowadzać zmian w stanie produced . Na przykład, kiedy zacząłem używać Immera, próbowałem zastosować metodę sort na tablicy obiektów zwróconych przez funkcję produce Immera. Wystąpił błąd informujący mnie, że nie mogę wprowadzić żadnych zmian w tablicy. Musiałem zastosować metodę wycinania tablicy przed zastosowaniem sort . Ponownie, wytworzony nextState jest niezmiennym drzewem stanów.

Immer jest również mocno napisany i bardzo mały (tylko 3 KB po spakowaniu gzipem).

Wniosek

Jeśli chodzi o zarządzanie aktualizacjami stanu, używanie Immera jest dla mnie oczywiste. Jest to bardzo lekka biblioteka, która pozwala nadal używać wszystkiego, czego nauczyłeś się o JavaScript, bez próbowania uczenia się czegoś zupełnie nowego. Zachęcam do zainstalowania go w swoim projekcie i natychmiastowego rozpoczęcia korzystania z niego. Możesz dodać używać go w istniejących projektach i stopniowo aktualizować swoje reduktory.

Zachęcam również do przeczytania wprowadzającego wpisu na blogu Immera autorstwa Michaela Weststrate'a. Szczególnie interesująca jest część „Jak działa Immer?” sekcja, która wyjaśnia, w jaki sposób Immer wykorzystuje funkcje języka, takie jak serwery proxy i koncepcje, takie jak kopiowanie przy zapisie.

Zachęcam również do zapoznania się z następującym wpisem na blogu: Niezmienność w JavaScript: przeciwstawne spojrzenie, w którym autor, Steven de Salas, przedstawia swoje przemyślenia na temat zalet dążenia do niezmienności.

Mam nadzieję, że dzięki temu, czego nauczyłeś się w tym poście, możesz od razu zacząć korzystać z Immera.

Powiązane zasoby

  1. use-immer , GitHub
  2. Immer, GitHub
  3. function , dokumentacja internetowa MDN, Mozilla
  4. proxy , dokumentacja internetowa MDN, Mozilla
  5. Obiekt (informatyka), Wikipedia
  6. „Niezmienność w JS”, ​​Orji Chidi Matthew, GitHub
  7. „Typy i wartości danych ECMAScript”, Ecma International
  8. Niezmienne kolekcje dla JavaScript, Immutable.js , GitHub
  9. „Przypadek niezmienności”, Immutable.js , GitHub