使用 Hook 实现一个 Redux

1,573 阅读7分钟

使用 React 的时候,比较常见的状态管理工具就是 Redux,虽然 Redux 写的代码特别多,但是不可否认的是 Redux 的设计原则中的数据不可变这条原则,其实完美的契合了 React。因而在 Redux 中广泛使用,再加上丰富的中间件,比如 redux-thunkredux-sagaDva 等,Redux 的热度一直很高,在 Hook 时代,也有 useSelector useDispatch 这些很有用的 Hook,大大提高开发效率。不过 Hook 时代,也可以通过 useReducer Context API 实现类似 Redux,这样就不用导入 Redux

React.createContext

在一个典型的 React 应用中,数据是通过 props 属性自上而下(由父及子)进行传递的,但这种做法对于某些类型的属性而言是极其繁琐的(例如:地区偏好,UI 主题),这些属性是应用程序中许多组件都需要的。Context 提供了一种在组件之间共享此类值的方式,而不必显式地通过组件树的逐层传递 props。

官方文档,官网文档如是说,其实就是一个父组件的状态,全部子组件都能获取,并且不需要通过属性一层一层传递

React.useReducer

useState 的替代方案。它接收一个形如 (state, action) => newState 的 reducer,并返回当前的 state 以及与其配套的 dispatch 方法。

useReduceruseReducer 的用法和 Redux 的用法非常相似,需要传入一个 reducer 和初始的 state,返回一个 state 和一个类似 Redux dispatch 的操作函数,同时也符合 Redux 的设计理念,数据只读,通过纯函数修改数据。

Context 和 useReducer 结合

可以通过将 useReducer 返回的 statedispatch 作为 Context.Providervalue,这样被 Provider 包裹的子组件就都能获得 state 和操作 state

// store.js
import { createContext, useReducer } from 'react';

const initState = {
  count: 0,
  text: '',
};

const reducer = (state, action) => {
  const { type, payload } = action;
  switch (type) {
    case 'add':
      return { ...state, count: state.count + 1 };
    case 'minus':
      return { ...state, count: state.count - 1 };
    case 'change-text':
      return { ...state, ...payload };
    default:
      return state;
  }
};

export const Store = createContext(null);

const Provider = (props) => {
  const [state, dispatch] = useReducer(reducer, initState);
  return (
    <Store.Provider value={{ state, dispatch }}>
      {props.children}
    </Store.Provider>
  );
};

export default Provider;

store.js 里面初始化了一个 Store,初始化的时候默认可以不传值,可以通过 Store.Provider 传值,通过 useReducer 初始化 statedispatch,这里可以看到 useReducer 的入参和 Redux 一样,都是默认的 statereducer

如何使用,只要把这个组件包裹在你需要这些状态的地方就好了,如果是全局的状态,直接包裹在最外面的组件就好

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import Provider from './store';

ReactDOM.render(
  <Provider>
    <App />
  </Provider>,
  document.getElementById('root')
);

这样包裹的话,整个 React 应用都能使用 statedispatch

子组件如何使用

上面提到怎么创建一个 Provider 和怎么注入到组件中,组件怎么使用这个状态依旧没有提到,实现方法主要有两个。

  • 可以借用 useContext
  • 可以参考一下 React-Redux 的做法,用高阶组件 connect 包裹获需要获取状态的组件

useContext

接收一个 context 对象(React.createContext 的返回值)并返回该 context 的当前值。当前的 context 值由上层组件中距离当前组件最近的 <MyContext.Provider> 的 value prop 决定。

当组件上层最近的 <MyContext.Provider> 更新时,该 Hook 会触发重渲染,并使用最新传递给 MyContext provider 的 context value 值。即使祖先使用 React.memo 或 shouldComponentUpdate,也会在组件本身使用 useContext 时重新渲染。

const value = useContext(MyContext);

官方文档useContext 可以订阅 Context 对象,当 Context 对象的 Providervalue 发生改变的时候,useContext 会获取最新的最新的 value

假设应用根组件就是 App.jsCountTxt 分别依赖 state.textstate.count

// app.js
import Count from './count';
import Txt from './txt';

function App() {
  return (
    <div>
      <Count />
      <Txt />
    </div>
  );
}

export default App;
// count.js
import { useContext } from 'react';
import { Store } from './store';
const Count = () => {
  const { dispatch, state } = useContext(Store);
  const add = () => {
    dispatch({ type: 'add' });
  };
  const minus = () => {
    dispatch({ type: 'minus' });
  };
  return (
    <div>
      <div className="count">{state.count}</div>
      <button onClick={add}>Add</button>
      <button onClick={minus}>Minus</button>
    </div>
  );
};

export default Count;
import { useContext } from 'react';
import { Store } from './store';

const Txt = () => {
  const { dispatch, state } = useContext(Store);
  const changeText = () => {
    const text = Math.random().toString();
    dispatch({ type: 'change-text', payload: { text } });
  };
  return (
    <div>
      <div className="txt">Text: {state.text}</div>
      <button onClick={changeText}>Change Text</button>
    </div>
  );
};

export default Txt;

按照上面的写法,CountTxt 都能获得到 state 并且还能操作 state 了,但是这样有一个问题,仔细看官网文档,只要 value 发生改变了就会重新渲染,这里明显有问题的,Count 只依赖 state.countTxt 只依赖 state.text,但是上面那个写法,摆明就就是 state 改变了两个组件就会重新渲染,解决方案也挺多的

  • 最简单的避免方式,在 App 中取这个变量,然后分发到不同的组件中去,例如
