我們或許不需要 Redux 這個庫
之前和大家分享了我們不需要系列:
接下來我們繼續調整難度, 替換應用範圍更廣的庫:redux
,react-redux
《我們或許不需要...》系列如果是做輪子就沒有意義了, 此係列目的是通過簡單的程式碼, 對原有庫的設計思路進行概括提取, 最終從理解其理念 到更高效 的開發專案的過程.
此行目的
Redux 在 React 中的重要性在此不再暫開, 其設計理念(單向資料流, 提供者模式)深得人心, 但是在實際開發中, 每個引用 Redux 的專案都會需要解決以下三個問題:
- 如何設計狀態管理在工程中的模組結構
- 如何在不影響設計結構的前提下減少編寫 action, reducer 的模板程式碼
- 如何減少不必要的重繪(immutable)
我們最後會基於 Context, 使用 20 行程式碼和一點點規範滿足以上目標
本文中提到的程式碼都可以直接貼上至專案中進行驗證.
redux 官方最佳實踐
首先編寫專案入口
// src/index.js import React from 'react'; import { render } from 'react-dom'; import { Provider } from 'react-redux'; import Home from './Home'; import ChangerBar from './ChangerBar'; import store from './store'; // 我們在專案的最外層包裹一個 Provider 物件, 以實現提供者模式 function App() { return ( <Provider store={store}> <div> <ChangerBar /> <Home /> </div> </Provider> ); } render(<App />, document.getElementById('root')); 複製程式碼
接下來初始化狀態, 並且編寫 reducer, 根據後續 dispatch 傳遞的 action 物件修改狀態
// src/store.js import { createStore } from 'redux'; const defaultStore = { name: 'dog', friends: ['cat', 'fish'], age: 100, }; function reducer(store = defaultStore, action) { switch (action.type) { case 'changeName': store = { ...store, name: action.name }; break; case 'addAge': store = { ...store, age: store.age + 1 }; break; default: break; } return store; } const store = createStore(reducer); store.dispatch({ type: 'init' }); export default store; 複製程式碼
接下來我們建立 actions 檔案, 裡面是 action 的集合
// src/actions.js export function changeName(name) { return { type: 'changeName', name, }; } export function addAge() { return { type: 'addAge', }; } 複製程式碼
我們繪製一個元件, 用來修改全域性狀態
react-redux 提供了一個 connect 元件, 它其實是一個 HOC(高階元件), redux 這樣的設計目的有兩個:
- 監聽和釋放對 store 的訂閱的行為被封裝在 HOC 中, 這樣可以不必每次都編寫此邏輯程式碼;
- 將 state 和 disptch 物件轉換為 props 注入至元件中, 而不是由元件去引用外部的物件, 這樣元件內部只有一個概念就是 props.
// src/ChangerBar.js import React from 'react'; import { connect } from 'react-redux'; import * as actions from './actions'; const ChangerBar = ({ changeName, addAge }) => { function handleOnChange(e) { changeName(e.target.value); } return ( <div> <div>bar</div> <input placeholder="修改姓名" onChange={handleOnChange} /> <button type="button" onClick={addAge}> 更新年齡 </button> </div> ); }; // 將 dispatch 注入到元件Props中 function mapDispatchToProps(dispatch) { return { addAge: () => dispatch(actions.addAge()), changeName: name => dispatch(actions.changeName(name)), }; } export default connect( null, mapDispatchToProps, )(ChangerBar); 複製程式碼
實現 Home 元件, 訂閱資料, 驗證狀態管理(UI 更新)
// src/Home.js import React from 'react'; import { connect } from 'react-redux'; const Home = ({ name, age }) => { return ( <div> <div>name: {name}</div> <div>age: {age}</div> </div> ); }; // 將 state 的值注入到 props 中 function mapStateToProps(state) { // 當任何一個 dispatch() 執行時, 此處將會重新執行, 並且注入新的 name 和 age 至元件 props 中, 以更新元件 return { name: state.name, age: state.age, }; } export default connect(mapStateToProps)(Home); 複製程式碼
以上程式碼相信有一定經驗的 React 開發者已經非常熟悉, 當專案逐漸複雜時, 我們會逐步修改專案結構, 常見的有兩種:
- 鴨子模式, 將狀態管理分佈在一個個頁面中, 跨頁面的狀態管理提升至全域性, dva 使用的是此模式, 每個需要使用全域性狀態的頁面都會有一個自己的 action 和 reducer
- 中心化的狀態管理: 將整個 actions 和 reducer 規整到一個全域性目錄中, actions 以事件為約定, 而不是以頁面
這兩種方式各有千秋, 鴨子模式的缺點是我們無法避免有跨頁面的狀態管理情況發生, 所以狀態會被分佈在全域性和區域性兩處.
以上模式還有一個弊端就是 action.type 我們需要保持一致, 當 action 過多時, 可能需要一個 types 的檔案用來儲存 action.type 常量, 如此一來我們每編寫一個新的狀態需要:
- 開啟 types 檔案, 新增一個 action.type 常量
- 開啟某個 action 檔案, 引入 types, 編寫 action
- 開啟某個 reducer 檔案, 引入 types, 編寫 reducer
- 開啟容器元件檔案, 引入 connect, actions, 編寫狀態的獲取和觸發更新
以上還是沒有引入 sage 和 immutable , 寫到這裡已經感受到我們 react 開發者正處於水深火熱之中, 我們得加緊步伐.
接下來我們拋棄 redux 重寫以上程式碼
利用 context 實現 react-redux 類似功能
redux 作者在 context API 更新之後, 提到過, 有了 context 我們可以不需要 redux 了, 所言非虛.
我們建立一個類似 createStore 的函式, 此函式會建立一個 Provider 和一個 store, 我們要利用不可變資料減少不必要的的重繪, 這裡使用 immer:
// src/createContextRedux.js import React, { createContext, useMemo } from 'react'; import immer from 'immer'; export default function createContextRedux() { // 建立一個context, 用於後續配合 useContext 進行更新元件 const store = createContext(); // 建立一個提供者元件 const Provider = ({ defaultState = {}, ...rest }) => { const [state, setState] = React.useState(defaultState); // 僅有 state 變更了, 才會重新更新 context 和 store return useMemo(() => { // 使用 immer 進行更新狀態, 確保未更新的物件還是舊的引用 const dispatch = fn => setState(immer(state, v => fn(v))); store.state = state; store.dispatch = dispatch; return <store.Provider value={state} {...rest} />; }, [state]); }; return { Provider, store }; } 複製程式碼
好的, 這 20 行程式碼就是狀態管理庫的全部, 接下來我們利用它去實現剛剛的業務
重寫 store, 我們引入剛剛編寫的狀態管理庫, 然後建立全域性 Provider 和 store:
// src/store.js import createContextRedux from './createContextRedux'; const { Provider, store } = createContextRedux(); export { Provider, store }; 複製程式碼
在專案最頂層使用 Provider 包裹, 以提供 context
// index.js import React from 'react'; import { render } from 'react-dom'; import { Provider } from './store'; import Home from './Home'; import ChangerBar from './ChangerBar'; function App() { return ( <Provider> <ChangerBar /> <Home /> </Provider> ); } render(<App />, document.getElementById('root')); 複製程式碼
這裡我們移除了 reducer, 只有 action 的概念, 可以簡化非常多的程式碼量
我修改 actions.js 檔案, 由於當前只有 action 的概念, 所以非常適合使用 action 中心化的方式, 將容器元件的 action 都彙集放置一處, 容器元件僅讀取狀態和呼叫 action
// src/actions.js import { store } from './store'; export function changeName(name) { // 我們直接修改狀態物件即可, 該函式會利用 immer 建立一個新的物件返回, 沒有被修改的子物件還是舊的引用 store.dispatch(state => { state.name = name; }); } export function addAge() { store.dispatch(state => { if (state.age === void 0) { state.age = 0; } state.age += 1; }); } 複製程式碼
ChangerBar 不需要關聯狀態, 它只需要引用 actions 即可
// src/ChangerBar.js import React from 'react'; import * as actions from './actions'; const ChangerBar = () => { // 使用 hook 獲取 context, 代替 conncet function handleOnChange(e) { actions.changeName(e.target.value); } function handleAddAage() { actions.addAge(); } return ( <div> <div>bar</div> <input placeholder="修改姓名" onChange={handleOnChange} /> <button type="button" onClick={handleAddAage}> 更新年齡 </button> </div> ); }; export default ChangerBar; 複製程式碼
最後在 Home 頁面讀取全域性資料, 監聽全域性修改, 使用 useContext 代替 connect
// src/Home.js import React from 'react'; import { store } from './store'; const Home = () => { const { name, age } = React.useContext(store); return ( <div> <div>name: {name}</div> <div>age: {age}</div> </div> ); }; export default Home; 複製程式碼
序
有時候序也可以寫在結尾, 不是麼?
上面可能貼的程式碼太多了, 並且較為分散, 我們把它聚合成兩個檔案重新閱讀:
實現狀態管理
import React, { createContext, useMemo } from 'react'; import immer from 'immer'; export default function createContextRedux() { // 建立一個context, 用於後續配合 useContext 進行更新元件 const store = createContext(); // 建立一個提供者元件 const Provider = ({ defaultState = {}, ...rest }) => { const [state, setState] = React.useState(defaultState); // 僅有 state 變更了, 才會重新更新 context 和 store return useMemo(() => { // 使用 immer 進行更新狀態, 確保未更新的物件還是舊的引用 const dispatch = fn => setState(immer(state, v => fn(v))); store.state = state; store.dispatch = dispatch; return <store.Provider value={state} {...rest} />; }, [state]); }; return { Provider, store }; } 複製程式碼
使用狀態管理
// src/index.js import React from 'react'; import { render } from 'react-dom'; import createContextRedux from './createStore'; const { Provider, store } = createContextRedux(); // 模擬一個非同步 function fetchData() { return new Promise(res => { setTimeout(() => { res(); }, 500); }); } // 一個基礎的 action, 用來修改狀態 // 在實際專案中, action 應該統一放置一處, 不應該分散在各元件中 async function actionOfAddNum() { await fetchData(); store.dispatch(state => { state.age += 1; }); } // 點選之後, 利用 action 修改全域性狀態 function Changer() { return ( <button type="button" onClick={actionOfAddNum}> add </button> ); } // 利用 useContext 監聽全域性狀態, 並隨時進行更新 function Shower() { const { age } = React.useContext(store); return <div>age: {age}</div>; } function App() { return ( <Provider defaultState={{ age: 0 }}> <Shower /> <Changer /> </Provider> ); } render(<App />, document.getElementById('root')); 複製程式碼
最終我們移除了 redux, 在確保單向資料流
的狀態邏輯上, 移除了 reducer, connect 的步驟, 將模板程式碼減少至一份:編寫 action
;
通過分離 action 和元件, 解耦狀態變更和更新.