React中级教程(二)– React Query(中)

继续学习React Query。​


添加数据

我们已学习了查询数据,现在来学习更改数据,我们要在前面的数据列表前加上一个表单,用于提交数据。

打开我们的项目,能看到 src/react-query/TodoForm.tsx组件。这个组件非常简单,只有一个Input,并用Ref引用它,用于获取输入。

// TodoForm.tsx
​
import { useRef } from 'react';
​
const TodoForm = () => {
  const ref = useRef<HTMLInputElement>(null);
​
  return (
    <form className="row mb-3">
      <div className="col">
        <input ref={ref} type="text" className="form-control" />
      </div>
      <div className="col">
        <button className="btn btn-primary">Add</button>
      </div>
    </form>
  );
};
​
export default TodoForm;

再到App中使用它。

// App.tsx
​
import "./App.css";
import TodoForm from "./react-query/TodoForm";
import TodoList from "./react-query/TodoList";
​
function App() {
  return (
    <>
      <TodoForm />
      <TodoList />
    </>
  );
}
​
export default App;

现在来处理提交事件。

定义一个 useMutation的Hook名为addTodo,用于添加数据。需要提供更新函数的属性 mutationFn,我们定义该函数需提供参数 todo对象,然后调用axios.post方法将todo对象添加到todos列表。

再在Form的onSubmit中使用,首先调用event.preventDefault禁用默认行为,再判断Input是否为空,不为空的话,就调用addTodo.mutate方法,也就是前面的mutationFn属性定义的函数,为了不转移注意力,我们在提供的todo对象用了硬编码,比如id为0,userId为1等。

接着需要更新列表项。

useMutation有一些回调函数的属性,比如onSuccess表示成功的处理函数,onError表示失败的处理,onSettled表示不管成功或失败的处理函数等等,这里就用onSuccess。

onSuccess需要提供一些参数,第1个参数表示成功写入后端的数据,我们命名为savedTodo,第2个参数表示我们传递过去的数据,这里命名为newTodo。

具体如何更新列表项呢?有两种技术。

第1种是指示缓存无效,这样ReactQuery就会自动从后端获取数据刷新,但这里不适用,因为我们用的是伪API,不会实际添加,重新从后端获取不会有新的条目,但实际场景中是可以这么做的,具体是:为使用ReactQuery,前面我们在main.tsx中,定义了queryClient对象,在TodoForm中,我们调用用useQueryClient()获取。然后通过queryClient.invalidateQueries方法同时提供queryKey属性使该键值的缓存失效。

第2种方法是更新缓存,通过queryClient.setQueryData方法实现,该方法需要提供键值(这里是[“todos”]),以及更新函数来实现。具体代码如下。

// TodoForm.tsx
​
​
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useRef } from "react";
import { Todo } from "./hooks/useTodos";
import axios from "axios";
​
const TodoForm = () => {
  const queryClient = useQueryClient();
  const ref = useRef<HTMLInputElement>(null);
  const addTodo = useMutation({
    mutationFn: (todo: Todo) =>
      axios
        .post<Todo>("https://jsonplaceholder.typicode.com/todos", todo)
        .then((res) => res.data),
    onSuccess: (savedTodo, newTodo) => {
      // 有两种技术,第一种是使缓存无效,这样ReactQuery就会从服务后端获取最新的数据
      // 但这种方法对我们的伪API是无效的,伪API只是模拟不会实际在后端添加条目
      // queryClient.invalidateQueries({
      //   queryKey: ["todos"],
      // });
​
      // 第二种方法是更新缓存
      queryClient.setQueryData<Todo[]>(["todos"], (todos) => [
        savedTodo,
        ...(todos || []),
      ]);
    },
  });
​
  return (
    <form
      className="row mb-3"
      onSubmit={(event) => {
        event.preventDefault();
        if (ref.current && ref.current.value)
          addTodo.mutate({
            id: 0,
            title: ref.current?.value,
            completed: false,
            userId: 1,
          });
      }}
    >
      <div className="col">
        <input ref={ref} type="text" className="form-control" />
      </div>
      <div className="col">
        <button className="btn btn-primary">Add</button>
      </div>
    </form>
  );
};
​
export default TodoForm;
​

回到页面,在输入框输入条目,并点”Add”,检查列表会增加该条目。


处理数据更改的错误

我们在组件里添加记录时,如果有错误,就展示出来。为了不使TypeScript报错,useMutation添加类型:useMutation<Todo, Error, Todo>.

// TodoForm.tsx
​
......
  const addTodo = useMutation<Todo, Error, Todo>(
  ......
  );
