React Hooks和Redux哪個才是更好的狀態管理策略?
譯文【51CTO.com快譯】如果您是一名React開發人員,那么一定對狀態管理策略并不陌生。當我們在使用React去構建Web應用時,所有信息都被保存在所謂的狀態之中。我們只需要更新該狀態,即可實現對Web應用的更新。而狀態管理,是指在應用程序的生命周期中,處理各種事件,并控制不同組件之間數據傳遞的過程。
一直以來,我們都習慣于使用針對JavaScript應用的、流行且強大的Redux庫,作為狀態容器。而React本身在其16.8版中已增加了Hooks。在本文中,我將根據自己在使用React SDK,構建生產級數據可視化工具過程中的經驗,和您探討這兩種狀態管理的方法,并介紹作為第三種方法的混合使用。
狀態管理戰略規劃
首先,讓我們來考慮狀態管理的兩個難題:需要存儲什么狀態,以及為什么要如此。畢竟,在數據可視化的應用中,并非所有狀態都是相同的。
如下應用示例所示,我們希望通過圖表中顯示的節點和鏈接,以獲悉當前的各個連接,以及與時間線組件共享的數據,進而甄別出數據集中的時間戳。其Sidebar包括了用于搜索和更新圖表、及時間線的UI元素。簡單而言,我們的目標就是實現如下圖形和時間線的可視化。具體請參見--KronoGraph(。
在狀態管理策略的規劃階段,我們可以通過在軸上繪制狀態,以了解正在處理的具體內容:
如上圖所示,我們在此所遵循的原則為:
- 條目類型:除非您正在構建一個通用應用,否則圖表和時間線中的節點類型(如:人員、地點、車輛)都應當盡可能是靜態的。由于我們可以提前定義它們,因此它們不需要帶有狀態,可以位于存儲庫的配置文件中。
- 條目樣式:包含了每個節點和鏈接類型的核心樣式,以及它們的預期邏輯。
- 主題選擇:為用戶提供了在暗模式與亮模式之間切換的選項,并通過該狀態的變化,去跟蹤用戶的偏好。
- UI狀態:UI狀態包括靜態和臨時等。雖然我們沒有必要在狀態中,存儲所有關于表單的交互,但是需謹防那些可能導致應用處于無響應狀態的常見錯誤。
- 條目位置和時間線范圍:網絡中的節點位置可能并不固定:
- 在圖表中,用戶可以根據偏好進行布局,并手動定位節點。
- 在時間線中,用戶可以放大其感興趣的時間范圍。
- 在不同的會話中,通過位置的保持,用戶可以從上一次中斷處繼續。
- 撤消與重做棧:在高級應用中,我們需要通過設計,讓用戶能夠在各自的當前會話中,保留撤消與重做數據的權限。
- 來自API的數據:功能強大的應用程序,需要將那些從外部端點或API接收來的、動態且臨時的數據緩存起來,并保存它們在應用中的狀態。
狀態管理的方法
有了前面狀態管理的規劃,我們來考慮應用中的數據層次結構。目前,我們有三種主要的狀態管理方法可供選擇:
- 處理組件中的狀態,并按需使用Hook在狀態間進行傳遞。這種方法通常被稱為“prop drilling”或“提升狀態”,常被推薦用于基礎類的應用。
- 使用某種全局存儲,以便所有組件都可訪問。Redux之類的庫可以提供該功能。
- 使用混合方法,將Hook與那些經過慎重選擇的重要狀態相結合。
下面,讓我們通過上述數據可視化的應用,來進一步探索這三種方法。
Redux狀態管理
自2015年被發布以來,Redux已經成為了React生態系統的關鍵部分。它使用不變性(immutability)來簡化應用程序的開發和邏輯設計。通過將處于某種狀態的所有條目,強制設置為不變性,我們可以跟蹤對于數據的任何更改,進而避免可能導致意外錯誤發生的數據突變。
雖然Redux目前仍是狀態復雜的大型應用的絕佳選擇,但是隨著時間的推移,它變得日漸臃腫。為了協助降低其復雜性,Redux Toolkit于2019年應運而生,并成為了Redux的首推方式。
一致性的狀態更新
Redux的一個核心概念是reducer。對于那些具有函數編程經驗的人而言,這是一個能夠接受多個輸入,并將其減少為單個輸出的函數。在狀態管理中,該擴展能夠讓您通過采用一個或多個狀態的更新指令,為圖表生成一致性的狀態更新。
讓我們來考慮一個標準化的圖形可視化用例:在圖表中添加和刪除節點。為了在全局存儲中創建一個狀態“切片”,我們在store.js中創建了如下代碼:
JavaScript
- import { configureStore } from '@reduxjs/toolkit';
- import itemsReducer from '../features/chart/itemsSlice';
- export const store = configureStore({
- reducer: {
- items: itemsReducer
- }
- });
為了讓應用程序中的其他組件能夠訪問該存儲,我們可以對應用程序進行如下“包裝”:
JavaScript
- importReactfrom 'react';
- import ReactDOM from 'react-dom';
- import './index.css';
- import App from './App';
- import { store } from './app/store';
- import { Provider } from 'react-redux';
- import * as serviceWorker from './serviceWorker';
- ReactDOM.render(
- <React.StrictMode>
- <Provider store={store}>
- <App />
- </Provider>
- </React.StrictMode>,
- document.getElementById('root')
- );
其中的Provider段意味著,其任何下游都可以訪問該存儲。在itemsSlice.js中,我們為各個條目定義了狀態切片:
JavaScript
- import { createSlice, createEntityAdapter } from '@reduxjs/toolkit';
- export const itemsAdapter = createEntityAdapter();
- const initialState = itemsAdapter.getInitialState();
- export const itemsSlice = createSlice({
- name: 'items',
- initialState,
- reducers: {
- addItems: itemsAdapter.addMany,
- addItem: itemsAdapter.addOne,
- removeItems: itemsAdapter.removeMany,
- removeItem: itemsAdapter.removeOne,
- },
- });
- export const { addItems, addItem, removeItems, removeItem } = itemsSlice.actions;
- export const { select, selectAll, selectTotal } = itemsAdapter.getSelectors((state) => state.items);
- export default itemsSlice.reducer;
通過上述代碼段,我們可以獲悉:
- ReGraph的條目是各種通過ID索引的節點和鏈接對象。其核心數據結構十分常見。Redux Toolkit會通過一些輔助函數,來處理此類格式數據。在此,我們用到了由createEntityAdapter提供的addMany、addOne、removeMany、以及removeOne等功能。
- 在Redux中,Selector允許我們從存儲中獲取一個狀態片。我可以利用getSelectors適配器,來避免自行編寫狀態查詢代碼。
- 最后,我們導出所有內容,以便在應用程序的其他地方使用。
在應用的其他代碼中,我們還用到了store、reducer和selectors:
JavaScript
- import React from 'react';
- import { useSelector, useDispatch } from 'react-redux';
- import { Chart } from 'regraph';
- import { addItems, addItem, removeItems, removeItem, selectAll, selectTotal } from './itemsSlice';
- import mapValues from 'lodash/mapValues';
- import styles from './NetworkChart.module.css';
- const colors = ['#173753', '#6daedb', '#2892d7', '#1b4353', '#1d70a2'];
- const defaultNodeStyle = (label) => ({
- label: {
- text: `User ${label}`,
- backgroundColor: 'transparent',
- color: 'white',
- },
- border: { width: 2, color: 'white' },
- color: colors[(label - 1) % colors.length],
- });
- const styleItems = (items, theme) => {
- return mapValues(items, (item) => {
- if (item.id1) {
- return { ...defaultLinkStyle(item.id), ...theme[item.type] };
- } else {
- return { ...defaultNodeStyle(item.id), ...theme[item.type] };
- }
- });
- };
- export function NetworkChart() {
- const dispatch = useDispatch();
- const items = useSelector(selectAll);
- const itemCount = useSelector(selectTotal);
- const theme = { user: {} };
- const styledItems = styleItems(items, theme);
- return (
- <div className={styles.container}>
- <Chart
- items={styledItems}
- animation={{ animate: false }}
- options={{ backgroundColor: 'rgba(0,0,0,0)', navigation: false, overview: false }}
- />
- <div className={styles.row}>
- <button
- className={styles.button}
- aria-label="Add items"
- onClick={() => dispatch(addItem({ id: itemCount + 1, type: 'user' }))}
- >
- Add User
- </button>
- <button
- className={styles.button}
- aria-label="Remove Items"
- onClick={() => dispatch(removeItem(itemCount))}
- >
- Remove User
- </button>
- </div>
- </div>
- );
- }
通過使用Redux Hook的suseSelector,我們可以輕松利用切片代碼,來提供選擇器。同時,其useDispatch允許我們根據狀態的“調度(dispatch)”動作(Redux的另一個實用部分),去變更狀態。
使用Redux管理狀態去添加和刪除節點
Redux Toolkit使用時下流行的不變性庫--Immer,對狀態進行“純凈”地更新,而無需額外編寫復雜的克隆和更新邏輯。在此,我們直接在組件中設置了圖表項的樣式。
當您從外部來源獲取數據時,應用程序的狀態和數據庫的存儲之間,很難被清晰地界定。與Redux Toolkit同源的RTK Query則通過與諸如react-query之類的庫相配合,避免了從零開始編寫緩存等功能。
如果您的應用單純依賴Redux,那么可以將整個應用的狀態放在全局存儲中,以便每個組件都能訪問它。當然,實際上只有某一些可視化組件的狀態,需要通過Hooks和Redux的混合方法,實現存儲。
Prop Drilling
著名的軟件工程教育者--Kent C. Dodds曾提出了一個重要的觀點:應保持狀態盡可能地靠近需要的地方。對于上述示例,這意味著如果我們希望在圖表和時間線組件之間共享數據,則可以通過Prop Drilling來簡化并實現。這將是一種跨組件共享狀態的有效且純凈的方式。也就是說,如果我們將狀態帶到VisualizationContainer應用中,則可以將數據作為prop傳遞到每個組件處。當然,如果我需要在復雜的層次結構中上下傳遞,則仍可以使用Redux。
憑借著其強大的API和一些精心設計的prop,ReGraph在控制其內部狀態方面,非常有效。我們甚至不需要讓過多的prop流轉到圖表的組件之外。
React Hooks
就示例中的圖表組件而言,我們可以使用simpleuseState和useRefHooks,來處理狀態中的基本配置。ReGraph可以將那些對于狀態的多次更新處理,通過單獨調用useState對方式來實現,進而免去了prop組的頻繁更新。
JavaScript
- const [layout, setLayout] = useState(defaults.layout);
- setLayout({name: 'sequential'})
對于使用過Redux的人來說,Hook的useReducer以及如下代碼段,一定不會陌生。
JavaScript
- import React, { useState, useReducer, useCallback } from 'react';
- const [combine, combineDispatch] = useReducer(combineReducer, defaults.combine)
- const combineItems = useCallback(property => combineDispatch({ type: 'COMBINE', property }), [])
- const uncombineItems = useCallback(property => combineDispatch({ type: 'UNCOMBINE', property }), [])
- function combineReducer(combine, action) {
- const newCombine = { ...combine };
- if (action.type === 'COMBINE') {
- newCombine.properties.push(action.property);
- newCombine.level = combine.level + 1;
- }
- else if (action.type === 'UNCOMBINE') {
- newCombine.properties.pop();
- newCombine.level = combine.level - 1;
- } else {
- throw new Error(`No action ${action.type} found`);
- }
- return newCombine;
- }
值得注意的是,沒有了Redux Toolkit的幫助,我們需要人工更新已組合的對象。這就意味著,更多的代碼需要被編寫。在上述ReGraph之類的小型應用示例中,我們手動編寫了reducer。
React的useReducer與Redux中的reducer之間存在概念上的差異。在React中,我們編寫了任意數量的reducer。它們只是各種便于更新狀態的Hooks。而在Redux中,它們作為概念性的分離,以應對集中式的存儲。
正如下面代碼段所示,我們可以為ReGraph編寫一個定制的Hook,來封裝所有需要用到的prop:
JavaScript
- import React, { useState, useReducer, useCallback } from 'react';
- import { has, merge, mapValues, isEmpty } from 'lodash';
- import { chart as defaults } from 'defaults';
- const linkColor = '#fff9c4';
- const nodeColor = '#FF6D66';
- function isNode(item) {
- return item.id1 == null && item.id2 == null;
- }
- function transformItems(items, itemFn) {
- return mapValues(items, (item, id) => {
- const newItem = itemFn(item, id);
- return newItem ? merge({}, item, newItem) : item
- });
- };
- function styleItems(items) {
- return transformItems(items, item => {
- return defaults.styles[isNode(item) ? 'node' : 'link'];
- });
- }
- function itemsReducer(items, action) {
- const newItems = { ...items };
- if (action.type === 'SET') {
- return { ...newItems, ...styleItems(action.newItems) }
- }
- else if (action.type === 'REMOVE') {
- Object.keys(action.removeItems).forEach(removeId => { delete newItems[removeId]; })
- return newItems;
- } else {
- throw new Error(`No action ${action.type} found`);
- }
- }
- function combineReducer(combine, action) {
- const newCombine = { ...combine };
- if (action.type === 'COMBINE') {
- newCombine.properties.push(action.property);
- newCombine.level = combine.level + 1;
- }
- else if (action.type === 'UNCOMBINE') {
- newCombine.properties.pop();
- newCombine.level = combine.level - 1;
- } else {
- throw new Error(`No action ${action.type} found`);
- }
- return newCombine;
- }
- function useChart({ initialItems = {} }) {
- const styledItems = styleItems(initialItems)
- const [items, dispatch] = useReducer(itemsReducer, styledItems)
- const addItems = useCallback(newItems => dispatch({ type: 'SET', newItems }), [])
- const removeItems = useCallback(removeItems => dispatch({ type: 'REMOVE', removeItems }), [])
- const [combine, combineDispatch] = useReducer(combineReducer, defaults.combine)
- const combineItems = useCallback(property => combineDispatch({ type: 'COMBINE', property }), [])
- const uncombineItems = useCallback(property => combineDispatch({ type: 'UNCOMBINE', property }), [])
- const [animation, setAnimation] = useState(defaults.animation);
- const [view, setView] = useState(defaults.view);
- const [layout, setLayout] = useState(defaults.layout);
- const [positions, setPositions] = useState(defaults.positions);
- const [selection, setSelection] = useState(defaults.selection);
- const [map, setMap] = useState(defaults.map);
- const [options, setOptions] = useState(defaults.options);
- const chartState = { items, options, layout, positions, selection, map, animation, combine }
- return [chartState, { addItems, removeItems, setPositions, setSelection, combineItems, uncombineItems }]
- }
- export { useChart, isNode }
值得注意的是,由于ReGraph會針對每一個prop用到大量的useState調用,因此我們可以將它們放入一個簡單的對象中,并通過單個函數處理,來實現更新。為了簡單起見,我們可以使用lodash merge,來合并條目的更新。同時,在生產環境中,我們會使用Immer之類的工具,來提高更新的效率。
Context API
我們定制的useChart Hook足以滿足讓單個組件去控制圖表。但是,我們又該如何處置Sidebar呢?此時,我們就需要通過全局范圍的Redux來解決。
作為React API的一部分,由于Context可以讓各種數據在用戶定義的范圍內,被訪問到,因此它可以協助我們實現在Redux中,創建全局存儲。
雖然,業界有對于context是否能成為Redux useContext替代品的爭論,但是有一點可以肯定:它是一種純凈的API,可以在組件之間一致性地共享context。 如下代碼段展示了如何使用Hook和Context:
JavaScript
- import React, { useState, useReducer, useCallback } from 'react';
- import merge from 'lodash/merge';
- import mapValues from 'lodash/mapValues';
- import { chart as defaults } from 'defaults';
- const ChartContext = React.createContext();
- function isNode(item) {
- return item.id1 == null && item.id2 == null;
- }
- function transformItems(items, itemFn) {
- return mapValues(items, (item, id) => {
- const newItem = itemFn(item, id);
- return newItem ? merge({}, item, newItem) : item;
- });
- }
- function styleItems(items) {
- return transformItems(items, (item) => {
- return defaults.styles[isNode(item) ? 'node' : 'link'];
- });
- }
- function itemsReducer(items, action) {
- const newItems = { ...items };
- if (action.type === 'SET') {
- return { ...newItems, ...styleItems(action.newItems) };
- } else if (action.type === 'REMOVE') {
- Object.keys(action.removeItems).forEach((removeId) => {
- delete newItems[removeId];
- });
- return newItems;
- } else {
- throw new Error(`No action ${action.type} found`);
- }
- }
- function combineReducer(combine, action) {
- const newCombine = { ...combine };
- if (action.type === 'COMBINE') {
- newCombine.properties.push(action.property);
- newCombine.level = combine.level + 1;
- } else if (action.type === 'UNCOMBINE') {
- newCombine.properties.pop();
- newCombine.level = combine.level - 1;
- } else {
- throw new Error(`No action ${action.type} found`);
- }
- return newCombine;
- }
- function ChartProvider({ children }) {
- const [items, dispatch] = useReducer(itemsReducer, {});
- const addItems = useCallback((newItems) => dispatch({ type: 'SET', newItems }), []);
- const removeItems = useCallback((removeItems) => dispatch({ type: 'REMOVE', removeItems }), []);
- const [combine, combineDispatch] = useReducer(combineReducer, defaults.combine);
- const combineItems = useCallback((property) => combineDispatch({ type: 'COMBINE', property }),[]);
- const uncombineItems = useCallback((property) => combineDispatch({ type: 'UNCOMBINE', property }),[]);
- const [animation, setAnimation] = useState(defaults.animation);
- const [view, setView] = useState(defaults.view);
- const [layout, setLayout] = useState(defaults.layout);
- const [positions, setPositions] = useState(defaults.positions);
- const [selection, setSelection] = useState(defaults.selection);
- const [map, setMap] = useState(defaults.map);
- const [options, setOptions] = useState(defaults.options);
- const value = [
- { view, items, options, layout, positions, selection, map, animation, combine },
- { addItems, removeItems, setOptions, setMap, setView, setLayout, setAnimation, setPositions, setSelection, combineItems, uncombineItems },
- ];
- return <ChartContext.Provider value={value}>{children}</ChartContext.Provider>;
- }
- function useChart() {
- const context = React.useContext(ChartContext);
- if (context === undefined) {
- throw new Error('useChart must be used within a ChartProvider');
- }
- return context;
- }
- export { ChartProvider, useChart };
下面,我使用定制的ChartProvider上下文,來包裝那些需要訪問圖表的詳細信息,以及設置器的任何組件:
HTML
- <App>
- <ChartProvider>
- <VisualizationContainer>
- <Chart/>
- <Timeline/>
- </VisualizationContainer>
- <Sidebar/>
- </ChartProvider>
- </App>
接著,我們需要通過如下簡單的調用,導入useChart,并獲取當前圖表的狀態,以及應用層次結構中任意位置的調度函數。
- const [state, { setLayout }] = useChart();
Context與Redux
可見,使用Context和Redux存儲之間的關鍵區別在于,Context必須由您來定義范圍,而不會自動地為應用程序的其余部分提供服務。這會迫使我們更加有意識地去規劃應用程序的邏輯。正如useReducer那樣,我們通常的做法是:創建許多不同的上下文,以供應用程序去使用。
小結
綜上所述,我們先介紹了如何使用Redux Toolkit的綜合狀態管理策略,去實現全局存儲;然后探究了一個簡單的應用程序,如何通過使用核心的React Hooks,去實現狀態管理的各個細節;最后得出兩者可以混合使用,相互補足的使用建議。
原文標題:React Hooks vs. Redux: Choosing the Right State Management Strategy ,作者:Christian Miles
【51CTO譯稿,合作站點轉載請注明原文譯者和出處為51CTO.com】