React, Redux i Immutable.js: składniki wydajnych aplikacji internetowych

Opublikowany: 2022-03-11

React, Redux i Immutable.js są obecnie jednymi z najpopularniejszych bibliotek JavaScript i szybko stają się pierwszym wyborem programistów, jeśli chodzi o rozwój front-end. W kilku projektach React i Redux, nad którymi pracowałem, zdałem sobie sprawę, że wielu programistów zaczynających z Reactem nie do końca rozumie Reacta i nie rozumie, jak pisać wydajny kod, aby wykorzystać jego pełny potencjał.

W tym samouczku Immutable.js zbudujemy prostą aplikację przy użyciu React i Redux oraz zidentyfikujemy niektóre z najczęstszych nadużyć Reacta i sposoby ich uniknięcia.

Problem z odniesieniem do danych

W React chodzi o wydajność. Został zbudowany od podstaw, aby był wyjątkowo wydajny, tylko ponownie renderował minimalne części DOM, aby zaspokoić nowe zmiany danych. Każda aplikacja React powinna składać się głównie z małych, prostych (lub bezstanowych funkcji) komponentów. Są one łatwe do zrozumienia i większość z nich może mieć funkcję shouldComponentUpdate zwracającą wartość false .

 shouldComponentUpdate(nextProps, nextState) { return false; }

Jeśli chodzi o wydajność, najważniejszą funkcją cyklu życia składnika jest shouldComponentUpdate i jeśli to możliwe, powinna zawsze zwracać false . Gwarantuje to, że ten komponent nigdy nie zostanie ponownie renderowany (z wyjątkiem początkowego renderowania), dzięki czemu aplikacja React będzie działać niezwykle szybko.

Jeśli tak nie jest, naszym celem jest tanie sprawdzenie równości starych właściwości/stanu z nowymi właściwościami/stanem i pominięcie ponownego renderowania, jeśli dane są niezmienione.

Cofnijmy się na chwilę i przyjrzyjmy się, jak JavaScript wykonuje sprawdzanie równości dla różnych typów danych.

Sprawdzanie równości dla prymitywnych typów danych, takich jak boolean , string i integer jest bardzo proste, ponieważ są one zawsze porównywane według ich rzeczywistej wartości:

 1 === 1 'string' === 'string' true === true

Z drugiej strony sprawdzanie równości dla złożonych typów, takich jak obiekty , tablice i funkcje, jest zupełnie inne. Dwa obiekty są takie same, jeśli mają to samo odniesienie (wskazujące na ten sam obiekt w pamięci).

 const obj1 = { prop: 'someValue' }; const obj2 = { prop: 'someValue' }; console.log(obj1 === obj2); // false

Mimo że obj1 i obj2 wydają się być takie same, ich odniesienie jest inne. Ponieważ są one różne, porównywanie ich naiwnie w ramach funkcji shouldComponentUpdate spowoduje, że nasz komponent będzie niepotrzebnie ponownie renderowany.

Ważną rzeczą do zapamiętania jest to, że dane pochodzące z reduktorów Redux, jeśli nie zostaną poprawnie skonfigurowane, zawsze będą podawane z różnymi referencjami, co spowoduje ponowne renderowanie komponentu za każdym razem.

Jest to główny problem w naszym dążeniu do uniknięcia ponownego renderowania komponentów.

Referencje dotyczące obsługi

Weźmy przykład, w którym mamy głęboko zagnieżdżone obiekty i chcemy porównać go z jego poprzednią wersją. Moglibyśmy rekursywnie przechodzić przez zagnieżdżone właściwości obiektów i porównywać je, ale oczywiście byłoby to niezwykle kosztowne i nie wchodzi w rachubę.

Pozostaje nam tylko jedno rozwiązanie, a mianowicie sprawdzenie referencji, ale szybko pojawiają się nowe problemy:

  • Zachowanie odniesienia, jeśli nic się nie zmieniło
  • Zmiana referencji w przypadku zmiany wartości właściwości zagnieżdżonego obiektu/tablicy

Nie jest to łatwe zadanie, jeśli chcemy to zrobić w ładny, czysty i zoptymalizowany pod względem wydajności sposób. Facebook już dawno zdał sobie sprawę z tego problemu i wezwał Immutable.js na ratunek.

 import { Map } from 'immutable'; // transform object into immutable map let obj1 = Map({ prop: 'someValue' }); const obj2 = obj1; console.log(obj1 === obj2); // true obj1 = obj1.set('prop', 'someValue'); // set same old value console.log(obj1 === obj2); // true | does not break reference because nothing has changed obj1 = obj1.set('prop', 'someNewValue'); // set new value console.log(obj1 === obj2); // false | breaks reference