import Count from './count';
import Txt from './txt';
import { Store } from './store';

function App() {
  const { dispatch, state } = useContext(Store);
  return (
    <div>
      <Count count={state.count} dispatch={dispatch} />
      <Txt text={state.text} dispatch={dispatch} />
    </div>
  );
}

export default App;
  • 可以借助 useMemo

useMemo useCallback

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

官方链接useMemo 是一个记忆化的值,当它的依赖改变的时候,它才会重新执行函数,这个和 useCallback 差不多,useCallback 是返回一个记忆化的函数,当依赖发生改变的时候,才会重新生成一个新的函数

这个看起来和上面的内容没什么关联,但是仔细想一下,组件每次执行也是返回一个 jsx 组件,如果只要必要依赖的值发生改变的时候,才返回新的组件,不然就返回一个记忆化的组件
配合 useMemo, Txt 组件可以修改成这样,dispatch 是一定不会改变的,所以 useCallback 也可以把依赖写成空数组,如果不用 useCallback ,每次 state 改变的时候,changeText 就重新定义了,这样还是会导致 useMemo 重新执行,或者可以不把 changeText 加入 useMemo 依赖列表,在这个组件是可以的,最好还是建议写上去,Count 组件也是同样的修改

const Txt = () => {
  const { dispatch, state } = useContext(Store);
  const changeText = useCallback(() => {
    const text = Math.random().toString();
    dispatch({ type: 'change-text', payload: { text } });
  }, [dispatch]);
  return useMemo(
    () => (
      <div>
        <div className="txt">Text: {state.text}</div>
        <button onClick={changeText}>Change Text</button>
      </div>
    ),
    [state.text, changeText]
  );
};

实现一个 connect

使用 useContext 有个缺点,就是使用到 Store 的地方,必须把源 Store 导进来,写多了也很繁琐。解决的方式也是有的

  • 可以参考 react reduxconnect 高阶组件,实现一个 connect 如下
import { useContext, useMemo } from 'react';
import { Store } from './store';

const connect = (fn) => {
  if (typeof fn !== 'function') {
    throw new Error('first param must be function');
  }
  return (WrappedComponent) => (props) => {
    const { dispatch, state } = useContext(Store);
    const value = fn(state);
    return useMemo(
      () => <WrappedComponent {...props} {...value} dispatch={dispatch} />,
      // 注意这里
      [JSON.stringify(value), dispatch, props]
    );
  };
};

export default connect;

仔细看下,这里就是一个很简单的高阶组件,没有实现 connectequalFn 第二个参数,第一个参数和 connect 一样,都是必须返回一个对象.因为是返回一个对象,所以,每次执行函数的返回也是不一样的,value 的值只要 state 做了改变就会改变,就像刚刚的 TextCount 组件一样,修改了一个和组件内部无任何关联的属性,组件也重新渲染了,这样不符合要求。这里使用一个简单的 JSON.stringify 处理。Redux connect 的处理方式是实现一个 shallowEqual,实现起来也很简单

  • 使用 connect
import connect from './connect';

const Count = ({ num, dispatch, count }) => {
  const add = () => {
    dispatch({ type: 'add' });
  };
  const minus = () => {
    dispatch({ type: 'minus' });
  };
  console.log('wrapper reload');
  return (
    <div>
      <div className="count">count from context {count}</div>
      <div className="num">num from props {num}</div>
      <button onClick={add}>Add</button>
      <button onClick={minus}>Minus</button>
    </div>
  );
};

export default connect((state) => ({
  count: state.count,
}))(Count);

这样能解决问题,但是 Hook 时代也有 Hook 的解法,可以参考 react-reduxuseDispatch useSelector ,但是笔者太菜,折腾了半天依旧没有写出像 react-redux useSelector 那让无关属性不会导致组件重新渲染的,所以使用的时候还得搭配 useMemo

useSelector useDispatch useMemo

// useDispatch useReducer 生成的 dispatch 是绝对不会改变的,所以直接返回就好
const useDispatch = () => {
  const { dispatch } = useContext(Store);
  return dispatch;
};

// 折腾了半天,也没有找到解决方法,干脆放飞自我了
const useSelector = (fn) => {
  if (typeof fn !== 'function') {
    throw new Error('first param must be function');
  }
  const { state } = useContext(Store);
  const value = fn(state);
  return value;
};

// 使用的时候要调配 useMemo,这样也方便一些,不用每个组件都 useContext 一下
const Count = () => {
  const count = useSelector((state) => state.count);
  const dispatch = useDispatch();
  const add = useCallback(() => {
    dispatch({ type: 'add' });
  }, [dispatch]);
  const minus = useCallback(() => {
    dispatch({ type: 'minus' });
  }, [dispatch]);

  return useMemo(() => {
    console.log(count);
    return (
      <div>
        <div className="count">{count}</div>
        <button onClick={add}>Add</button>
        <button onClick={minus}>Minus</button>
      </div>
    );
  }, [add, count, minus]);
};

总结

Hook 出来之后,React 的可玩性就更高了,一个简单的状态管理,还能玩出花来。希望这篇文章给读者带来收获,还有,新年快乐!