通过 react-query + localforage 对列表数据进行缓存的最佳实践

947 阅读7分钟

一、前言

在开发中后台管理系统时,我们经常遇到以下问题:当列表数据量很大,或者后端接口响应速度较慢时,每次重新请求数据都会导致页面加载缓慢,影响用户体验。那么,如何提高加载速度呢?能不能第二次访问同一页面时或者刷新页面后重新加载时速度提升一下呢?

有人可能会建议使用 React 的性能优化钩子函数,如 useCallbackuseMemo。这些确实可以避免不必要的重新渲染,提升性能,但它们的局限在于无法持久化数据。一旦页面刷新,存储在内存中的数据就会丢失。

有没有办法让数据在页面刷新后也能保持呢?在这篇文章中,我将介绍如何通过 react-querylocalforage 结合实现高效的数据缓存方案。欢迎大家讨论一下,有没有其他方法?

二、技术栈选择

  • react-query:用于管理服务器端数据的请求、缓存、同步与更新。
  • localforage:一个用于本地存储的库,支持 IndexedDB、WebSQL 和 LocalStorage,可以高效存储大数据量。
  • lodash:常用的 JavaScript 实用工具库,这里我们使用它的 isEmpty 方法来检查数据是否为空。

三、设计思路

我们的目标是让应用优先从本地缓存中获取列表数据,第一次加载会向服务端发起请求,并将新数据同步到本地缓存中。后续再次加载时。会先使用缓存数据,此时loading为false,这样做的好处是显著提高数据的读取速度。

注意!!!我们并没有将接口阻止,因此后面再次请求时,还是会继续请求接口数据并且及时更新,只不过是先使用缓存,然后在幕后进行数据加载。

如果将接口阻止了,那么在进行一下新增、删除操作时,可能会导致数据没有更新,不过也不用担心,可以在这些操作中调用refetch函数,重新进行加载。

四、实现步骤

1. 配置 localforage 实例

import localforage from "localforage";

export const localStore = localforage.createInstance({
  name: "web-xxxDB",
});

localforage.createInstance 用于创建一个本地存储实例,指定存储名称为 "web-xxxDB",方便我们对数据进行归类管理。

2. 创建 useLocalforage Hook

useLocalforage 是我们封装的 Hook,用于从 localforage 中获取或设置数据:

const useLocalforage = (localKey) => {
  const [localData, setLocalData] = useState();
  const [localLoading, setLocalLoading] = useState(false);
  const queryClient = useQueryClient();

// 从 localforage 中获取数据
  const get = useCallback(async () => {
    try {
      const data = await localStore.getItem(localKey);
      return data;
    } catch (error) {
      console.error("useLocalforage get error:", error);
      return null;
    }
  }, [localKey]);

  const set = useCallback(
    async (data) => {
      try {
        const value = await localStore.setItem(localKey, data);
        return value;
      } catch (error) {
        console.error("useLocalforage set error:", { error, localKey, data });
        return null;
      }
    },
    [localKey]
  );

  const initLocalData = useCallback(async () => {
    setLocalLoading(true);
    const data = await get();
    if (data) {
      setLocalData(data);
      queryClient.setQueryData(localKey, data);
    }
    setLocalLoading(false);
  }, [get, queryClient, localKey]);

  useEffect(() => {
    initLocalData();
  }, [localKey, initLocalData]);

  return {
    localData,
    localLoading,
    getLocalData: get,
    setLocalData: set,
    initLocalData,
  };
};

这里,useLocalforage 通过 localKey 获取对应的本地存储数据,同时可以在没有本地数据的情况下从服务器获取数据后,更新本地缓存。

3. 实现 useLocalQuery Hook

useLocalQueryreact-querylocalforage 结合在一起,实现本地优先的查询逻辑:

// 自定义 Hook: 集成 react-query 和 localforage,用于缓存和持久化数据
const useLocalQuery = ({
  enabled = true, // 控制查询是否启用
  initialData, // 初始数据
  queryKey, // 查询的 key,用于区分不同的缓存
  queryFn, // 查询函数,实际请求数据的函数
  onSuccess = () => {}, // 查询成功时的回调
  onError = () => {}, // 查询失败时的回调
  keepPreviousData = false // 是否保留上一次的数据
}) => {

  // 如果 queryKey 是数组,将其转化为字符串形式
  if (Array.isArray(queryKey)) {
    queryKey = queryKey.join("-")
  }

  const queryClient = useQueryClient(); // 获取 react-query 的 queryClient 实例

  // 使用 useLocalforage Hook 获取 localforage 的数据和方法
  const { localData, localLoading, getLocalData, setLocalData, initLocalData } =
    useLocalforage(queryKey);

  // 使用 useQuery 执行查询,包含缓存和状态管理
  const {
    data: queryData,
    isLoading: queryLoading, // 查询加载状态
    isFetching: queryFetching, // 查询是否正在获取
    refetch, // 手动重新获取数据
    isFetched, // 是否已完成首次获取
    isPreviousData, // 是否为上一次的数据
    isRefetching, // 是否正在重新获取数据
  } = useQuery({
    enabled, // 控制是否执行查询
    initialData, // 初始化时的数据
    queryKey, // 查询的 key,用于缓存
    queryFn, // 执行查询的函数
    onSuccess: (data) => {
      setLocalData(data); // 成功后将数据保存到 localforage
      onSuccess(data); // 调用外部传入的成功回调
    },
    onError: (error) => {
      onError(error); // 调用外部传入的错误回调
    },
    keepPreviousData, // 控制是否保留上一次的数据
  });

  // 使用 useMemo 优化 data 的计算
  const data = useMemo(() => {
    if (!enabled) return initialData; // 如果未启用查询,返回初始数据
    return queryData; // 否则返回查询的数据
  }, [enabled, initialData, queryData]);

  // 定义 setData 函数,用于更新缓存和 localforage 的数据
  const setData = useCallback(
    async (newDataOrUpdateFun) => {
      await queryClient.setQueryData(queryKey, newDataOrUpdateFun); // 更新 react-query 的缓存
      if (typeof newDataOrUpdateFun === "function") {
        await setLocalData(newDataOrUpdateFun(data)); // 如果传入的是函数,更新 localforage 的数据
      } else {
        await setLocalData(newDataOrUpdateFun); // 否则直接设置新的数据
      }
    },
    [queryClient, queryKey, data, setLocalData]
  );

  // 计算当前的加载状态
  const loading = useMemo(() => {
    return localLoading || queryLoading; // 本地数据加载或查询加载都视为加载状态
  }, [localLoading, queryLoading]);

  // 如果正在获取数据且数据为空,视为加载中
  const isLoading = queryFetching && isEmpty(data);

  // 返回数据、状态和相关操作方法
  return {
    data,
    setData,
    loading,
    refetch,
    isFetched,
    fetching: queryFetching,
    isPreviousData,
    isRefetching,
    isLoading, // 是否处于加载状态
  };
};

export default useLocalQuery; 

通过 useLocalQuery,我们首先尝试从本地缓存获取数据,只有在缓存不存在的情况下才发起请求,并将请求结果缓存到本地。这样,应用中的数据请求变得更加智能与高效。

五、使用示例

使用方法

安装依赖

npm install localforage react-query lodash

引入模块

import useLocalQuery from './useLocalQuery';

Hook 参数

useLocalQuery 接受一个配置对象,支持以下属性:

参数类型默认值描述
queryKey`stringArray`必填
queryFnfunction必填数据查询函数,返回 Promise。
enabledbooleantrue是否启用该查询。
initialDataanyundefined查询的初始数据。
onSuccessfunction() => {}查询成功时的回调,接收查询结果作为参数。
onErrorfunction() => {}查询失败时的回调,接收错误对象作为参数。
keepPreviousDatabooleanfalse是否保留前一次的查询数据,直到新数据加载完成。

Hook 返回值

useLocalQuery 返回一个对象,包含以下属性:

属性类型描述
dataany查询返回的数据,优先使用本地缓存。
setDatafunction用于手动更新数据。支持直接赋值或通过回调函数计算新值。
loadingboolean表示本地缓存或远程数据是否处于加载状态。
isLoadingboolean仅在远程数据加载且本地缓存为空时返回 true
refetchfunction重新触发查询。
isFetchedboolean指示是否已完成至少一次查询。
fetchingboolean表示是否正在获取远程数据。
isPreviousDataboolean如果启用了 keepPreviousData,在数据更新前为 true
isRefetchingboolean表示是否正在重新触发查询。