Żadna z funkcji Immutable.js nie wykonuje bezpośredniej mutacji na podanych danych. Zamiast tego dane są wewnętrznie klonowane, mutowane i jeśli nastąpiły jakieś zmiany, zwracana jest nowa referencja. W przeciwnym razie zwraca początkowe odwołanie. Nowa referencja musi być ustawiona jawnie, na przykład obj1 = obj1.set(...); .

Przykłady React, Redux i Immutable.js

Najlepszym sposobem na zademonstrowanie możliwości tych bibliotek jest zbudowanie prostej aplikacji. A co może być prostszego niż aplikacja do zrobienia?

Dla zwięzłości, w tym artykule omówimy tylko te części aplikacji, które są kluczowe dla tych pojęć. Cały kod źródłowy kodu aplikacji można znaleźć na GitHub.

Po uruchomieniu aplikacji zauważysz, że wywołania pliku console.log są wygodnie umieszczone w kluczowych obszarach, aby wyraźnie pokazać ilość ponownego renderowania DOM, która jest minimalna.

Jak każda inna aplikacja do zrobienia, chcemy pokazać listę rzeczy do zrobienia. Gdy użytkownik kliknie element do zrobienia, oznaczymy go jako zakończony. Potrzebujemy również małego pola wejściowego na górze, aby dodać nowe rzeczy do zrobienia, a na dole 3 filtry, które pozwolą użytkownikowi przełączać się między:

  • Wszystko
  • Zakończony
  • Aktywny

Redukcja Redux

Wszystkie dane w aplikacji Redux znajdują się w jednym obiekcie sklepu i możemy patrzeć na reduktory jako po prostu wygodny sposób na podzielenie sklepu na mniejsze części, które łatwiej jest wnioskować. Ponieważ reduktor jest również funkcją, również można go podzielić na jeszcze mniejsze części.

Nasz reduktor będzie się składał z 2 małych części:

  • Lista rzeczy do zrobienia
  • aktywny filtr
 // reducers/todos.js import * as types from 'constants/ActionTypes'; // we can look at List/Map as immutable representation of JS Array/Object import { List, Map } from 'immutable'; import { combineReducers } from 'redux'; function todoList(state = List(), action) { // default state is empty List() switch (action.type) { case types.ADD_TODO: return state.push(Map({ // Every switch/case must always return either immutable id: action.id, // or primitive (like in activeFilter) state data text: action.text, // We let Immutable decide if data has changed or not isCompleted: false, })); // other cases... default: return state; } } function activeFilter(state = 'all', action) { switch (action.type) { case types.CHANGE_FILTER: return action.filter; // This is primitive data so there's no need to worry default: return state; } } // combineReducers combines reducers into a single object // it lets us create any number or combination of reducers to fit our case export default combineReducers({ activeFilter, todoList, });

Łączenie z Redux

Teraz, gdy mamy skonfigurowany reduktor Redux z danymi Immutable.js, połączmy go z komponentem React, aby przekazać dane.

 // components/App.js import { connect } from 'react-redux'; // ….component code const mapStateToProps = state => ({ activeFilter: state.todos.activeFilter, todoList: state.todos.todoList, }); export default connect(mapStateToProps)(App);

W idealnym świecie łączenie powinno odbywać się tylko na najwyższych komponentach trasy, wydobywając dane w mapStateToProps, a reszta to podstawa Reacta przekazująca props dzieciom. W aplikacjach na dużą skalę trudno jest śledzić wszystkie połączenia, więc chcemy ograniczyć je do minimum.

Należy zauważyć, że state.todos jest zwykłym obiektem JavaScript zwracanym przez funkcję Redux mergeReducers (todos jest nazwą reduktora), ale state.todos.todoList jest niezmienną listą i bardzo ważne jest, aby pozostawała w takim formularz, dopóki nie przejdzie, powinien sprawdzać ComponentUpdate.

Unikanie ponownego renderowania komponentów

Zanim zagłębimy się głębiej, ważne jest, aby zrozumieć, jaki rodzaj danych musi zostać dostarczony do komponentu:

  • Wszelkiego rodzaju prymitywne typy
  • Obiekt/tablica tylko w niezmiennej formie

Posiadanie tego typu danych pozwala nam płytko porównywać właściwości, które wchodzą w skład komponentów React.

