React-Redux 100行程式碼簡易版探究原理。(面試熱點,React Hook + TypeScript實現)

晨曦時夢見兮發表於2020-01-11

前言

各位使用react技術棧的小夥伴都不可避免的接觸過redux + react-redux的這套組合,眾所周知redux是一個非常精簡的庫,它和react是沒有做任何結合的,甚至可以在vue專案中使用。

redux的核心狀態管理實現其實就幾行程式碼

function createStore(reducer) {
 let currentState
 let subscribers = []

 function dispatch(action) {
   currentState = reducer(currentState, action);
   subscribers.forEach(s => s())
 }

 function getState() {
   return currentState;
 }
 
 function subscribe(subscriber) {
     subscribers.push(subscriber)
     return function unsubscribe() {
         ...
     }
 }

 dispatch({ type: 'INIT' });

 return {
   dispatch,
   getState,
 };
}

複製程式碼

它就是利用閉包管理了state等變數,然後在dispatch的時候通過使用者定義reducer拿到新狀態賦值給state,再把外部通過subscribe的訂閱給觸發一下。

那redux的實現簡單了,react-redux的實現肯定就需要相對複雜,它需要考慮如何和react的渲染結合起來,如何優化效能。

目標

  1. 本文目標是儘可能簡短的實現react-reduxv7中的hook用法部分Provider, useSelector, useDispatch方法。(不實現connect方法)
  2. 可能會和官方版本的一些複雜實現不一樣,但是保證主要的流程一致。
  3. 用TypeScript實現,並且能獲得完善的型別提示。

預覽

redux gif.gif

預覽地址:sl1673495.github.io/tiny-react-…

效能

說到效能這個點,自從React Hook推出以後,有了useContextuseReducer這些方便的api,新的狀態管理庫如同雨後春筍版的冒了出來,其中的很多就是利用了Context做狀態的向下傳遞。

舉一個最簡單的狀態管理的例子

export const StoreContext = React.createContext();

function App({ children }) {
 const [state, setState] = useState({});
 return <StoreContext.Provider value={{ state, setState }}>{children}</StoreContext.Provider>;
}

function Son() {
 const { state } = useContext(StoreContext);
 return <div>state是{state.xxx}</div>;
}

複製程式碼

利用useState或者useContext,可以很輕鬆的在所有元件之間通過Context共享狀態。

但是這種模式的缺點在於Context會帶來一定的效能問題,下面是React官方文件中的描述:

Context效能問題

想像這樣一個場景,在剛剛所描述的Context狀態管理模式下,我們的全域性狀態中有countmessage兩個狀態分別給通過StoreContext.Provider向下傳遞

  1. Counter計數器元件使用了count
  2. Chatroom聊天室元件使用了message

而在計數器元件通過Context中拿到的setState觸發了count改變的時候,

由於聊天室元件也利用useContext消費了用於狀態管理的StoreContext,所以聊天室元件也會被強制重新渲染,這就造成了效能浪費。

雖然這種情況可以用useMemo進行優化,但是手動優化和管理依賴必然會帶來一定程度的心智負擔,而在不手動優化的情況下,肯定無法達到上面動圖中的重渲染優化。

那麼react-redux作為社群知名的狀態管理庫,肯定被很多大型專案所使用,大型專案裡的狀態可能分散在各個模組下,它是怎麼解決上述的效能缺陷的呢?接著往下看吧。

缺陷示例

在我之前寫的類vuex語法的狀態管理庫react-vuex-hook中,就會有這樣的問題。因為它就是用了Context + useReducer的模式。

你可以直接在 線上示例 這裡,在左側選單欄選擇需要優化的場景,即可看到上述效能問題的重現,優化方案也已經寫在文件底部。

這也是為什麼我覺得Context + useReducer的模式更適合在小型模組之間共享狀態,而不是在全域性。

使用

本文的專案就上述效能場景提煉而成,由

  1. 聊天室元件,用了store中的count
  2. 計數器元件,用了store中的message
  3. 控制檯元件,用來監控元件的重新渲染。

redux的定義

redux的使用很傳統,跟著官方文件對於TypeScript的指導走起來,並且把型別定義和store都export出去。

import { createStore } from 'redux';

type AddAction = {
  type: 'add';
};

type ChatAction = {
  type: 'chat';
  payload: string;
};

type LogAction = {
  type: 'log';
  payload: string;
};

const initState = {
  message: 'Hello',
  logs: [] as string[],
};

export type ActionType = AddAction | ChatAction | LogAction;
export type State = typeof initState;

function reducer(state: State, action: ActionType): State {
  switch (action.type) {
    case 'add':
      return {
        ...state,
        count: state.count + 1,
      };
    case 'chat':
      return {
        ...state,
        message: action.payload,
      };
    case 'log':
      return {
        ...state,
        logs: [action.payload, ...state.logs],
      };
    default:
      return initState;
  }
}

export const store = createStore(reducer);
複製程式碼

在元件中使用

import React, { useState, useCallback } from 'react';
import { Card, Button, Input } from 'antd';
import { Provider, useSelector, useDispatch } from '../src';
import { store, State, ActionType } from './store';
import './index.css';
import 'antd/dist/antd.css';

function Count() {
  const count = useSelector((state: State) => state.count);
  const dispatch = useDispatch<ActionType>();
  // 同步的add
  const add = useCallback(() => dispatch({ type: 'add' }), []);

  dispatch({
    type: 'log',
    payload: '計數器元件重新渲染?',
  });
  return (
    <Card hoverable style={{ marginBottom: 24 }}>
      <h1>計數器</h1>
      <div className="chunk">
        <div className="chunk">store中的count現在是 {count}</div>
        <Button onClick={add}>add</Button>
      </div>
    </Card>
  );
}

