在React里用zustand管理请求接口返回的数据

2,626 阅读3分钟

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>
    </>
  );
}

运行效果如下:

image.png

可以在控制台看到,首次渲染的时候会渲染两次,而且还会请求两次接口,重network tab也可以看到

image.png

这个原因是因为在React 18的开发模式中,默认会执行两次useEffect内的副作用函数,看似是一个bug,其目的是官方为了提醒开发者不要重复渲染组件官方文档

想要避免这个‘bug’,有两种方式:

  1. 移除入口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>
    )
    
  2. 添加一个标识,用来判断组件是否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;
    
    
    这样组件刷新和请求只执行一次了:

image.png

image.png

还有一处不是很优雅的地方在于,虽然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>
    </>
  );
}

image.png

image.png

从运行截图可以看到,组件渲染两次,但数据只请求了一次。

总结

基于React Hook的SWR可以在函数组件的顶层就拿到数据,减少了组件副作用的侵入,并且自带的缓存功能减少不必要的请求。