Further Usage of redux-toolkit in React


redux-toolkit 在 react 中的用法(续)

rtk-query

rtk-query 是进行网络请求的一个高级工具,下面介绍一下使用流程。

首先是新建一个基础 api。

// apis/base.ts

import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";

export const baseApi = createApi({
  // 根据url自动选择http或者https协议
  // 如果是移动端应用,由于模拟器中的网络与本地不一致,即使服务器在本地也不能使用127.0.0.1,必须用局域网地址
  baseQuery: fetchBaseQuery({ baseUrl: "http://192.168.31.228:8000/v1/" }), // 发送请求的目的地址的前半部分
  reducerPath: "baseApi", // 告诉redux-toolkit我们想把从这个api获取的数据放到store的什么位置
  endpoints: () => ({}), // endpoints中放的是各种请求相关的函数,这里暂时为空,后续写具体的Api时再补充。
});

如果你没有现成的服务端程序,可以在 https://pokeapi.co/ 进行测试。

然后是写一个具体的 api,填充 endpoints。

// apis/user.ts

import { baseApi } from "../base";

// 请求返回值的类型
// 这里不需要严格对应,举个例子
// 假设真实返回值的类型比下面还多个time字段,或者说并没有data字段,下面定义的类型也是不会报错的
// 甚至返回的是一个json文件,且定义的返回值类型为string也是可以的,最终可以通过`.`操作符获取其中的字段
interface User {
  data: string;
}

// 在baseApi的基础上创建userApi
const userApi = baseApi.injectEndpoints({
  endpoints: (builder) => ({
    // 根据id去查询
    // query用于检索数据,并可以向缓存提供标签
    // 第一个参数是返回值的类型,第二个参数是传递给后端的数据类型(这里传递的是id,为number类型)
    getUserById: builder.query<User, number>({
      // 这里参数的类型必须和上面定义的一致
      query: (id: number) => ({
        // 请求地址的后半部分
        url: `/user/${id}`,
        // 请求的方法
        method: "get",
      }),
    }),
    // 根据id删除用户
    // 对于改变服务器上的数据或可能会使缓存无效的任何内容应当使用mutation
    deleteUserById: builder.mutation({
      query: (id: number) => ({
        url: `/user/${id}`,
        method: "delete",
      }),
    }),
  }),
  overrideExisting: false,
});

// 导出可在函数式组件使用的hooks,它是基于定义的endpoints自动生成的
// 这个的名称是固定的,就是use加上前面定义的名字再加上query或者mutation
export const { useGetUserByIdQuery, useDeleteUserByIdMutation } = userApi;

然后就是将定义好的 api 与 store 联系起来。

// stores/Welcome.ts

import { configureStore } from "@reduxjs/toolkit";
import loginReducer from "../components/welcome/LoginRedux";
import { baseApi } from "../apis/base";
import { setupListeners } from "@reduxjs/toolkit/dist/query";

// 中间件集合
const middlewareHandler = (getDefaultMiddleware: any) => {
  const middlewareList = [...getDefaultMiddleware()];
  // 把api自动生成的中间件加进去
  middlewareList.push(baseApi.middleware);
  return middlewareList;
};

export const welcomeStore = configureStore({
  reducer: {
    // login是Welcome页面的一个模块
    login: loginReducer,
    // 把api自动生成的reducer加进入
    [baseApi.reducerPath]: baseApi.reducer,
  },
  middleware: (getDefaultMiddleware) => middlewareHandler(getDefaultMiddleware),
});

// 这里主要是监听焦点、网络通断、组件可视性变化和其他一些事件,如果没有这些情况,就不需要监听
setupListeners(welcomeStore.dispatch);

export type WelcomeState = ReturnType<typeof welcomeStore.getState>;
export type WelcomeDispatch = typeof welcomeStore.dispatch;

现在已经全部配置完成,可以使用了。

// components/Login.tsx

import { TextInput, Button, View, Text } from "react-native";
import { useGetUserByIdQuery } from "../../apis/welcome/user";

export function Login() {
  const { data, isLoading, isError } = useGetUserByIdQuery(1);

  if (isLoading) {
    return (
      <View>
        <Text>loading</Text>
      </View>
    );
  }

  if (isError) {
    return (
      <View>
        <Text>Something went wrong</Text>
      </View>
    );
  }

  return (
    <View>
      <Text>The response is {data}</Text>
    </View>
  );
}

如果要进行轮询,那也很简单。前有export const { useGetUserByIdQuery, useDeleteUserByIdMutation } = userApi;。只需在使用时传入轮询间隔即可。比如像下面这样。