​
  return (
    <>
      {addTodo.error && (
        <div className="alert alert-danger">{addTodo.error.message}</div>
      )}
      <form
        ......
      </form>
    </>
  );
};
​
export default TodoForm;

我们可以故意修改成错误的API端点,会看到有错误提示。


显示数据更改过程

数据更改是需要花时间的,我们来显示提示。与查询一样,也有一个isLoading属性。

另外在添加条目时,我们希望清空输入框,调用ref.current.value=‘’即可。

// TodoForm.tsx
​
......
​
      queryClient.setQueryData<Todo[]>(["todos"], (todos) => [
        savedTodo,
        ...(todos || []),
      ]);
      if (ref.current) ref.current.value = "";
​
......
        <div className="col">
          <button disabled={addTodo.isLoading} className="btn btn-primary">
            {addTodo.isLoading ? "Adding..." : "Add"}
          </button>
......

乐观更新

目前是更新后端后再作UI的展示,响应方面稍有些慢,可以改成乐观更新。稍有些步骤。

首先将之前在onSuccess的代码移到 onMutate里,这样就先更新UI。考虑到后端调用失败需要还原,将更新前的Todo列表保存到previousTodos里,该变量也有可能是空列表,所以后面加上”||[]”,否则TypeScript可能报错,在onMutate最后返回 {previousTodos}。在useMutation里,各回调函数之间可以传递上下文,onMutate返回的数据在其他回调函数如 onError中使用。

我们定义了一个interface AddTodoContext,表示各回调函数中的上下文的数据格式,并在调用useMutation时,提供这个类型参数。

onSuccess中,我们需要更新,因为传递的id我们随便设了0,实际保存的ID由后端生成,通过 map方法更新。

onError中,检查上下文是否为空,非空的话将上下文的内容还原并更新UI。

// TodoForm.tsx
​
​
......
​
interface AddTodoContext {
  previousTodos: Todo[];
}
......
​
  const addTodo = useMutation<Todo, Error, Todo, AddTodoContext>({
    ......
​
    onMutate(newTodo) {
      const previousTodos = queryClient.getQueryData<Todo[]>(["todos"]) || [];
      queryClient.setQueryData<Todo[]>(["todos"], (todos) => [
        newTodo,
        ...(todos || []),
      ]);
      if (ref.current) ref.current.value = "";
      return { previousTodos };
    },
​
    onSuccess: (savedTodo, newTodo) => {
      queryClient.setQueryData<Todo[]>(["todos"], (todos) =>
        todos?.map((todo) => (todo === newTodo ? savedTodo : todo))
);
    },
​
    onError(error, newTodo, context) {
      if (!context) return;
      queryClient.setQueriesData<Todo[]>(["todos"], context.previousTodos);
    },
  });
​
......

创建自定义更改Hook

目前的实现明显包含太多了,既知道后端细节,也知道如何实现乐观更新,不符合单一职责原则,需要进行分离。

新建文件 src/react-query/hooks/useAddTodo.ts。

将有关后端调用的操作及缓存的操作全部移到这个文件里。直接返回useMutation对象。之前在onMutate里有清空 input的操作,这属于UI层面的操作,在useAddTodo.ts里实现就不对了,于是提取成一个 onAdd回调函数,由消费者即TodoForm.tsx实现。

useAddTodo.ts里修改了以下代码片段,参数todos给出默认值[],这样就不用在后面 用 …(todos||[]),个人感觉更清晰一点。

 …… (todos=[]) => [newTodo, …todos,])

另外,多次用到的键值 [“todos”]提取成CACHE_KEY_TODOS,并放在一个单独的文件constants.ts里。

接着修改TodoForm.tsx,调用useAddTodo,并提供onAdd回调函数的实现。

这样之后,就非常清晰,可重用性也增强了。

// useAddTodo.ts
​
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Todo } from "./useTodos";
import axios from "axios";
import { CACHE_KEY_TODOS } from "../constants";
​
interface AddTodoContext {
    previousTodos: Todo[];
}
const useAddTodo=(onAdd:()=>void)=>{
    const queryClient = useQueryClient();
    return useMutation<Todo, Error, Todo, AddTodoContext>({
      mutationFn: (todo: Todo) =>
        axios
          .post<Todo>("https://jsonplaceholder.typicode.com/todos", todo)
          .then((res) => res.data),
  
      onMutate(newTodo) {
        const previousTodos = queryClient.getQueryData<Todo[]>(CACHE_KEY_TODOS) || [];
        queryClient.setQueryData<Todo[]>(CACHE_KEY_TODOS, (todos=[]) => [
          newTodo,
          ...todos,
        ]);
        onAdd();
        return { previousTodos };
      },
  
      onSuccess: (savedTodo, newTodo) => {
        queryClient.setQueryData<Todo[]>(CACHE_KEY_TODOS, (todos) =>
          todos?.map((todo) => (todo === newTodo ? savedTodo : todo))
);
      },
  
      onError(error, newTodo, context) {
        if (!context) return;
        queryClient.setQueriesData<Todo[]>(CACHE_KEY_TODOS, context.previousTodos);
      },
    });
​
}
​
export default useAddTodo;