以下是如何在组件中使用 useLocalQuery 的示例:

import axios from "axios";
import useLocalQuery from "./useLocalQuery"; // 引入你的自定义 hook

const fetchListData = async () => {
  const response = await axios.get("/api/list-data");
  return response.data; // 返回数据
};

const ListComponent = () => {
  const { data, loading, refetch } = useLocalQuery({
    queryKey: "list-data",
    queryFn: fetchListData,
    onSuccess: ({ pickedPolicy }) => {
      setCurPickedPolicy(pickedPolicy); //setData
    },
  }),
    onError: (error) => {
      showToastByError({ code: error.code }) //自定义错误
    }
  });
  
 const pickedData= useMemo(() => {
    return data?.pickedPolicy || {};
  }, [data]);

  if (loading) return <p>Loading...</p>;

  return (
    <div>
      <ul>
        {pickedData.map((item) => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
      <button onClick={refetch}>Refresh Data</button>
    </div>
  );
};

image.png

六、完整代码

import { useState, useEffect, useMemo, useCallback } from "react";
import localforage from "localforage";
import { useQuery, useQueryClient } from "react-query";
import { isEmpty } from "lodash";

export const localStore = localforage.createInstance({
  name: "web-xxx",
});

/** util */

const useLocalforage = (localKey) => {
  const [localData, setLocalData] = useState();
  const [localLoading, setLocalLoading] = useState(false);
  const queryClient = useQueryClient();
  const get = useCallback(async () => {
    try {
      const data = await localStore.getItem(localKey);
      return data;
    } catch (error) {
      console.log("useLocalforage get error:", error);
      return null;
    }
  }, [localKey]);

  const set = useCallback(
    async (data) => {
      try {
        const value = await localStore.setItem(localKey, data);
        return value;
      } catch (error) {
        console.log("useLocalforage set error:", { error, localKey, data });
        return null;
      }
    },
    [localKey]
  );
  const initLocalData = useCallback(async () => {
    setLocalLoading(true);
    const data = await get();
    if (data) {
      setLocalData(data);
      queryClient.setQueryData(localKey, data);
    }
    setLocalLoading(false);
  }, [get]);

  useEffect(() => {
    initLocalData();
  }, [localKey]);

  return {
    localData,
    localLoading,
    getLocalData: get,
    setLocalData: set,
    initLocalData,
  };
};

const useLocalQuery = ({
  enabled = true,
  initialData,
  queryKey,
  queryFn,
  onSuccess = () => {},
  onError = () => {},
  keepPreviousData = false
}) => {

  if(Array.isArray(queryKey)) {
    queryKey = queryKey.join("-")
  }

  const queryClient = useQueryClient();

  const { localData, localLoading, getLocalData, setLocalData, initLocalData } =
    useLocalforage(queryKey);

  const {
    data: queryData,
    isLoading: queryLoading,
    isFetching: queryFetching,
    refetch,
    isFetched,
    isPreviousData,
    isRefetching,
  } = useQuery({
    enabled,
    initialData,
    queryKey,
    queryFn,
    onSuccess: (data) => {
      setLocalData(data);
      onSuccess(data);
    },
    onError: (error) => {
      onError(error);
    },
    keepPreviousData
  });


  const data = useMemo(() => {
    if (!enabled) return initialData;
    return queryData;
  }, [enabled, initialData, queryData]);

  const setData = useCallback(
    async (newDataOrUpdateFun) => {
      await queryClient.setQueryData(queryKey, newDataOrUpdateFun);
      if (typeof newDataOrUpdateFun === "function") {
        await setLocalData(newDataOrUpdateFun(data));
      } else {
        await setLocalData(newDataOrUpdateFun);
      }
    },
    [queryClient, queryKey, data, setLocalData]
  );

  const loading = useMemo(() => {
    return localLoading || queryLoading ;
  }, [localLoading, queryLoading]);

  const isLoading = queryFetching && isEmpty(data)

  return {
    data,
    setData,
    loading,
    refetch,
    isFetched,
    fetching: queryFetching,
    isPreviousData,
    isRefetching,

    // new
    isLoading
  };
};

export default useLocalQuery;