const { data, isLoading, isError, refetch } = useGetUserByIdQuery(1, {
  pollingInterval: 3000,
});

由事件触发请求

export function Login() {
  const { data, isLoading, isError } = useGetUserByIdQuery(1);
}

从前面的例子中可以看到,发出请求的这些函数只能在函数式组件内部使用,也就是说,请求函数永远在组件被渲染之前调用,想要直接将其作为事件的处理函数是不可能的。如果需求是当按钮按下时发出才请求,就必须要进行进一步处理。

解决该问题的方案有三种,一种是使用lazy版本的函数,比如useLazyGetUserByIdQuery,一种是使用选择性调用特性,还有一种是使用mutation

第一种可能写的比较多,这里只介绍后两种。下面是代码片段。

import { useState } from "react";
export function Login() {
  const [skip, setSkip] = useState(true);
  // 下面的useVerifyUserQuery就相当于前面的useGetUserByIdQuery,与上面的定义方式没有区别。
  // userState是useVerifyUserQuery定义时的参数,等同于useGetUserByIdQuery的id:number。
  // {skip}是传入的第二个参数,该参数不需要定义。
  // 这些配置使得该函数不会在一开始就执行。
  const { data, error, isLoading, isUninitialized } =
    Hooks.user.useVerifyUserQuery(userState, { skip });

  // 然后定义一个按钮触发请求函数。
  // 至此已经可以实现需求。除了这里的改动之外,其他地方都没有变。
  const LoginButton = () => (
    <Button title="登陆" onPress={() => setSkip((prev) => !prev)} />
  );

  return (
    ……
  );
}

那么假设现在有两个按钮,每个按钮对应不同的请求,其余和之前相同,之前的写法还能用吗。显然是不行的,因为skip就一个,按一个按钮给它改了,两个都触发了。所以,还需要改进。

再稍微看一下上面的代码,skip就只是一个boolean值,那么问题已经解决了。用createSlice再建一个 Slice,其 state 的类型就假设为number。然后可以在页面中获取该 state 的值。现在用{skip : !(state == 1)}代替{skip},另一个写{skip : !(state == 2)}。按钮响应事件改成修改state的值就行,后面的就不用说了。这样不管是想要用一个按钮触发几个请求,不管页面上有多少个按钮想触发请求都是可以实现的。当然1, 2这种过于低级,应当自行包装一下。

第三种方案是mutation,以下是代码片段。

export const UserApi = BaseApi.injectEndpoints({
  endpoints: (builder) => ({
    verifyUser: builder.mutation<boolean, User>({
      query: (user: User) => ({
        url: `/user/${user.name}:${user.password}`,
        method: "get",
      }),
      // 第一个参数是传入的User
      // 第二个参数是MutationLifecycleApi的解构
      async onQueryStarted(
        arg,
        { dispatch, getState, queryFulfilled, requestId, extra, getCacheEntry }
      ) {
        console.log("onQueryStarted");
      },
      // 第一个参数同上
      // 第二个参数是MutationCacheLifecycleApi的解构
      async onCacheEntryAdded(
        arg,
        {
          dispatch,
          getState,
          extra,
          requestId,
          cacheEntryRemoved,
          cacheDataLoaded,
          getCacheEntry,
        }
      ) {
        console.log("onCacheEntryAdded");
      },
    }),
  }),
  overrideExisting: false,
});

这里定义了一个mutation函数,其中有两个钩子函数,分别是onQueryStartedonCacheEntryAdded,看名称就知道是怎么回事。

const [verifyUser, result] = Hooks.user.useVerifyUserMutation();

const LoginButton = () => (
  <Button
    title="登陆"
    onPress={() => {
      verifyUser(userState);
    }}
  />
);

使用的时候也很简单,useVerifyUserMutation()会返回一个触发函数和一个result。触发函数可以在生命域内任何地方使用。result内含status, error, data

现在还有一个问题,如何在发出请求并且接收到数据之后再对数据进行处理。比如,请求返回了用户的信息,想把这个信息存到 store 中。在刚才的例子中<Button title="登陆" onPress={() => setSkip((prev) => !prev)} />响应事件时只是给了触发请求的条件。在此之后对返回的数据进行操作显然不能保证数据已经返回来了。使用mutation的时候虽然有两个钩子函数,但是暂时没能在里面搞到返回的结果。onCacheEntryAdded中打印一下它的参数getCacheEntry(),发现isLoading=true,但不知道这个东西是怎么判断的,因为此时数据还没入缓存,流程还没走完,isLoading=true也可以理解,就这个函数的用处来说此时数据应当已经返回。

最终笔者找到一个不是非常理想的方案,来看以下代码。

