Lepsze reduktory z Immerem
Opublikowany: 2022-03-10Jako 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.
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 douseState
. Funkcja zwraca krotkę, pierwsza wartość krotki to aktualny stan, druga to funkcja aktualizująca, która przyjmuje funkcję producenta immer, w którejdraft
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
-
use-immer
, GitHub - Immer, GitHub
-
function
, dokumentacja internetowa MDN, Mozilla -
proxy
, dokumentacja internetowa MDN, Mozilla - Obiekt (informatyka), Wikipedia
- „Niezmienność w JS”, Orji Chidi Matthew, GitHub
- „Typy i wartości danych ECMAScript”, Ecma International
- Niezmienne kolekcje dla JavaScript, Immutable.js , GitHub
- „Przypadek niezmienności”, Immutable.js , GitHub