手把手实现一个 Hooks 版 mini Redux

959 阅读3分钟

我们今天讲述的内容是来自于 redux 和 react-redux 这两个库。

虽然 Redux 并不是一个必须要配合 React 使用的状态管理库,但是它和 React 的结合是最常见的。

我们今天的计划:先不绑定任何框架,把 Redux 基本的 API 实现;然后再基于在 React 上的使用,继续完善。最终实现的结果就是一个可用的状态管理器。

基础实现

在开始之前,我们得先看一个例子,看它怎么在原生 JS 应用下使用。这有助于我们理解 Redux 最原始的 API。毕竟在 React 中使用也是继续在这个层次上封装。

且看下面这段代码,我是从官网拷贝过来的。网络允许的话,可以在线预览

代码有点多,好在逻辑比较简单。大家重点关注 createStore 方法创造出来的 store:

  1. 使用 store.dispatch 去派发一个 action
  2. 可以使用 store.getState() 拿到最新的值
  3. 视图层使用 store.subscribe 订阅更新,等计算出新的值会调用订阅了更新的函数

image.png

import { createStore } from 'redux'

function counter(state, action) {
  if (typeof state === 'undefined') {
    return 0
  }

  switch (action.type) {
    case 'INCREMENT':
      return state + 1
    case 'DECREMENT':
      return state - 1
    default:
      return state
  }
}

const store = createStore(counter)
const valueEl = document.getElementById('value')

function render() {
  valueEl.innerHTML = store.getState().toString()
}

render()
store.subscribe(render)

document.getElementById('increment')
  .addEventListener('click', function () {
    store.dispatch({ type: 'INCREMENT' })
  })

document.getElementById('decrement')
  .addEventListener('click', function () {
    store.dispatch({ type: 'DECREMENT' })
  })

document.getElementById('incrementIfOdd')
  .addEventListener('click', function () {
    if (store.getState() % 2 !== 0) {
      store.dispatch({ type: 'INCREMENT' })
    }
  })

document.getElementById('incrementAsync')
  .addEventListener('click', function () {
    setTimeout(function () {
      store.dispatch({ type: 'INCREMENT' })
    }, 1000)
  })

明白了基本的使用,现在我们就来实现 createStore 方法:

入参是一个 reducer 对象,首先我们先定义入参类型:

export interface Action<T = any> {
    type: T
}

export interface AnyAction extends Action {
    [extraProps: string]: any
}

type Reducer<S = any, A extends Action = AnyAction> = (
    state: S | undefined,
    action: A
) => S

刚才我们也介绍了,createStore 返回的结果是一个 store 对象,来定义一下它的类型:

export interface Store<S = any, A extends Action = AnyAction> {
    dispatch: Dispatch<A>;
    getState: () => S;
    subscribe(listener: () => void): Unsubscribe 
    // 返回值是取消订阅的函数,等会看怎么用,先写着
}

export interface Unsubscribe {
    (): void
}

接下来我们来实现 createStore 内部的逻辑。

我们知道,Redux 采用的是单项数据流,当我们使用 dispatch 函数派发一个 action 的时候,它会把这个 action 入参给 reducer 函数,reducer 函数产生的返回值会作为新的 state。

ReduxDataFlowDiagram-49fa8c3968371d9ef6f2a1486bd40a26.gif

也就是说,dispatch 函数的原理很简单,就是调用 reducer 产生新的 state ,如下:

function dispatch(action) => {
  state = reducer(state, action);
}

那怎么解决数据更新完了通知 UI 的功能呢?我们可以在内部维护一个订阅者数组,初期把需要更新视图的函数加入进来。

当我们新的 state 产生完了,可以通知所有的订阅者,这就达到了更新视图的功能。添加订阅者就是通过 subscribe 函数。当组件注销的时候,我们就不必在通知了,就可以注销掉他们。

下面是整体的代码:

const randomString = () =>
    Math.random().toString(36).substring(7).split('').join('.')

const ActionTypes = {
    INIT: `@@redux/INIT${/* #__PURE__ */ randomString()}`,
}