export const UserApi = BaseApi.injectEndpoints({
  endpoints: (builder) => ({
    verifyUser: builder.mutation<boolean, User>({
      query: (user: User) => ({
        url: `/user/${user.name}:${user.password}`,
        method: "get",
      }),
      transformResponse: (response: boolean, meta, arg) => {
        console.log("real", arg);
        return response;
      },
    }),
  }),
  overrideExisting: false,
});

关键在transformResponse,该属性在querymutation中都存在,在这里对数据进行处理完全不用担心是否返回的问题,只是它的本意应当是整理返回的数据。另外,在这个函数中除了 response,其他的参数meta是设备信息,argverifyUser输入的参数,没有现成的可以将返回值存入 store 或者其他地方的函数。这个 return 的response就是使用时返回的result中的data

还有要注意的一点是这里的response: boolean必须和返回类型一致。如果写成response: {data: boolean}return response.data,虽然不会报错,但result中的data永远是undefined

有兴趣的可以研究一下文档,找出mutation的正确用法。

中间件

之前添加了一个 api 自动生成的中间件,现在可以模仿之前的写法添加更多中间件。

日志

使用redux-logger

import logger from "redux-logger";

const middlewareHandler = (getDefaultMiddleware: any) => {
  const middlewareList = [...getDefaultMiddleware()];
  middlewareList.push(baseApi.middleware);
  // 开发环境下加上redux logger中间件
  if (process.env.NODE_ENV === "development") {
    middlewareList.push(logger);
  }
  return middlewareList;
};

默认情况下应当就是开发环境,不需要额外设置

错误处理

import {
  configureStore,
  MiddlewareAPI,
  isRejectedWithValue,
  Middleware,
} from "@reduxjs/toolkit";
import loginReducer from "../components/welcome/LoginRedux";
import { baseApi } from "../apis/base";
import { setupListeners } from "@reduxjs/toolkit/dist/query";
import logger from "redux-logger";

// 错误中间件
export const rtkQueryErrorLogger: Middleware =
  (api: MiddlewareAPI) => (next) => (action) => {
    // 拦截错误,httpstatus不是200的时候
    if (isRejectedWithValue(action)) {
      console.warn("中间件拦截了");
    } else {
      console.log(action, "未发生错误", api);
    }
    return next(action);
  };

// 中间件集合
const middlewareHandler = (getDefaultMiddleware: any) => {
  const middlewareList = [...getDefaultMiddleware()];
  // 加上错误处理中间件
  middlewareList.push(rtkQueryErrorLogger);
  middlewareList.push(baseApi.middleware);
  // 开发环境下加上redux logger中间件
  if (process.env.NODE_ENV === "development") {
    middlewareList.push(logger);
  }
  return middlewareList;
};

该中间件是自定义的,功能不强,只能通过 httpstatus 判别错误。更进一步需要拦截器。不过用了拦截器中间件似乎没法用了,不知是哪里没写对,暂未解决。

数据持久化

这里数据持久化使用的是redux-persist。再来看一个简单的案例,为了简化,就不用 rtk-query 了。

这是一个使用 Expo cli 建立的 react native 项目,目录结构如下。

App.tsx
pages/
  Welcome.tsx
components/
  welcome/
    LoginRedux.ts
    Login.tsx
stores/
  Welcome.ts
hooks/
  Welcome.ts

实现一个简单的登陆功能,输入用户名为user,密码为password时显示success,否则显示fail

App.tsx 是入口,内容如下。

import { View } from "react-native";
import Welcome from "./pages/Welcome";

export default function App() {
  return (
    <View>
      <Welcome />
    </View>
  );
}

这里调用的 Welcome 来自pages/Welcome.tsx

import { Provider } from "react-redux";
import { Login } from "../components/welcome/Login";
import { welcomeStore, welcomePersistor } from "../stores/Welcome";
import { PersistGate } from "redux-persist/integration/react";

export default function Welcome() {
  return (
    <Provider store={welcomeStore}>
      <PersistGate loading={null} persistor={welcomePersistor}>
        <Login />
      </PersistGate>
    </Provider>
  );
}

这里出现的PersistGate就是数据持久化插件的内容。下面来到stores/Welcome.ts看填入的welcomePersistor是如何生成的。

import { combineReducers, configureStore } from "@reduxjs/toolkit";
import loginReducer from "../components/welcome/LoginRedux";
import { persistStore, persistReducer } from "redux-persist";
import ExpoFileSystemStorage from "redux-persist-expo-filesystem";

const persistConfig = {
  key: "root",
  storage: ExpoFileSystemStorage,
};

const persistedReducer = persistReducer(
  persistConfig,
  combineReducers({
    login: loginReducer,
  })
);

