手寫一個React-Redux,玩轉React的Context API
阿新 • • 發佈:2020-07-09
[上一篇文章我們手寫了一個Redux](https://juejin.im/post/5efec81be51d4534942dd589),但是單純的Redux只是一個狀態機,是沒有UI呈現的,所以一般我們使用的時候都會配合一個UI庫,比如在React中使用Redux就會用到`React-Redux`這個庫。這個庫的作用是將Redux的狀態機和React的UI呈現繫結在一起,當你`dispatch action`改變`state`的時候,會自動更新頁面。本文還是從它的基本使用入手來自己寫一個`React-Redux`,然後替換官方的NPM庫,並保持功能一致。
**本文全部程式碼已經上傳GitHub,大家可以拿下來玩玩:[https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/React/react-redux](https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/React/react-redux)**
## 基本用法
下面這個簡單的例子是一個計數器,跑起來效果如下:
![Jul-02-2020 16-44-04](https://user-gold-cdn.xitu.io/2020/7/8/1732dd26deccaa02?w=338&h=112&f=gif&s=62209)
要實現這個功能,首先我們要在專案裡面新增`react-redux`庫,然後用它提供的`Provider`包裹整個`React`App的根元件:
```javascript
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux'
import store from './store'
import App from './App';
ReactDOM.render(
,
document.getElementById('root')
);
```
上面程式碼可以看到我們還給`Provider`提供了一個引數`store`,這個引數就是Redux的`createStore`生成的`store`,我們需要調一下這個方法,然後將返回的`store`傳進去:
```javascript
import { createStore } from 'redux';
import reducer from './reducer';
let store = createStore(reducer);
export default store;
```
上面程式碼中`createStore`的引數是一個`reducer`,所以我們還要寫個`reducer`:
```javascript
const initState = {
count: 0
};
function reducer(state = initState, action) {
switch (action.type) {
case 'INCREMENT':
return {...state, count: state.count + 1};
case 'DECREMENT':
return {...state, count: state.count - 1};
case 'RESET':
return {...state, count: 0};
default:
return state;
}
}
export default reducer;
```
這裡的`reduce`會有一個初始`state`,裡面的`count`是`0`,同時他還能處理三個`action`,這三個`action`對應的是UI上的三個按鈕,可以對`state`裡面的計數進行加減和重置。到這裡其實我們`React-Redux`的接入和`Redux`資料的組織其實已經完成了,後面如果要用`Redux`裡面的資料的話,只需要用`connect`API將對應的`state`和方法連線到元件裡面就行了,比如我們的計數器元件需要`count`這個狀態和加一,減一,重置這三個`action`,我們用`connect`將它連線進去就是這樣:
```javascript
import React from 'react';
import { connect } from 'react-redux';
import { increment, decrement, reset } from './actions';
function Counter(props) {
const {
count,
incrementHandler,
decrementHandler,
resetHandler
} = props;
return (
<>
);
```
所以我們完全可以用`context api`來傳遞`redux store`,現在我們也可以猜測`React-Redux`的`Provider`其實就是包裝了`Context.Provider`,而傳遞的引數就是`redux store`,而`React-Redux`的`connect`HOC其實就是包裝的`Context.Consumer`或者`useContext`。我們可以按照這個思路來自己實現下`React-Redux`了。
## 手寫`Provider`
上面說了`Provider`用了`context api`,所以我們要先建一個`context`檔案,匯出需要用的`context`:
```javascript
// Context.js
import React from 'react';
const ReactReduxContext = React.createContext();
export default ReactReduxContext;
```
這個檔案很簡單,新建一個`context`再匯出就行了,[對應的原始碼看這裡](https://github.com/reduxjs/react-redux/blob/master/src/components/Context.js)。
然後將這個`context`應用到我們的`Provider`元件裡面:
```javascript
import React from 'react';
import ReactReduxContext from './Context';
function Provider(props) {
const {store, children} = props;
// 這是要傳遞的context
const contextValue = { store };
// 返回ReactReduxContext包裹的元件,傳入contextValue
// 裡面的內容就直接是children,我們不動他
return (
)
}
```
`Provider`的元件程式碼也不難,直接將傳進來的`store`放到`context`上,然後直接渲染`children`就行,[對應的原始碼看這裡](https://github.com/reduxjs/react-redux/blob/master/src/components/Provider.js)。
## 手寫`connect`
### 基本功能
其實`connect`才是React-Redux中最難的部分,裡面功能複雜,考慮的因素很多,想要把它搞明白我們需要一層一層的來看,首先我們實現一個只具有基本功能的`connect`。
```javascript
import React, { useContext } from 'react';
import ReactReduxContext from './Context';
// 第一層函式接收mapStateToProps和mapDispatchToProps
function connect(mapStateToProps, mapDispatchToProps) {
// 第二層函式是個高階元件,裡面獲取context
// 然後執行mapStateToProps和mapDispatchToProps
// 再將這個結果組合使用者的引數作為最終引數渲染WrappedComponent
// WrappedComponent就是我們使用connext包裹的自己的元件
return function connectHOC(WrappedComponent) {
function ConnectFunction(props) {
// 複製一份props到wrapperProps
const { ...wrapperProps } = props;
// 獲取context的值
const context = useContext(ReactReduxContext);
const { store } = context; // 解構出store
const state = store.getState(); // 拿到state
// 執行mapStateToProps和mapDispatchToProps
const stateProps = mapStateToProps(state);
const dispatchProps = mapDispatchToProps(store.dispatch);
// 組裝最終的props
const actualChildProps = Object.assign({}, stateProps, dispatchProps, wrapperProps);
// 渲染WrappedComponent
return
}
return ConnectFunction;
}
}
export default connect;
```
### 觸發更新
用上面的`Provider`和`connect`替換官方的`react-redux`其實已經可以渲染出頁面了,但是點選按鈕還不會有反應,因為我們雖然通過`dispatch`改變了`store`中的`state`,但是這種改變並沒有觸發我們元件的更新。之前Redux那篇文章講過,可以用`store.subscribe`來監聽`state`的變化並執行回撥,我們這裡需要註冊的回撥是檢查我們最終給`WrappedComponent`的`props`有沒有變化,如果有變化就重新渲染`ConnectFunction`,所以這裡我們需要解決兩個問題:
> 1. 當我們`state`變化的時候檢查最終給到`ConnectFunction`的引數有沒有變化
> 2. 如果這個引數有變化,我們需要重新渲染`ConnectFunction`
#### 檢查引數變化
要檢查引數的變化,我們需要知道上次渲染的引數和本地渲染的引數,然後拿過來比一下就知道了。為了知道上次渲染的引數,我們可以直接在`ConnectFunction`裡面使用`useRef`將上次渲染的引數記錄下來:
```javascript
// 記錄上次渲染引數
const lastChildProps = useRef();
useLayoutEffect(() => {
lastChildProps.current = actualChildProps;
}, []);
```
注意`lastChildProps.current`是在第一次渲染結束後賦值,而且需要使用`useLayoutEffect`來保證渲染後立即同步執行。
因為我們檢測引數變化是需要重新計算`actualChildProps`,計算的邏輯其實都是一樣的,我們將這塊計算邏輯抽出來,成為一個單獨的方法`childPropsSelector`:
```javascript
function childPropsSelector(store, wrapperProps) {
const state = store.getState(); // 拿到state
// 執行mapStateToProps和mapDispatchToProps
const stateProps = mapStateToProps(state);
const dispatchProps = mapDispatchToProps(store.dispatch);
return Object.assign({}, stateProps, dispatchProps, wrapperProps);
}
```
然後就是註冊`store`的回撥,在裡面來檢測引數是否變了,如果變了就強制更新當前元件,對比兩個物件是否相等,`React-Redux`裡面是採用的`shallowEqual`,也就是淺比較,也就是隻對比一層,如果你`mapStateToProps`返回了好幾層結構,比如這樣:
```json
{
stateA: {
value: 1
}
}
```
你去改了`stateA.value`是不會觸發重新渲染的,`React-Redux`這樣設計我想是出於效能考慮,如果是深比較,比如遞迴去比較,比較浪費效能,而且如果有迴圈引用還可能造成死迴圈。採用淺比較就需要使用者遵循這種正規化,不要傳入多層結構,[這點在官方文件中也有說明](https://react-redux.js.org/using-react-redux/connect-mapstate#return)。我們這裡直接抄一個它的淺比較:
```javascript
// shallowEqual.js
function is(x, y) {
if (x === y) {
return x !== 0 || y !== 0 || 1 / x === 1 / y
} else {
return x !== x && y !== y
}
}
export default function shallowEqual(objA, objB) {
if (is(objA, objB)) return true
if (
typeof objA !== 'object' ||
objA === null ||
typeof objB !== 'object' ||
objB === null
) {
return false
}
const keysA = Object.keys(objA)
const keysB = Object.keys(objB)
if (keysA.length !== keysB.length) return false
for (let i = 0; i < keysA.length; i++) {
if (
!Object.prototype.hasOwnProperty.call(objB, keysA[i]) ||
!is(objA[keysA[i]], objB[keysA[i]])
) {
return false
}
}
return true
}
```
在回撥裡面檢測引數變化:
```javascript
// 註冊回撥
store.subscribe(() => {
const newChildProps = childPropsSelector(store, wrapperProps);
// 如果引數變了,記錄新的值到lastChildProps上
// 並且強制更新當前元件
if(!shallowEqual(newChildProps, lastChildProps.current)) {
lastChildProps.current = newChildProps;
// 需要一個API來強制更新當前元件
}
});
```
#### 強制更新
要強制更新當前元件的方法不止一個,如果你是用的`Class`元件,你可以直接`this.setState({})`,老版的`React-Redux`就是這麼幹的。但是新版`React-Redux`用hook重寫了,那我們可以用React提供的`useReducer`或者`useState`hook,`React-Redux`原始碼用了`useReducer`,為了跟他保持一致,我也使用`useReducer`:
```javascript
function storeStateUpdatesReducer(count) {
return count + 1;
}
// ConnectFunction裡面
function ConnectFunction(props) {
// ... 前面省略n行程式碼 ...
// 使用useReducer觸發強制更新
const [
,
forceComponentUpdateDispatch
] = useReducer(storeStateUpdatesReducer, 0);
// 註冊回撥
store.subscribe(() => {
const newChildProps = childPropsSelector(store, wrapperProps);
if(!shallowEqual(newChildProps, lastChildProps.current)) {
lastChildProps.current = newChildProps;
forceComponentUpdateDispatch();
}
});
// ... 後面省略n行程式碼 ...
}
```
`connect`這塊程式碼主要對應的是原始碼中`connectAdvanced`這個類,基本原理和結構跟我們這個都是一樣的,只是他寫的更靈活,支援使用者傳入自定義的`childPropsSelector`和合並`stateProps, dispatchProps, wrapperProps`的方法。有興趣的朋友可以去看看他的原始碼:[https://github.com/reduxjs/react-redux/blob/master/src/components/connectAdvanced.js](https://github.com/reduxjs/react-redux/blob/master/src/components/connectAdvanced.js)
到這裡其實已經可以用我們自己的`React-Redux`替換官方的了,計數器的功能也是支援了。但是下面還想講一下`React-Redux`是怎麼保證元件的更新順序的,因為原始碼中很多程式碼都是在處理這個。
## 保證元件更新順序
前面我們的`Counter`元件使用`connect`連線了`redux store`,假如他下面還有個子元件也連線到了`redux store`,我們就要考慮他們的回撥的執行順序的問題了。我們知道React是單向資料流的,引數都是由父元件傳給子元件的,現在引入了`Redux`,即使父元件和子元件都引用了同一個變數`count`,但是子元件完全可以不從父元件拿這個引數,而是直接從`Redux`拿,這樣就打破了`React`本來的資料流向。在`父->子`這種單向資料流中,如果他們的一個公用變數變化了,肯定是父元件先更新,然後引數傳給子元件再更新,但是在`Redux`裡,資料變成了`Redux -> 父,Redux -> 子`,`父`與`子`完全可以根據`Redux`的資料進行獨立更新,而不能完全保證父級先更新,子級再更新的流程。所以`React-Redux`花了不少功夫來手動保證這個更新順序,`React-Redux`保證這個更新順序的方案是在`redux store`外,再單獨建立一個監聽者類`Subscription`:
> 1. `Subscription`負責處理所有的`state`變化的回撥
> 2. 如果當前連線`redux`的元件是第一個連線`redux`的元件,也就是說他是連線`redux`的根元件,他的`state`回撥直接註冊到`redux store`;同時新建一個`Subscription`例項`subscription`通過`context`傳遞給子級。
> 3. 如果當前連線`redux`的元件不是連線`redux`的根元件,也就是說他上面有元件已經註冊到`redux store`了,那麼他可以拿到上面通過`context`傳下來的`subscription`,原始碼裡面這個變數叫`parentSub`,那當前元件的更新回撥就註冊到`parentSub`上。同時再新建一個`Subscription`例項,替代`context`上的`subscription`,繼續往下傳,也就是說他的子元件的回撥會註冊到當前`subscription`上。
> 4. 當`state`變化了,根元件註冊到`redux store`上的回撥會執行更新根元件,同時根元件需要手動執行子元件的回撥,子元件回撥執行會觸發子元件更新,然後子元件再執行自己`subscription`上註冊的回撥,觸發孫子元件更新,孫子元件再呼叫註冊到自己`subscription`上的回撥。。。這樣就實現了從根元件開始,一層一層更新子元件的目的,保證了`父->子`這樣的更新順序。
### `Subscription`類
所以我們先新建一個`Subscription`類:
```javascript
export default class Subscription {
constructor(store, parentSub) {
this.store = store
this.parentSub = parentSub
this.listeners = []; // 原始碼listeners是用連結串列實現的,我這裡簡單處理,直接陣列了
this.handleChangeWrapper = this.handleChangeWrapper.bind(this)
}
// 子元件註冊回撥到Subscription上
addNestedSub(listener) {
this.listeners.push(listener)
}
// 執行子元件的回撥
notifyNestedSubs() {
const length = this.listeners.length;
for(let i = 0; i < length; i++) {
const callback = this.listeners[i];
callback();
}
}
// 回撥函式的包裝
handleChangeWrapper() {
if (this.onStateChange) {
this.onStateChange()
}
}
// 註冊回撥的函式
// 如果parentSub有值,就將回調註冊到parentSub上
// 如果parentSub沒值,那當前元件就是根元件,回撥註冊到redux store上
trySubscribe() {
this.parentSub
? this.parentSub.addNestedSub(this.handleChangeWrapper)
: this.store.subscribe(this.handleChangeWrapper)
}
}
```
[`Subscription`對應的原始碼看這裡](https://github.com/reduxjs/react-redux/blob/master/src/utils/Subscription.js)。
### 改造`Provider`
然後在我們前面自己實現的`React-Redux`裡面,我們的根元件始終是`Provider`,所以`Provider`需要例項化一個`Subscription`並放到`context`上,而且每次`state`更新的時候需要手動呼叫子元件回撥,程式碼改造如下:
```javascript
import React, { useMemo, useEffect } from 'react';
import ReactReduxContext from './Context';
import Subscription from './Subscription';
function Provider(props) {
const {store, children} = props;
// 這是要傳遞的context
// 裡面放入store和subscription例項
const contextValue = useMemo(() => {
const subscription = new Subscription(store)
// 註冊回撥為通知子元件,這樣就可以開始層級通知了
subscription.onStateChange = subscription.notifyNestedSubs
return {
store,
subscription
}
}, [store])
// 拿到之前的state值
const previousState = useMemo(() => store.getState(), [store])
// 每次contextValue或者previousState變化的時候
// 用notifyNestedSubs通知子元件
useEffect(() => {
const { subscription } = contextValue;
subscription.trySubscribe()
if (previousState !== store.getState()) {
subscription.notifyNestedSubs()
}
}, [contextValue, previousState])
// 返回ReactReduxContext包裹的元件,傳入contextValue
// 裡面的內容就直接是children,我們不動他
return (
)
}
export default Provider;
```
### 改造`connect`
有了`Subscription`類,`connect`就不能直接註冊到`store`了,而是應該註冊到父級`subscription`上,更新的時候除了更新自己還要通知子元件更新。在渲染包裹的元件時,也不能直接渲染了,而是應該再次使用`Context.Provider`包裹下,傳入修改過的`contextValue`,這個`contextValue`裡面的`subscription`應該替換為自己的。改造後代碼如下:
```javascript
import React, { useContext, useRef, useLayoutEffect, useReducer } from 'react';
import ReactReduxContext from './Context';
import shallowEqual from './shallowEqual';
import Subscription from './Subscription';
function storeStateUpdatesReducer(count) {
return count + 1;
}
function connect(
mapStateToProps = () => {},
mapDispatchToProps = () => {}
) {
function childPropsSelector(store, wrapperProps) {
const state = store.getState(); // 拿到state
// 執行mapStateToProps和mapDispatchToProps
const stateProps = mapStateToProps(state);
const dispatchProps = mapDispatchToProps(store.dispatch);
return Object.assign({}, stateProps, dispatchProps, wrapperProps);
}
return function connectHOC(WrappedComponent) {
function ConnectFunction(props) {
const { ...wrapperProps } = props;
const contextValue = useContext(ReactReduxContext);
const { store, subscription: parentSub } = contextValue; // 解構出store和parentSub
const actualChildProps = childPropsSelector(store, wrapperProps);
const lastChildProps = useRef();
useLayoutEffect(() => {
lastChildProps.current = actualChildProps;
}, [actualChildProps]);
const [
,
forceComponentUpdateDispatch
] = useReducer(storeStateUpdatesReducer, 0)
// 新建一個subscription例項
const subscription = new Subscription(store, parentSub);
// state回撥抽出來成為一個方法
const checkForUpdates = () => {
const newChildProps = childPropsSelector(store, wrapperProps);
// 如果引數變了,記錄新的值到lastChildProps上
// 並且強制更新當前元件
if(!shallowEqual(newChildProps, lastChildProps.current)) {
lastChildProps.current = newChildProps;
// 需要一個API來強制更新當前元件
forceComponentUpdateDispatch();
// 然後通知子級更新
subscription.notifyNestedSubs();
}
};
// 使用subscription註冊回撥
subscription.onStateChange = checkForUpdates;
subscription.trySubscribe();
// 修改傳給子級的context
// 將subscription替換為自己的
const overriddenContextValue = {
...contextValue,
subscription
}
// 渲染WrappedComponent
// 再次使用ReactReduxContext包裹,傳入修改過的context
return (
)
}
return ConnectFunction;
}
}
export default connect;
```
到這裡我們的`React-Redux`就完成了,跑起來的效果跟官方的效果一樣,完整程式碼已經上傳GitHub了:[https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/React/react-redux](https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/React/react-redux)
下面我們再來總結下`React-Redux`的核心原理。
## 總結
1. `React-Redux`是連線`React`和`Redux`的庫,同時使用了`React`和`Redux`的API。
2. `React-Redux`主要是使用了`React`的`context api`來傳遞`Redux`的`store`。
3. `Provider`的作用是接收`Redux store`並將它放到`context`上傳遞下去。
4. `connect`的作用是從`Redux store`中選取需要的屬性傳遞給包裹的元件。
5. `connect`會自己判斷是否需要更新,判斷的依據是需要的`state`是否已經變化了。
6. `connect`在判斷是否變化的時候使用的是淺比較,也就是隻比較一層,所以在`mapStateToProps`和`mapDispatchToProps`中不要反回多層巢狀的物件。
7. 為了解決父元件和子元件各自獨立依賴`Redux`,破壞了`React`的`父級->子級`的更新流程,`React-Redux`使用`Subscription`類自己管理了一套通知流程。
8. 只有連線到`Redux`最頂級的元件才會直接註冊到`Redux store`,其他子元件都會註冊到最近父元件的`subscription`例項上。
9. 通知的時候從根元件開始依次通知自己的子元件,子元件接收到通知的時候,先更新自己再通知自己的子元件。
## 參考資料
官方文件:[https://react-redux.js.org/](https://react-redux.js.org/)
GitHub原始碼:[https://github.com/reduxjs/react-redux/](https://github.com/reduxjs/react-redux/)
**文章的最後,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支援是作者持續創作的動力。**
**作者博文GitHub專案地址: [https://github.com/dennis-jiang/Front-End-Knowledges](https://github.com/dennis-jiang/Front-End-Knowledges)**
**作者掘金文章彙總:[https://juejin.im/post/5e3ffc85518825494e2772fd](https://juejin.im/post/5e3ffc85518825494e2