继续学习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应用。