zustand是最近比较出圈的一款小而美的状态管理库,基于发布-订阅的模式,只需通过一个create函数创建一个store来存储state和action,然后在组件内就可以引入这个store hook来获取状态,而不需要像redux一样用connect将状态和组件联系起来,也不需要将组件用context provider包裹,大大降低了复杂性,而且zustand还导出了一些中间件供使用(比如持久化函数persist),对于一般的web项目完全够用。
基本用法(React)
安装
yarn add zustand
创建一个Store
store 是一个 hook,你可以在里面放任何东西:基本类型值、对象、函数。而set函数会合并状态。
import create from "zustand";
const useStore = create((set) => ({
bears: 0,
increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 }),
}));
然后绑定组件
可以在任意一个组件内引入这个store hook,组件会在你选择的状态变化时重新渲染。
function BearCounter() {
const bears = useStore((state) => state.bears); // 获取state
return <h1>{bears} around here ...</h1>;
}
function Controls() {
const increasePopulation = useStore((state) => state.increasePopulation); // 获取action
return <button onClick={increasePopulation}>one up</button>;
}
就这么简单!!!
当然zustand也支持选择器selector和异步action。
获取所有状态
const state = useStore(); // 这将导致任何一个状态变更都会引起组件重新渲染
获取特定状态
const nuts = useStore((state) => state.nuts);
const honey = useStore((state) => state.honey);
默认是基于严格相等来判断的前后状态是否一致
异步action
const useStore = create((set) => ({
fishies: {},
fetch: async (pond) => {
const response = await fetch(pond);
set({ fishies: await response.json() });
},
}));
在action里读取状态
set允许函数式更新:set(state => result),但你仍然可以通过get访问状态。
const useStore = create((set, get) => ({
sound: "grunt",
action: () => {
const sound = get().sound
// ...
}
})
如何在zustand中管理请求数据
既然zustand的action支持异步,那自然想到可以用异步action来请求后端数据,再通过set方法改变state。
// store
import {create} from 'zustand';
import axios from 'axios';
export const useStore = create((set, get) => ({
products: [],
getProducts: async (key) => {
console.log('==========request=========')
const {data} = await axios.get(key);
set(({products: data.products}));
}
}));
然后在组件内使用
import { Suspense, useEffect } from "react";
import { Spin } from "antd";
import { useStore } from "@/store";
import { API_PRODUCTS } from "@/APIs";
const Products = () => {
return (
<div>
<Suspense fallback={<Spin spinning size="large"/>}>
<ProductList />
</Suspense>
</div>
);
};
function ProductList() {
const {getProducts, products} = useStore(); // 获取状态
useEffect(() => {
console.log('==========rendering=======')
getProducts(API_PRODUCTS.getProducts); // 调用接口
}, [])
return (
<>
<table>
<thead>
<tr>
<td>
<th>Product Name</th>
</td>
<td>
<th>Price</th>
</td>
<td>
<th>Brand</th>
</td>
<td>
<th>Operation</th>
</td>
</tr>
</thead>
<tbody>
{products.map((product)=> (
<tr key={product.id}>
<td>{product.title}</td>
<td>{"$" + product.price}</td>
<td>{product.brand}</td>
</tr>
))}
</tbody>
</table>
</>
);
}
运行效果如下:
可以在控制台看到,首次渲染的时候会渲染两次,而且还会请求两次接口,重network tab也可以看到
这个原因是因为在React 18的开发模式中,默认会执行两次useEffect内的副作用函数,看似是一个bug,其目的是官方为了提醒开发者不要重复渲染组件,官方文档
想要避免这个‘bug’,有两种方式:
- 移除入口
main文件的StrictMode:import ReactDOM from 'react-dom/client' import App from './App.tsx' import './index.css' ReactDOM.createRoot(document.getElementById('root')!).render( // <StrictMode> <App /> // </StrictMode> ) - 添加一个标识,用来判断组件是否mount,然后在组件unmount时重置这个标识:
这样组件刷新和请求只执行一次了:import { Suspense, useEffect, useRef } from "react"; import { Link } from "react-router-dom"; import { useStore, IProduct } from "@/store"; import { shoppingCartCountSelector } from "./selectors"; import { Spin } from "antd"; import { API_PRODUCTS } from "@/APIs"; const Products = () => { return ( <div> <Suspense fallback={<Spin spinning size="large"/>}> <ProductList /> </Suspense> <div style={{marginTop: '20px'}}> <Link to={"/shopping-cart"}>Go to Shopping Cart</Link> </div> </div> ); }; function ProductList() { const flag = useRef(false); const { count, addProduct } = useStore(shoppingCartCountSelector); const {getProducts, products} = useStore(); // 获取状态 useEffect(() => { if(!flag.current){ getProducts(API_PRODUCTS.getProducts); // 调用接口 console.log('==========rendering=======') } return () => { flag.current = true; console.log('========unmount======') } }, []) return ( <> <table> <thead> <tr> <th>Product Name</th> <th>Price</th> <th>Brand</th> <th>Operation</th> </tr> </thead> <tbody> {products.map((product: IProduct) => ( <tr key={product.id}> <td>{product.title}</td> <td>{"$" + product.price}</td> <td>{product.brand}</td> <td> <button onClick={() => addProduct(product.id)}> Add to Shopping Cart </button> </td> </tr> ))} </tbody> </table> <div style={{ marginTop: "50px" }}> <strong>count: {count}</strong> </div> </> ); } export default Products;
还有一处不是很优雅的地方在于,虽然store里能直接发送异步请求,但是要暴露一个getProducts方法给到组件,而这个暴露出的方法又只能在副作用里执行,这样又回到了最开始学习React的时候,总是喜欢把副作用放在useEffect内执行,这样做当然没问题,不过现在用Hooks了,一切以useHooks作为出发点去思考:
如果把数据请求的方法用Hooks实现呢?
如果在组件首次渲染时就能拿到数据呢?
在Store里引入SWR
SWR应该不用过多介绍了吧,一个用于数据请求的 React Hooks 库。
改造store
import axios from "axios";
import useSWR from 'swr';
import {create} from 'zustand';
export const getFetcher = (url) => {
return axios.get(url).then((res) => res.data);
};
export const useStore = create((set, get) => ({
products: [],
getProducts: (key) => {
console.log('==========request=========');
return useSWR(key, getFetcher, {
suspense: true, // 开启React Suspense
onSuccess: (data) => set(() => ({products: data.products})) // 在回调函数里处理状态变更
})
},
}));
组件里使用
import { Suspense } from "react";
import { Spin } from "antd";
import { API_PRODUCTS } from "@/APIs";
import { useStore } from "@/store";
import { API_PRODUCTS } from "@/APIs";
const Products = () => {
return (
<div>
<Suspense fallback={<Spin spinning size="large"/>}>
<ProductList />
</Suspense>
</div>
);
};
function ProductList() {
const {data} = useStore(s => s.getProducts(API_PRODUCTS.getProducts)); // 传入selector,获取特定的action
console.log('=========render=========')
return (
<>
<table>
<thead>
<tr>
<td>
<th>Product Name</th>
</td>
<td>
<th>Price</th>
</td>
<td>
<th>Brand</th>
</td>
<td>
<th>Operation</th>
</td>
</tr>
</thead>
<tbody>
{data.products.map((product) => (
<tr key={product.id}>
<td>{product.title}</td>
<td>{"$" + product.price}</td>
<td>{product.brand}</td>
</tr>
))}
</tbody>
</table>
</>
);
}
从运行截图可以看到,组件渲染两次,但数据只请求了一次。
总结
基于React Hook的SWR可以在函数组件的顶层就拿到数据,减少了组件副作用的侵入,并且自带的缓存功能减少不必要的请求。