使用React Hooks代替Redux进行状态管理和进行异步请求, 基于Typescript

·  阅读 1843

写作原由

目前网上有一种观点, 即可以使用 React Hooks 完全取代 Redux 进行状态管理。个人认为对于一些比较大的前端项目, 会有相当一部分状态需要在各个模块中进行数据共享和操作共享。由于 Redux 的 dispatch action 过程是通过匹配 type 字符串的方式进行相关方法的调用,可以避免 import 方法和类型,因此对于此种情况还是有一定优势的。但是对于那些状态的作用范围不大的数据, 或者那些不算太大的项目, 尤其是在使用 Typescript 时, 使用 Hooks 代替 Redux, 无论是从整体代码量, 还是在需求变化后处理业务逻辑的难易程度, Hooks 方案还是有一定优势的。

鉴于网上的大部分文章对此使用方法的论述仅局限于使用 Hooks 处理状态, 并不涉及异步请求的内容, 本文将根据笔者对相关博客的研究和自己在开发中的实践经验, 给出一种使用React Hooks代替Redux进行状态管理和进行异步请求的实现方案。

PS: 文章中的代码可参考GitHub代码仓库

技术路线

笔者的技术路线为使用React Context提供上下文环境, 配合 useReducer 进行状态管理, 对于异步请求和获取状态的代码封装成自定义hooks暴露给视图组件使用. 视图组件仅仅是通过使用自定义hooks暴露出来的state和方法来获取和改变状态. 自定义Hooks的定义参考了状态管理工具 Easy Peasy.

创建一个data.d.ts文件用于定义用到的接口

export interface IListItem {
  id: number;
  name: string;
};

export interface IStoreState {
  byId: { [key in number]?: IListItem };
  allIds: number[];
}
复制代码

PS: 使用byId和allIds的原因可以参考Redux官方文档

创建一个Store.tsx文件, 用于写业务逻辑

首先需要定义context用于创建一个状态管理上下文

const initialState: IStoreState = { byId: {}, allIds: [] };
const StoreContext = createContext<{ state: IStoreState; dispatch?: Dispatch<Action> }>({
  state: initialState,
});
复制代码

其次需要定义reducer函数,用于在useReducer中控制状态改变

type Action = 
| { type: 'saveState', payload: IStoreState }
| { type: 'addItem', payload: IListItem }
| { type: 'removeItem', payload: Pick<IListItem, 'id'> };

const reducer = (state: IStoreState, action: Action) => {
  const { byId, allIds } = state;
  switch (action.type) {
    case 'saveState':
      return action.payload;
    case 'addItem':
      byId[action.payload.id] = action.payload;
      return { byId, allIds: [action.payload.id, ...allIds] };
    case 'removeItem':
      return { ...state, allIds: allIds.filter(i => i !== action.payload.id) };
    default:
      return state;
  }
};
复制代码

此处定义了三个action, 分别定义了保存初始状态, 增加一个Item, 移除一个Item.

然后定义一个React组件:Store组件,用于在父组件中定义状态管理上下文。此为默认导出的函数组件。

const Store: React.FC = ({ children }) => {
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <StoreContext.Provider value={{ state, dispatch }}>{children}</StoreContext.Provider>
  );
};
复制代码

在父组件中的使用方式如下

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

ReactDOM.render(
  <React.StrictMode>
    <Store>
      <App />
    </Store>
  </React.StrictMode>,
  document.getElementById('root')
);
复制代码

之后需要在子组件中使用这个上下文, 用户当然可以选择在子组件中直接使用useContext获取到上下文并使用, 但这样会将业务代码和视图组件混在一起,不利于后期的维护和需求迭代. 因此最好的办法是将业务代码也封装在Store中, 对外仅暴露视图需要的数据和方法即可.

首先是state,这里注意由于数据层是使用byId和allIds管理状态,而视图组件需要的是对象数组, 因此可以在自定义Hooks useStoreState中将这些逻辑都封装好, 对外直接暴露list

export const useStoreState = () => {
  const { state } = useContext(StoreContext);

  const list = useMemo(() => {
    const { byId, allIds } = state;
    return allIds.map(id => byId[id]).filter(i => i) as NonNullable<IListItem[]>;
  }, [state]);

  return { list };
}
复制代码

其次是视图组件使用的方法,使用useStoreActions封装好业务逻辑后,仅仅暴露方法给视图组件即可,后者无需知道其实现细节

export const useStoreActions = () => {
  const { dispatch } = useContext(StoreContext);

  if (dispatch === undefined) {
    throw new Error('error information');
  }

  const onAdd = useCallback(async (item: IListItem) => {
    // send async request;
    dispatch({ type: 'addItem', payload: item });
  }, [dispatch]);

  const onRemove = useCallback(async (id: number) => {
    // send async request
    dispatch({ type: 'removeItem', payload: { id } });
  }, [dispatch]);

  return { onAdd, onRemove };
};
复制代码

对外仅暴露 useStoreState 和 useStoreActions 作为获取Store状态和改变状态函数的唯一出口。

在视图组件中的使用方式如下

function App() {
  const countRef = useRef(200);

  const { list } = useStoreState();
  const { onAdd, onRemove } = useStoreActions();

  const handleAdd = () => {
    const id = countRef.current++;
    onAdd({ id, name: `item ${id}` });
  };

  return (
    <div className="App">
      <div className="button-block">
        <button onClick={handleAdd}>Add</button>
      </div>
      <div>
        <ul className="list">
          {list.map(item => (
            <li key={item.id}>
              <span>{item.id}</span>
              <span>{item.name}</span>
              <button onClick={() => onRemove(item.id)}>remove</button>
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
}
复制代码

当需要请求初始数据时

当需要请求初始数据,可在Store的组件内部使用useEffect实现

const Store: React.FC = ({ children }) => {
  const [state, dispatch] = useReducer(reducer, initialState);
  const [loading, setLoading] = useState(false);
  
  useEffect(() => {
    let didCancel = false;
    (async () => {
      setLoading(true);
      const res = await queryList();
      if (res && !didCancel) {
        dispatch({ type: 'saveState', payload: res });
        setLoading(false);
      }
    })();
    return () => {
      didCancel = true;
    };
  }, []);

  return (
    <StoreContext.Provider value={{ state, loading, dispatch }}>{children}</StoreContext.Provider>
  );
};
复制代码

PS: 在组件中发请求的方式可以参考How to fetch data with React Hooks?

loading状态可以实现请求后端数据时页面出现spinning状态,同样通过useStoreState暴露给视图组件使用

export const useStoreState = () => {
  const { state, loading } = useContext(StoreContext);

  const list = useMemo(() => {
    const { byId, allIds } = state;
    return allIds.map(id => byId[id]).filter(i => i) as NonNullable<IListItem[]>;
  }, [state]);

  return { list, loading };
}
复制代码

参考资料:

分类:
前端
标签:
分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改