export const welcomeStore = configureStore({
  reducer: persistedReducer,
  middleware: (getDefaultMiddleware) =>
    // serializableCheck这个中间件需要关掉,否则会报错
    getDefaultMiddleware({ serializableCheck: false }),
});

export const welcomePersistor = persistStore(welcomeStore);

export type WelcomeState = ReturnType<typeof welcomeStore.getState>;
export type WelcomeDispatch = typeof welcomeStore.dispatch;

storage 是存储数据的仓库,可以在这里查看并选择合适的,当前用的只适用于 Expo SDK 环境下

其他的内容与不使用数据持久化并无差别,这里将其补齐。

components/welcome/LoginRedux.ts

import { createSlice, PayloadAction } from "@reduxjs/toolkit";

interface LoginState {
  username: string;
  password: string;
  authentication: boolean;
}

const initialState = {
  username: "",
  password: "",
  authentication: false,
} as LoginState;

export const loginSlice = createSlice({
  name: "login",
  initialState,
  reducers: {
    login: (state) => {
      if (state.username == "user" && state.password == "password") {
        state.authentication = true;
      } else {
        state.authentication = false;
      }
    },
    setUsername: (state, action: PayloadAction<string>) => {
      state.username = action.payload;
    },
    setPassword: (state, action: PayloadAction<string>) => {
      state.password = action.payload;
    },
  },
});

export const { login, setUsername, setPassword } = loginSlice.actions;

export default loginSlice.reducer;

components/welcome/Login.tsx

import { TextInput, Button, View, Text } from "react-native";
import { useWelcomeDispatch, useWelcomeSelector } from "../../hooks/Welcome";
import { login, setUsername, setPassword } from "./LoginRedux";

export function Login() {
  const loginState = useWelcomeSelector((state) => state.login);

  const dispatch = useWelcomeDispatch();

  return (
    <View>
      <Text>Username: </Text>
      <TextInput
        value={loginState.username}
        onChangeText={(value) => dispatch(setUsername(value))}
      />
      <Text>Password: </Text>
      <TextInput
        value={loginState.password}
        onChangeText={(value) => dispatch(setPassword(value))}
      />
      <Button title="login" onPress={() => dispatch(login())} />
      <Text>{loginState.authentication ? "success" : "fail"}</Text>
    </View>
  );
}

hooks/welcome.ts

import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";

import type { WelcomeState, WelcomeDispatch } from "../stores/Welcome";

export const useWelcomeDispatch = () => useDispatch<WelcomeDispatch>();
export const useWelcomeSelector: TypedUseSelectorHook<WelcomeState> =
  useSelector;

现在的效果是输入用户名和密码之后刷新 app,可以发现之前输入的内容依旧存在。说明经过配置的这一个 store 存储的内容都被持久化了。

简化版请求

如果使用rtk-qeury实在有困难,也可以使用axios作为替代方案,下面是例子。

import { createAsyncThunk, createSlice, PayloadAction } from "@reduxjs/toolkit";
import axios from "axios";
import { User } from "utils/types";

const login = createAsyncThunk("user/login", async (user: User) => {
  const { data } = await axios.get(
    "http://192.168.1.109:8000/v1/user/user:password"
  );
  return data;
});

const initialState = {
  name: "",
  password: "",
} as User;

const UserSlice = createSlice({
  name: "user",
  initialState,
  reducers: {},
  extraReducers: {
    [login.pending.type]: (state) => {
      console.log("is loading");
    },
    [login.fulfilled.type]: (state, action) => {
      // 打印返回的数据
      console.log("is fulfilled", action.payload);
    },
    [login.rejected.type]: (state, action: PayloadAction<string | null>) => {
      console.log("is rejected");
    },
  },
});

const actionCreators = UserSlice.actions;

export const UserActionCreators = { actionCreators, login };

export const UserReducer = UserSlice.reducer;

以上代码应当不需要解释,可以看到异步函数的三种状态pending, fulfilled, rejected已经列出,比起之前的方案显然要简单的多。

注意这个不是请求的三种状态。可以在异步函数中通过请求的返回值来判断有没有出问题,然后触发异步的fulfilledrejected状态。

使用的时候只要像一般的 action creators 一样使用login即可。只是需要注意一点。之前使用export const useStoreDispatch = () => useDispatch<Dispatch>();const dispatch = useStoreDispatch();生成的dispatch不能再使用。建议直接把类型限定关掉,即将useDispatch<Dispatch>()改为useDispatch()


文章作者: niuiic
版权声明: 本博客所有文章除特別声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来源 niuiic !
评论
  目录