Następny przykład pokazuje, jak w najprostszy możliwy sposób zróżnicować właściwości:

 $ npm install react-pure-render
 import shallowEqual from 'react-pure-render/shallowEqual'; shouldComponentUpdate(nextProps, nextState) { return !shallowEqual(this.props, nextProps) || !shallowEqual(this.state, nextState); }

Funkcja shallowEqual sprawdzi różnice props/state tylko na 1 poziom głębokości. Działa niezwykle szybko i doskonale współgra z naszymi niezmiennymi danymi. Konieczność napisania tego w każdym komponencie powinna być bardzo niewygodna, ale na szczęście istnieje proste rozwiązanie.

Wyodrębnij powinien ComponentUpdate do specjalnego oddzielnego składnika:

 // components/PureComponent.js import React from 'react'; import shallowEqual from 'react-pure-render/shallowEqual'; export default class PureComponent extends React.Component { shouldComponentUpdate(nextProps, nextState) { return !shallowEqual(this.props, nextProps) || !shallowEqual(this.state, nextState); } }

Następnie po prostu rozszerz dowolny komponent, w którym ta logika powinna być pożądana:

 // components/Todo.js export default class Todo extends PureComponent { // Component code }

Jest to bardzo czysty i skuteczny sposób na uniknięcie ponownego renderowania komponentów w większości przypadków, a później, jeśli aplikacja stanie się bardziej złożona i nagle wymaga niestandardowego rozwiązania, można ją łatwo zmienić.

Podczas używania PureComponent podczas przekazywania funkcji jako właściwości występuje niewielki problem. Ponieważ React z klasą ES6 nie wiąże tego automatycznie z funkcjami, musimy to zrobić ręcznie. Możemy to osiągnąć, wykonując jedną z następujących czynności:

  • użyj powiązania funkcji strzałki ES6: <Component onClick={() => this.handleClick()} />
  • użyj bind : <Component onClick={this.handleClick.bind(this)} />

Oba podejścia spowodują ponowne renderowanie komponentu , ponieważ za każdym razem do onClick przekazywane było inne odwołanie.

Aby obejść ten problem, możemy wstępnie powiązać funkcje w metodzie konstruktora , takie jak:

 constructor() { super(); this.handleClick = this.handleClick.bind(this); } // Then simply pass the function render() { return <Component onClick={this.handleClick} /> }

Jeśli przez większość czasu będziesz wstępnie wiązać wiele funkcji, możemy wyeksportować i ponownie użyć małej funkcji pomocniczej:

 // utils/bind-functions.js export default function bindFunctions(functions) { functions.forEach(f => this[f] = this[f].bind(this)); } // some component constructor() { super(); bindFunctions.call(this, ['handleClick']); // Second argument is array of function names }

Jeśli żadne z rozwiązań nie działa dla Ciebie, zawsze możesz ręcznie napisać warunki powinnyComponentUpdate .

Obsługa niezmiennych danych wewnątrz komponentu

Przy obecnej konfiguracji niezmiennych danych uniknięto ponownego renderowania i pozostajemy z niezmiennymi danymi wewnątrz właściwości komponentu. Istnieje wiele sposobów wykorzystania tych niezmiennych danych, ale najczęstszym błędem jest natychmiastowa konwersja danych do zwykłego JS przy użyciu funkcji niezmiennej toJS .

Używanie toJS do głębokiej konwersji niezmiennych danych na zwykły JS neguje cały cel unikania ponownego renderowania, ponieważ zgodnie z oczekiwaniami jest bardzo powolny i jako taki należy go unikać. Jak więc radzimy sobie z niezmiennymi danymi?

Musi być używany tak, jak jest, dlatego Immutable API zapewnia szeroką gamę funkcji, map i jest najczęściej używany w komponencie React. Struktura danych todoList pochodząca z Redux Reducer to tablica obiektów w niezmiennej formie, z których każdy reprezentuje pojedynczy element todo:

 [{ id: 1, text: 'todo1', isCompleted: false, }, { id: 2, text: 'todo2', isCompleted: false, }]

API Immutable.js jest bardzo podobne do zwykłego JavaScriptu, więc użyjemy todoList jak każdej innej tablicy obiektów. W większości przypadków najlepiej sprawdza się funkcja mapy.

Wewnątrz wywołania zwrotnego mapy otrzymujemy todo , który jest obiektem wciąż w niezmiennej formie i możemy go bezpiecznie przekazać w komponencie Todo .

 // components/TodoList.js render() { return ( // …. {todoList.map(todo => { return ( <Todo key={todo.get('id')} todo={todo}/> ); })} // …. ); }