export default function createStore<S, A extends Action>(reducer: Reducer<S, A>) {
    let state: S;
    let listeners: (() => void)[] = []

    // 初始化派发一下不存在的 action,作用是为了让 state 不为空
    // 这也就是我们必须要在 reducer 写 default 分支的原因。
    dispatch({ type: ActionTypes.INIT } as A)

    function dispatch(action: A) {
        state = reducer(state, action);

        for (let i = 0; i < listeners.length; i++) {
            const listener = listeners[i]
            listener()
        }

        return action;
    }

    function subscribe(listener: () => void): Unsubscribe {
        // 添加订阅者
        listeners.push(listener);

        return () => {
            // 返回的是取消订阅的函数,在销毁组件的时候用
            const index = listeners.indexOf(listener)
            listeners.splice(index, 1)
        }
    }

    return {
        getState() { return state; },
        dispatch,
        subscribe
    }
}

真实的 createStore 依次接受三个参数:reducer、preloadedState、enhancer。

第一个我们刚才已经讲述了。第二个其实是为我们的 state 赋一个初始值,第三个,字面意思,就是增强 store,最常用的就是使用中间件。

举一个例子,我们想每次调用 dispatch 方法的时候都打印一下日志,那可以这么去做:

// 省略前面的代码...
// 这是我们的 createStore 方法
const newDispatch = function log(action) => {
    console.log('日志')
    dispatch(action)
}; 

return {
    getState() { return state; },
    dispatch: newDispatch,
    subscribe
}

硬编码进肯定不合适,为了能让别人自定义的增强我们的 dispatch,可以把入参一个 enhancer,让它来做一些增加 dispatch 的事情:

const newDispatch =  enhancer(dispatch) 

这就是中间件的基本思路。实际实现考虑的细节会多一点,但是无关紧要。

提示:事实上,enhancer 并不是接受一个 dispatch ,它接受的是一组中间件,我这么写只是为了简单的表示它的原理。源代码地址在这里:enhancer 调用位置 。 实际使用中,enhancer 接受的是 applyMiddleware 这个函数的返回值,点我查看

上面就已经把 Redux 基本的原理都表示出来了,上面的那个例子也完全可以正常运行:

image.png

配合 React 使用

接下来,开始探讨如何能在 React 中更好的使用。如果,我们什么也不改,也可以直接在 React 中使用,但是需采用类似下面这种写法:

const store = createStore(counter)
const rootEl = document.getElementById('root')

const render = () => ReactDOM.render(
    <Counter
        value={store.getState()}
        onIncrement={() => store.dispatch({ type: 'INCREMENT' })}
        onDecrement={() => store.dispatch({ type: 'DECREMENT' })}
    />,
    rootEl
)

render()
store.subscribe(render)

有什么问题呢?就在于 Counter 组件的入参 value,每次更新后,它拿到的都是一个新值,这就会使它及其它的子组件重新渲染。

除此之外,目前我们只是把 store 的值挂载到了顶级父组件上,子组件想使用的话,还是得通过 props 获取。当组件层级深了之后,这无疑很麻烦。

如何解决跨级传递难的问题呢?解决方案想必大家也都知道了,使用 context。这样就能在任意层级的子组件里取到值了。

Redux 中的使用是这样子的:

<Provider store={store}>
    <Todos/>
</Provider>

在 Redux 中,Provider 还可以接受自定义 context。为了方便起见,我们这里实现的 Provider 函数就不接受指定自己的 context 了。我们直接在全局直接声明了一个 ReactReduxContext ,后面取值都会在这个 context 下面操作。

请你放心,原理都是一样的。

先根据用法实现一个最基础的版本:

interface ReactReduxContextValue<SS = any,   A extends Action = AnyAction> {
    store: Store<SS, A>
}

// 全局的 context
const ReactReduxContext = React.createContext<ReactReduxContextValue>(null as any);

interface ProviderProps {
    store: Store;
    children: React.ReactNode
}

function Provider({store, children}: ProviderProps) {

    // 组装传递给 context 的值
    const contextValue = {
        store: store
    }

    const Context = ReactReduxContext;

    return (
        <Context.Provider value={contextValue}>
            {children}
        </Context.Provider>
    )
}

export default Provider;

好了,数据我们是已经存好了,但是还有两大问题没有解决:

  1. 子组件怎么取数据
  2. 子组件怎么派发事件

下面我们就来解决这两个问题。解决了这两个问题,我们再去优化性能。

Redux 中是怎么用的呢?我们不采用 connect 高阶函数的方案了,而是采用自定义 Hooks 的方式,写法是这样子的:

