写作原由
目前网上有一种观点, 即可以使用 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 };
}
复制代码