Jeśli planujesz wykonywać wiele połączonych iteracji na niezmiennych danych, takich jak:

 myMap.filter(somePred).sort(someComp)

…wtedy bardzo ważne jest, aby najpierw przekonwertować go na Seq za pomocą toSeq , a po iteracjach przywrócić go do pożądanej postaci, np.:

 myMap.toSeq().filter(somePred).sort(someComp).toOrderedMap()

Ponieważ Immutable.js nigdy nie mutuje bezpośrednio danych, zawsze musi wykonać ich kolejną kopię, wykonywanie wielu iteracji w ten sposób może być bardzo kosztowne. Seq jest leniwą niezmienną sekwencją danych, co oznacza, że ​​wykona jak najmniej operacji, aby wykonać swoje zadanie, pomijając tworzenie kopii pośrednich. Seq został zbudowany do tego celu.

Wewnątrz komponentu Todo użyj get lub getIn , aby uzyskać rekwizyty.

Wystarczająco proste, prawda?

Cóż, zdałem sobie sprawę, że przez większość czasu może stać się bardzo nieczytelne, mając dużą liczbę get() , a zwłaszcza getIn() . Postanowiłem więc znaleźć idealny punkt między wydajnością a czytelnością i po kilku prostych eksperymentach odkryłem, że funkcje toObject i toArray Immutable.js działają bardzo dobrze.

Funkcje te płytko konwertują (1 poziom głębokości) obiekty/tablice Immutable.js na zwykłe obiekty/tablice JavaScript. Jeśli mamy głęboko zagnieżdżone w środku dane, pozostaną one w niezmienionej formie, gotowe do przekazania komponenty potomne i to jest dokładnie to, czego potrzebujemy.

Jest wolniejszy niż get() tylko z niewielkim marginesem, ale wygląda na znacznie czystszy:

 // components/Todo.js render() { const { id, text, isCompleted } = this.props.todo.toObject(); // ….. }

Zobaczmy to wszystko w akcji

Jeśli jeszcze nie sklonowałeś kodu z GitHub, teraz jest świetny czas, aby to zrobić:

 git clone https://github.com/rogic89/ToDo-react-redux-immutable.git cd ToDo-react-redux-immutable

Uruchomienie serwera jest tak proste (upewnij się, że Node.js i NPM są zainstalowane), jak to:

 npm install npm start 

Przykład Immutable.js: aplikacja Todo, z polem „wprowadź zadanie”, pięcioma zadaniami do zrobienia (drugi i czwarty przekreślone), selektorem opcji dla wszystkich vs. ukończonych vs. aktywnych oraz przyciskiem „usuń wszystko”.

Przejdź do http://localhost:3000 w przeglądarce internetowej. Przy otwartej konsoli programisty obserwuj dzienniki, dodając kilka rzeczy do zrobienia, oznaczając je jako wykonane i zmieniając filtr:

  • Dodaj 5 rzeczy do zrobienia
  • Zmień filtr z „Wszystko” na „Aktywny”, a następnie z powrotem na „Wszystko”
    • Nie trzeba ponownie renderować, wystarczy zmienić filtr
  • Oznacz 2 rzeczy do zrobienia jako ukończone
    • Ponownie wyrenderowano dwa zadania, ale tylko jedno na raz
  • Zmień filtr z „Wszystko” na „Aktywny”, a następnie z powrotem na „Wszystko”
    • Tylko 2 ukończone elementy do zrobienia zostały zamontowane/odmontowane
    • Aktywne nie zostały ponownie wyrenderowane
  • Usuń pojedynczy element do zrobienia ze środka listy
    • Dotyczyło to tylko usuniętego elementu do zrobienia, inne nie zostały ponownie renderowane

Zakończyć

Synergia React, Redux i Immutable.js, jeśli jest właściwie używana, oferuje eleganckie rozwiązania wielu problemów z wydajnością, które często występują w dużych aplikacjach internetowych.

Immutable.js pozwala nam wykrywać zmiany w obiektach/tablicach JavaScript bez uciekania się do nieefektywności głębokich kontroli równości, co z kolei pozwala Reactowi uniknąć kosztownych operacji ponownego renderowania, gdy nie są one wymagane. Oznacza to, że w większości scenariuszy wydajność Immutable.js jest dobra.

Mam nadzieję, że artykuł Ci się spodobał i przyda Ci się do budowania innowacyjnych rozwiązań React w Twoich przyszłych projektach.

Powiązane: Jak komponenty React ułatwiają testowanie interfejsu użytkownika