export function Counter() {
  // 从 store 中取数据
  const count = useSelector(state => state.counter.value) 
    
  // 派发事件的函数
  const dispatch = useDispatch() 

  return (
      <div>
        <div>
          <button onClick={() => dispatch({ type: 'increment'})}>
            Increment
          </button>
          <span>{count}</span>
          <button onClick={ {type: 'decrement' }}>
            Decrement
          </button>
        </div>
      </div>
  )
}

这段代码并不能直接运行,但是如果使用过 redux 应该就能大概了解它做了什么。

也就是说,我们:

  1. 使用 useDispatch 取到了 store 中的 dispatch 函数
  2. 使用 useSelector 取到了 store 中的 state 的数据

最开始的时候,我们定义了一个全局的 context,现在它就要派上用场了:

function useDispatch() {
    const contextValue = useContext(ReactReduxContext)
    return contextValue.store.dispatch;
}

function useSelector<TState, Selected extends unknown>(
    selector: (state: TState) => Selected
 ): Selected {
    const contextValue = useContext(ReactReduxContext);
    return selector((contextValue.store.getState()))
}

但是仅仅这样还是不行,我们在派发事件后,没法收到更新。有一种最简单的办法,更新我们 Provider 最上层的 store,把它完全更新成新的,这样,所有读取此 context 的组件都会更新。

但这也是 React 中 context 的问题。

我们往往这样使用 context:

<MyContext.Provider value={/* some value */}>
function Button1() {
  const name = useContext(MyContext);
  
  retur <div>{name}</div>
}

function Button2() {
  const name = useContext(MyContext);
  
  retur <div>{name}</div>
}

当这个情况下, context 中 value 发生变化,使用当前上下文的组件都会重新渲染。

放到我们 Redux 中,如果我们贸然更改了 store 的引用,无疑会引起我们所有用到当前 context 的组件树都会重新渲染,这对性能来说是不能接受的,我们不想要这种效果。

那怎么办呢?想一想,我们是不是仅仅把使用了 useSelector 的组件加入订阅者不就好了?只有当 useSelector 上一次取到的值和这一次取到的值有变化我们才更新。

现在,我们需要完善一下 useSelector 这个自定义 hook 函数了。

function usePrevious<T>(value: T | undefined): T | undefined {
    const ref = useRef<T>();
    useEffect(() => {
        ref.current = value;
    }, [value]);
    return ref.current;
}
function useSelector<TState, Selected extends unknown>(selector: (state: TState) => Selected): Selected {
    const contextValue = useContext(ReactReduxContext);
    const subscribe = contextValue.store.subscribe
    const state = contextValue.store.getState();
    
    const nextSelected = selector(state);
    const prevSelected = usePrevious(nextSelected);

    // 我们使用简单的计数器来触发组件的更新
    // redux 源码中引用的是来自于
    // react 一个内置 hook: `useSyncExternalStore`
    // 地址:https://github.com/facebook/react/blob/ceee524a8f45b97c5fa9861aec3f36161495d2e1/packages/react-reconciler/src/ReactFiberHooks.new.js#L2633
    // 原理基本一致。
    
    const [_, setCount] = useState(0);

    useEffect(() => {
        // 增加当前组件到订阅者列表
        const unsubscribe = subscribe(() => { 
            // 前后两次值不一样,走更新
            if (prevSelected !== nextSelected) { 
                forceUpdate();
            }
        })

        function forceUpdate() {
            setCount((prev) => prev+1);
        }

        // 组件注销的时候,取消订阅
        return () => {
            unsubscribe();
        }
        
    }, [subscribe])

    return nextSelected;
}

到这里我们基本就实现完了。实验一下下面这个例子,也能确实只更新变化的组件:

import React from 'react';
import Provider, {useDispatch, useSelector} from './Provider';
import createStore from './createStore';

interface State {
    count: number
}

const store = createStore<State, any>(counter);

function counter(state: any, action: any) {
    if (typeof state === 'undefined') {
        return {
            count: 0
        };
    }

    switch (action.type) {
        case 'INCREMENT':
            return {
                ...state,
                count: state.count + 1
            };
        default:
            return state;
    }
}


function A() {
    return (
        <Provider store={store}>
            <B/>
            <C/>
        </Provider>
    );
}

function B() {
    console.log('b');
    const count = useSelector<State, number>((state) => state.count);
    const dispatch = useDispatch();
    return (
        <>
            <button onClick={() => dispatch({type: 'INCREMENT'})}>
                +
            </button>
            <div>{count}</div>
        </>
    );
}