constants.ts:

// constants.ts
​
export const CACHE_KEY_TODOS = ['todos']

TodoForm.tsx:

// TodoForm.tsx
​
......
const TodoForm = () => {
  const ref = useRef<HTMLInputElement>(null);
  const addTodo = useAddTodo(() => {
    if (ref.current) ref.current.value = "";
  });
  return (
    ......
  );
};
​
export default TodoForm;

一个小技巧,当我们将相关代码从TodoForm移到useAddTodo后,TodoForm的一些import引用就用不到了,可以按ctrl+shift+p,输入Organize Imports指令就可以快捷清除不用的引用。


创建可重用的API客户端

目前有两个自定义Hook,useTodos和useAddTodo都用到了API同样的API端点,有点重复,我们来提取可重用的API。

新建文件 src/react-query/services/apiClient.ts,将API端点封装起来。

使用了APIClient类,在构造函数里获取具体的端点。该类封装了getAll和post方法,注意我们使箭头函数的方式,避免调用时javascript中的this指向的问题。

APIClient类使用了一个通用的类型<T>,这样调用它时可以提供具体的类型。

这种封装方法,我们在React入门系列里已有过提及。

//apiClient.ts
​
import axios from "axios";
​
const axiosInstance = axios.create({
    baseURL:"https://jsonplaceholder.typicode.com",
})
​
class APIClient<T>{
    endpoint:string;
    constructor(endpoinrt:string){
        this.endpoint = endpoinrt;
    }
​
    getAll = () =>{
        return axiosInstance.get<T[]>(this.endpoint).then(res=>res.data);
    }
​
    post = (data:T) =>{
        axiosInstance.post<T>(this.endpoint,data).then(res=>res.data)
    }
}
​
export default APIClient;

useTodos中调用ApiClient。

// useTodos.ts
​
​
import APIClient from "../services/apiClient";
......
​
const apiClient = new APIClient<Todo>('/todos');
......
​
const useTodos = () =>{
    return useQuery<Todo[], Error>({
        queryFn: apiClient.getAll,
        ......
      });
}
​
export default useTodos;

useAddTodo同样的调用。

// useAddTodo.ts
​
​
......
import APIClient from "../services/apiClient";
const apiClient = new APIClient<Todo>('/todos');
......
    return useMutation<Todo, Error, Todo, AddTodoContext>({
      mutationFn:apiClient.post,
      ......
      

创建可重用的HTTP 服务

目前我们的程序还不错,但还有重复,在useTodos和useAddTodo里重复使用了“/todos”端点,可以再提取成HTTP服务。

新建文件 src/react-query/services/todoService.ts。将 interface Todo 和 创建 APIClient的实例移到里面,并export。

// todoService.ts
​
​
import APIClient from "./apiClient";
export interface Todo {
    id: number;
    title: string;
    userId: number;
    completed: boolean;
  }
​
export default new APIClient<Todo>('/todos');

接着修改 useTodos.ts 和 useAddTodo.ts调用 todoService即可。

// useTodos.ts
​
......
import todoServicrs, { Todo } from "../services/todoServicr";
​
const useTodos = () =>{
    return useQuery<Todo[], Error>({
        ......
        queryFn: todoServicrs.getAll,
      });
}
​
export default useTodos;
// useAddTodo.ts
import todoServicrs, {Todo} from "../services/todoServicr";
......
​
    return useMutation<Todo, Error, Todo, AddTodoContext>({
      mutationFn:todoServicrs.post,
......

应用程序层次

看一下目前我们的应用程序层次,非常清晰,可维护性、可读性及伸缩性也很不错。

API Client 用于与后端交互。

HTTP Services 用于封装HTTP的方法。

Custom Hooks用于处理获取的数据及缓存。

Components 用于前端的展示。


小结

本篇继续学习React Query,介绍使用React Query添加数据及重构应用程序的相关技巧。

下一篇利用React Query 修改完善我们在前一系列中创建的game-hub应用。

发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注