export default () => {
  return (
    <Provider store={store}>
      <Count />
    </Provider>
  );
};

複製程式碼

可以看到,我們用Provider元件裡包裹了Count元件,並且把redux的store傳遞了下去

在子元件裡,通過useDispatch可以拿到redux的dispatch, 通過useSelector可以訪問到store,拿到其中任意的返回值。

實現

用最簡短的方式實現程式碼,探究react-redux為什麼能在count發生改變的時候不讓使用了message的元件重新渲染。

實現Context

利用官方api構建context,並且提供一個自定義hook: useReduxContext去訪問這個context,對於忘了用Provider包裹的情況進行一些錯誤提示:

對於不熟悉自定義hook的小夥伴,可以看我之前寫的這篇文章:
使用React Hooks + 自定義Hook封裝一步一步打造一個完善的小型應用。

import React, { useContext } from 'react';
import { Store } from 'redux';

interface ContextType {
  store: Store;
}
export const Context = React.createContext<ContextType | null>(null);

export function useReduxContext() {
  const contextValue = useContext(Context);

  if (!contextValue) {
    throw new Error(
      'could not find react-redux context value; please ensure the component is wrapped in a <Provider>',
    );
  }

  return contextValue;
}
複製程式碼

實現Provider

import React, { FC } from 'react';
import { Store } from 'redux';
import { Context } from './Context';

interface ProviderProps {
  store: Store;
}

export const Provider: FC<ProviderProps> = ({ store, children }) => {
  return <Context.Provider value={{ store }}>{children}</Context.Provider>;
};
複製程式碼

實現useDispatch

這裡就是簡單的把dispatch返回出去,通過泛型傳遞讓外部使用的時候可以獲得型別提示。

泛型推導不熟悉的小夥伴可以看一下之前這篇:
進階實現智慧型別推導的簡化版Vuex

import { useReduxContext } from './Context';
import { Dispatch, Action } from 'redux';

export function useDispatch<A extends Action>() {
  const { store } = useReduxContext();
  return store.dispatch as Dispatch<A>;
}
複製程式碼

實現useSelector

這裡才是重點,這個api有兩個引數。

  1. selector: 定義如何從state中取值,如state => state.count
  2. equalityFn: 定義如何判斷渲染之間值是否有改變。

在效能章節也提到過,大型應用中必須做到只有自己使用的狀態改變了,才去重新渲染,那麼equalityFn就是判斷是否渲染的關鍵了。

關鍵流程(初始化):

  1. 根據傳入的selector從redux的store中取值。
  2. 定義一個latestSelectedState儲存上一次selector返回的值。
  3. 定義一個checkForceUpdate方法用來控制當狀態發生改變的時候,讓當前元件的強制渲染。
  4. 利用store.subscribe訂閱一次redux的store,下次redux的store發生變化執行checkForceUpdate

關鍵流程(更新)

  1. 當使用者使用dispatch觸發了redux store的變動後,store會觸發checkForceUpdate方法。
  2. checkForceUpdate中,從latestSelectedState拿到上一次selector的返回值,再利用selector(store)拿到最新的值,兩者利用equalityFn進行比較。
  3. 根據比較,判斷是否需要強制渲染元件。

有了這個思路,就來實現程式碼吧:

import { useReducer, useRef, useEffect } from 'react';
import { useReduxContext } from './Context';

type Selector<State, Selected> = (state: State) => Selected;
type EqualityFn<Selected> = (a: Selected, b: Selected) => boolean;

// 預設比較的方法
const defaultEqualityFn = <T>(a: T, b: T) => a === b;
export function useSelector<State, Selected>(
  selector: Selector<State, Selected>,
  equalityFn: EqualityFn<Selected> = defaultEqualityFn,
) {
  const { store } = useReduxContext();
  // 強制讓當前元件渲染的方法。
  const [, forceRender] = useReducer(s => s + 1, 0);

  // 儲存上一次selector的返回值。
  const latestSelectedState = useRef<Selected>();
  // 根據使用者傳入的selector,從store中拿到使用者想要的值。
  const selectedState = selector(store.getState());

  // 檢查是否需要強制更新
  function checkForUpdates() {
    // 從store中拿到最新的值
    const newSelectedState = selector(store.getState());

    // 如果比較相等,就啥也不做
    if (equalityFn(newSelectedState, latestSelectedState.current)) {
      return;
    }
    // 否則更新ref中儲存的上一次渲染的值
    // 然後強制渲染
    latestSelectedState.current = newSelectedState;
    forceRender();
  }
  
  // 元件第一次渲染後 執行訂閱store的邏輯
  useEffect(() => {
  
    // ?重點,去訂閱redux store的變化
    // 在使用者呼叫了dispatch後,執行checkForUpdates
    const unsubscribe = store.subscribe(checkForUpdates);
    
    // 元件被銷燬後 需要呼叫unsubscribe停止訂閱
    return unsubscribe;
  }, []);
  
  return selectedState;
}

複製程式碼

總結

本文涉及到的原始碼地址:
github.com/sl1673495/t…

原版的react-redux的實現肯定比這裡的簡化版要複雜的多,它要考慮class元件的使用,以及更多的優化以及邊界情況。

從簡化版的實現入手,我們可以更清晰的得到整個流程脈絡,如果你想進一步的學習原始碼,也可以考慮多花點時間去看官方原始碼並且單步除錯。

相關文章