function C() {
    console.log('c');
    return <div>hello world</div>;
}

别急,到这里还没完,我们还要再完善一下我们的 Provider 组件,有一种情况会导致我们 Provider 组件的 store 值重新计算,那就是它不在根节点:

function App() {

    return (
        <Provider> ... </Provider>
    )
}

如果我在 App 级别做了更新,会触发Provider 的重新渲染,此时 contextValue 重新生成,我们刚才做到就全白做了。我们希望 Provider 不在根目录,也不让 store 重新渲染,怎么做呢?用 useMemo:

function Provider({store, children}: ProviderProps) {
    const contextValue = useMemo(() => {
        return {
            store
        };
    }, [store]);

    const Context = ReactReduxContext;

    return (
        <Context.Provider value={contextValue}>
            {children}
        </Context.Provider>
    );
}

到这里,我们整个 redux 的核心源码就都涉及到了。不知道是否对你有帮助呢。

写这篇的时候,我的脑子有点懵,思路有点不清晰。有些地方看不懂还请留言,看到之后我会回复。

完整代码

// createStore.ts
export interface Action<T = any> {
    type: T
}

export interface AnyAction extends Action {
    [extraProps: string]: any
}

export type Reducer<S = any, A extends Action = AnyAction> = (
    state: S | undefined,
    action: A
) => S

export interface Dispatch<A extends Action = AnyAction> {
    <T extends A>(action: T): T
}

export interface Store<S = any, A extends Action = AnyAction> {
    dispatch: Dispatch<A>;
    getState: () => S;
    subscribe(listener: () => void): Unsubscribe
}

export interface Unsubscribe {
    (): void
}

const randomString = () =>
    Math.random().toString(36).substring(7).split('').join('.')

const ActionTypes = {
    INIT: `@@redux/INIT${/* #__PURE__ */ randomString()}`,
}

export default function createStore<S, A extends Action>(reducer: Reducer<S, A>) {
    let state: S;
    let listeners: (() => void)[] = []

    // 初始化派发一下不存在的 action,作用是为了让 state 不为空
    // 这也就是我们必须要在 reducer 写 default 分支的原因。
    dispatch({ type: ActionTypes.INIT } as A)

    function dispatch(action: A) {
        state = reducer(state, action);

        for (let i = 0; i < listeners.length; i++) {
            const listener = listeners[i]
            listener()
        }

        return action;
    }

    function subscribe(listener: () => void): Unsubscribe {
        listeners.push(listener);

        return () => {
            const index = listeners.indexOf(listener)
            listeners.splice(index, 1)
        }
    }

    return {
        getState() { return state; },
        dispatch,
        subscribe
    }
}
// provider.tsx
import React, {useContext, useEffect, useMemo, useRef, useState} from 'react';
import {Action, AnyAction, Dispatch, Store} from './createStore';

interface ReactReduxContextValue<SS = any, A extends Action = AnyAction> {
    store: Store<SS, A>
}

const ReactReduxContext = React.createContext<ReactReduxContextValue>(null as any);

interface ProviderProps {
    store: Store;
    children: React.ReactNode
}

export function useDispatch() {
    const contextValue = useContext(ReactReduxContext);
    return contextValue.store.dispatch;
}

function usePrevious<T>(value: T | undefined): T | undefined {
    const ref = useRef<T>();
    useEffect(() => {
        ref.current = value;
    }, [value]);
    return ref.current;
}

export function useSelector<TState, Selected extends unknown>(selector: (state: TState) => Selected): Selected {
    const contextValue = useContext(ReactReduxContext);
    const subscribe = contextValue.store.subscribe;
    const state = contextValue.store.getState();
    const nextSelected = selector(state);
    const [_, setCount] = useState(0);
    const prevSelected = usePrevious(nextSelected);

    useEffect(() => {
        const unsubscribe = subscribe(() => {
            if (prevSelected !== nextSelected) {
                forceUpdate();
            }
        });

        function forceUpdate() {
            setCount((prev) => prev + 1);
        }

        return () => {
            unsubscribe();
        };
    }, [subscribe]);

    return nextSelected;
}

function Provider({store, children}: ProviderProps) {
    const contextValue = useMemo(() => {
        return {
            store
        };
    }, [store]);

    const Context = ReactReduxContext;

    return (
        <Context.Provider value={contextValue}>
            {children}
        </Context.Provider>
    );
}

export default Provider;