Primary React (3) 本文是 mosh 的 React 基础课的第三部分笔记,包括 Web 表单设计与 React 和后端进行交互两部分内容
这部分内容包括 React 中和 Web 表单相关的知识,包括用第三方库构建 Web 表单和数据验证等内容
创建表单 这一部分需要用回之前的 bootstrap.css 库,先取消 main 里的注释
这里介绍一个快速输入的小技巧,也就是 emmet 缩写:
1 div.mb-3>label.form-label+input.form-control
整体上很好理解:
.符号表示 CSS 类名
>符号表示下属 HTML 标记
+符号表示并列 HTML 标记
看一下最后的结果:
1 2 3 4 5 6 7 8 9 10 11 12 const Form = ( ) => { return ( <form > <div className ="mb-3" > <label htmlFor ="" className ="form-label" > </label > <input type ="text" className ="form-control" /> </div > </form > ); };export default Form ;
这里的 htmlFor 是 jsx 语言里对 for 属性的特化版本,原因是 for 是 js 语言里的关键字
我们将它和 input 的 id 设置成相同的内容(通常为 input 的属性),这样就将二者关联起来
看看效果:
太接近边缘了,我们可以考虑使用全局 CSS 来解决这个问题,在/src 目录下创建 index.css 文件
1 2 3 body { padding : 20px ; }
这样就设置好了整体样式
最后添加一些细节,完成我们的表单主体:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 const Form = ( ) => { return ( <form > <div className ="mb-3" > <label htmlFor ="name" className ="form-label" > Name </label > <input id ="name" type ="text" className ="form-control" /> </div > <div className ="mb-3" > <label htmlFor ="age" className ="form-label" > </label > <input id ="age" type ="number" className ="form-control" /> </div > <button className ="btn btn-primary" type ="submit" > Submit </button > </form > ); };export default Form ;
注意这里的按钮的 type 应该设置为 submit,否则不能触发表单的提交事件
但是现在的表单还只是个花架子,没有处理输入的功能,下一步就来实现之
提交表单 onSubmit 事件 要让表单在提交的时候有所作为,关键就是做这个 onSubmit 事件,它是表单的一个属性,负责处理提交事件(也就是上面说到的按下按钮后被触发的提交事件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 const Form = ( ) => { const handleSubmit = (e : React .FormEvent ) => { e.preventDefault (); console .log ("Form submitted" ); }; return ( <form onSubmit ={handleSubmit} > <div className ="mb-3" > <label htmlFor ="name" className ="form-label" > Name </label > <input id ="name" type ="text" className ="form-control" /> </div > <div className ="mb-3" > <label htmlFor ="age" className ="form-label" > Age </label > <input id ="age" type ="number" className ="form-control" /> </div > <button className ="btn btn-primary" type ="submit" > Submit </button > </form > ); };export default Form ;
注意这里的 preventDefault 是用来防止表单被发表到服务器的,我们现在还是只在本地做测试
同样不要忘了要给出 e 的类型,这是 ts 的要求
处理提交数据 如何获取输入的数据以便于进行下一步操作呢?听起来有点像我们的钩子思想了,事实上也确实如此,这一集我们需要用到一个新的钩子:useRef,它可以引用 DOM 元素,从而将我们的输入提取出来
useRef 一个需要注意的点是,useRef 初始化必须为 null 或者现有的 DOM 元素,不能为空,通常初始化为 null 即可
同样的,使用 useRef 需要给出对应的 DOM 类型,在这里即为 HTMLInputElement,同时在 input 元素里设置 ref 属性,这样就可以将二者关联起来
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 import { useRef, useState } from "react" ;const Form = ( ) => { const nameRef = useRef<HTMLInputElement >(null ); const ageRef = useRef<HTMLInputElement >(null ); const [person, setPerson] = useState ({ name : "" , age : 0 }); const handleSubmit = (e : React .FormEvent <HTMLFormElement > ) => { e.preventDefault (); setPerson ({ name : nameRef.current ?.value || "" , age : parseInt (ageRef.current ?.value || "0" ), }); console .log (person); }; return ( <form onSubmit ={handleSubmit} > <div className ="mb-3" > <label htmlFor ="name" className ="form-label" > Name </label > <input id ="name" type ="text" className ="form-control" ref ={nameRef} /> </div > <div className ="mb-3" > <label htmlFor ="age" className ="form-label" > Age </label > <input id ="age" type ="number" className ="form-control" ref ={ageRef} /> </div > <button className ="btn btn-primary" type ="submit" > Submit </button > </form > ); };export default Form ;
实际情况下,我们是需要将得到的内容发布到后端的,处理方式一般是构造一个对象然后做相应处理,在上面的示例代码中,我们仅仅是在控制台上打印之
useState 如果想使用 useState 来实现提交表单操作也是可以的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 import { FormEvent , useState } from "react" ;const Form = ( ) => { const [person, setPerson] = useState ({ name : "" , age : 0 }); const handleSubmit = (e : FormEvent <HTMLFormElement > ) => { e.preventDefault (); console .log (person); }; return ( <form onSubmit ={handleSubmit} > <div className ="mb-3" > <label htmlFor ="name" className ="form-label" > Name </label > <input onChange ={(event) => setPerson({ ...person, name: event.target.value }) } id="name" type="text" className="form-control" /> </div > <div className ="mb-3" > <label htmlFor ="age" className ="form-label" > Age </label > <input onChange ={(event) => setPerson({ ...person, age: parseInt(event.target.value) }) } id="age" type="number" className="form-control" /> </div > <button className ="btn btn-primary" type ="submit" > Submit </button > </form > ); };export default Form ;
受控组件 当前我们的表单只具备提交的基本功能,但是对于输入框具体显示什么还不是由 React 说了算的。如果我们想实现更复杂的操作,比如重置表单,设置表单默认值等,就需要用到受控组件的技术,把“组件显示什么”这种问题统一收归 React 来管理
说白了,就是中央集权
方法也很简单,设置 value 字段即可
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 <form onSubmit={handleSubmit}> <div className ="mb-3" > <label htmlFor ="name" className ="form-label" > Name </label > <input onChange ={(event) => setPerson({ ...person, name: event.target.value })} id="name" type="text" className="form-control" value={person.name} /> </div > <div className ="mb-3" > <label htmlFor ="age" className ="form-label" > Age </label > <input onChange ={(event) => setPerson({ ...person, age: parseInt(event.target.value) }) } id="age" type="number" className="form-control" value={person.age} /> </div > <button className ="btn btn-primary" type ="submit" > Submit </button > </form>
当组件多起来之后,每个 input 都需要设置 onChange/value 等属性,这无疑过于麻烦了
所以我们可以引入先进生产力:
1 npm i react-hook-form@latest
代码中就可以直接使用 useForm 接口来获取想要的函数了
处理提交 这里我们只介绍 register 和 onSubmit 两个,更多的可以去查阅文档等
1 const { register, handleSubmit } = useForm ();
实际上就是该库提供的两个实用函数,可以简化我们的代码逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 import { useForm, FieldValues } from "react-hook-form" ;const Form = ( ) => { const { register, handleSubmit } = useForm (); const onSubmit = (data : FieldValues ) => { console .log (data); }; return ( <form onSubmit ={handleSubmit(onSubmit)} > <div className ="mb-3" > <label htmlFor ="name" className ="form-label" > Name </label > <input {...register ("name ")} type ="text" className ="form-control" id ="name" /> </div > <div className ="mb-3" > <label htmlFor ="age" className ="form-label" > Age </label > <input {...register ("age ")} type ="number" className ="form-control" id ="age" /> </div > <button className ="btn btn-primary" type ="submit" > Submit </button > </form > ); };export default Form ;
onSubmit 其实好理解,就是做了一层包装,然后利用 register 传进去的数据写一个自己的 handler 即可
重点在 register 的用法上:
这一层其实就相当于设置了很多属性,等价于:
1 2 3 4 5 6 7 8 9 10 <input name="name" onChange={(e ) => { }} onBlur={(e ) => { }} ref={} />
把 value 和 onChange 的显示设置方式给去掉了,只需要一个扩展芙就完事
虽然这里没有 value 字段,但是同样是受控组件,react-hook-form 库提供了一层抽象屏障,用它自己的方式为我们实现了中央集权
这不就是宰相么
想要 reset?找宰相要就行了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 const Form = ( ) => { const { register, setValue, reset } = useForm ({ defaultValues : { name : "默认值" , }, }); return ( <div > <input {...register ("name ")} /> {/* 可以通过 setValue 设置值 */} <button onClick ={() => setValue("name", "新值")}>设置新值</button > {/* 可以重置表单 */} <button onClick ={() => reset()}>重置</button > </div > ); };
数据验证 该库同样可以帮我们实现数据验证
我们只需要设置 register 的第二个参数即可,这里你可以控制你想要的错误信息:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 <input {...register ("name" , { required : true , minLength : 3 })} type ="text" className="form-control" id="name" /><input {...register ("age ", { required: true })} type ="number" className ="form-control" id ="age" />
如何将错误展现出来?找宰相要一个 error,打印嘛:
1 2 3 4 5 const { register, handleSubmit, formState : { errors }, } = useForm ();
用上之前学的&&运算符
1 2 3 4 5 { errors.name ?.type === "required" && ( <p className ="text-danger" > Name is required</p > ); }
最后为了提高编码体验,可以传一个 interface 给宰相,这样我们写 errors 加点的时候就能看见表单属性了
放一个整体的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 import { useForm, FieldValues } from "react-hook-form" ;interface FormData { name : string ; age : number ; }const Form = ( ) => { const { register, handleSubmit, formState : { errors }, } = useForm<FormData >(); const onSubmit = (data : FieldValues ) => { console .log (data); }; return ( <form onSubmit ={handleSubmit(onSubmit)} > <div className ="mb-3" > <label htmlFor ="name" className ="form-label" > Name </label > <input {...register ("name ", { required: true , minLength: 3 })} type ="text" className ="form-control" id ="name" /> {errors.name?.type === "required" && ( <p className ="text-danger" > Name is required</p > )} {errors.name?.type === "minLength" && ( <p className ="text-danger" > At least be 3 characters</p > )} </div > <div className ="mb-3" > <label htmlFor ="age" className ="form-label" > Age </label > <input {...register ("age ", { required: true })} type ="number" className ="form-control" id ="age" /> {errors.age && <p className ="text-danger" > Age is required</p > } </div > <button className ="btn btn-primary" type ="submit" > Submit </button > </form > ); };export default Form ;
使用 zod 这一节将引入一个更方便的库——zod,它在数据验证的处理上比我们之前的方式还要优化许多
当然 zod 不是来替代掉 useRef 的,它们是合作与辅助的关系,zod 可以让 useRef 更强大
首先下载相关库:
1 2 npm i zod@latest --save npm i @hookform/resolvers@latest --save
数据验证 zod 的第一个作用是简化数据形式验证:
1 2 3 4 5 6 const schema = z.object ({ name : z.string ().min (3 ), age : z.number ().min (1 ), });type FormData = z.infer <typeof schema>;
我们只需要定义这样一个 schema 即可,还能自动生成 interface 而非手动定义
下一步就是和 useRef 结合起来,这里就需要我们下载的第二个库了——用 resolver 把它传进去
1 2 3 4 5 const { register, handleSubmit, formState : { errors }, } = useForm<FormData >({ resolver : zodResolver (schema) });
然后再在后面的部分把之前的错误验证内容删除,回到最初样子:
1 2 {...register ("name" )} {...register ("age" )}
错误信息同样可以简化,直接使用提供的默认信息即可:
1 2 3 4 5 6 { errors.name && <p className ="text-danger" > {errors.name.message}</p > ; } { errors.age && <p className ="text-danger" > {errors.age.message}</p > ; }
现在的效果如下:
当然这并非不可以定制,相关的功能只要更改我们的 schema 就好了
1 2 3 4 5 6 const schema = z.object ({ name : z.string ().min (3 , { message : "Name must be at least 3 characters" }), age : z .number ({ invalid_type_error : "Age field is required" }) .min (18 , { message : "Age must be at least 18" }), });
相关的更改会随着 resolver 传给 useRef,并最终反应到组件上,又是一层新的集权模式
最后放一个全量的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 import { useForm, FieldValues } from "react-hook-form" ;import { z } from "zod" ;import { zodResolver } from "@hookform/resolvers/zod" ;const schema = z.object ({ name : z.string ().min (3 , { message : "Name must be at least 3 characters" }), age : z .number ({ invalid_type_error : "Age field is required" }) .min (18 , { message : "Age must be at least 18" }), });type FormData = z.infer <typeof schema>;const Form = ( ) => { const { register, handleSubmit, formState : { errors }, } = useForm<FormData >({ resolver : zodResolver (schema) }); const onSubmit = (data : FieldValues ) => { console .log (data); }; return ( <form onSubmit ={handleSubmit(onSubmit)} > <div className ="mb-3" > <label htmlFor ="name" className ="form-label" > Name </label > <input {...register ("name ")} type ="text" className ="form-control" id ="name" /> {errors.name && <p className ="text-danger" > {errors.name.message}</p > } </div > <div className ="mb-3" > <label htmlFor ="age" className ="form-label" > Age </label > <input {...register ("age ", { valueAsNumber: true })} // 防止组件将之解释为字符串 type ="number" className ="form-control" id ="age" /> {errors.age && <p className ="text-danger" > {errors.age.message}</p > } </div > <button className ="btn btn-primary" type ="submit" > Submit </button > </form > ); };export default Form ;
设置按钮有效性 当输入不合规的时候,我们希望按钮直接无效,同样可以找这个奇妙库实现,找我们的宰相要一个 isValid 就行了
1 2 3 4 5 6 7 8 9 10 11 const { register, handleSubmit, formState : { errors, isValid }, } = useForm<FormData >({ resolver : zodResolver (schema), mode : "onBlur" }); ...... <button className="btn btn-primary" type ="submit" disabled={!isValid}> Submit </button>
这里还需要注意一下 mode 参数,它提供了几种不同的选项:
onSubmit(默认值):仅在点击提交按钮时进行验证;提交前不显示任何错误信息
onChange:在输入时实时验证;用户每次修改输入值时都会立即显示错误信息
onBlur:在输入框失去焦点时验证;用户完成输入并离开输入框后才显示错误信息
onTouched:在字段首次获得焦点后,才开始像 onChange 一样工作;适合不想一开始就显示错误,但后续需要实时反馈的场景
all:结合了 onChange 和 onBlur 的特性;在输入时和失去焦点时都进行验证;提供最严格的验证方式
这样表单相关的内容就介绍结束了,细想会发现其实 mosh 的课是一个循序渐进的过程:他往往会先介绍一些功能的最基本的实现方式,然后再告诉你这样做可能会有什么缺点,随后便会引入新的库来简化实现,同时还会教你一些关于新库的一些使用技巧。最后相当于你学到了一种需求多种解决手段的能力。
Connecting to the Backend 前端的(基础)部分基本讲完了,现在我们来学习怎么把前后端怼到一起!
理解 useEffect 介绍后端之前,我们先学习一个新的钩子——useEffect()
渲染后执行 之前讲过,React 设计哲学的一个要点就是我们写出的组件要是“Pure Component”,即输入相同则输出相同
但是有时候我们也会难免有一些涉及到组件之外的操作,这时候就需要 useEffect 操作,它的作用就是将涉及副作用的代码在组件渲染后再执行
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 import { useEffect, useRef } from "react" ;const useEffectExample = ( ) => { const ref = useRef<HTMLInputElement >(null ); useEffect (() => { if (ref.current ) ref.current .focus (); }); useEffect (() => { document .title = "My App" ; console .log ("useEffect" ); }); return ( <> <input type ="text" className ="form-control" ref ={ref} /> </> ); };export default useEffectExample;
这里我们放在箭头函数里的内容就会涉及到组件之外的部分,而我们将之放在 useEffect 里,就可以在组件渲染完成后执行这些代码
配置 effect 依赖项 考虑以下 bug 代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import { useEffect, useState } from "react" ;const ProductList = ( ) => { const [products, setProducts] = useState<string []>([]); useEffect (() => { console .log ("Fetching products" ); setProducts (["Product 1" , "Product 2" , "Product 3" ]); }); return ( <> {products.map((product) => ( <h1 key ={product} > {product}</h1 > ))} </> ); };export default ProductList ;
ts 编译器并不会哈气,但是如果真的把这个组件扔到浏览器上,浏览器会疯狂哈气并告诉你这组件陷入了无限循环
这是为什么呢?我们知道 useEffect 内的代码是在组件渲染后执行的,但是在上面的例子里,这部分被执行的代码里包括了 setProduct 函数,根据 useState 的规则,状态变量被更改之后组件需要被重新渲染,于是就造成了死循环:重新渲染——执行代码——状态变量被改变——重新渲染——···········
解决方法同样来自 useEffect,我们只需要给它配置第二个参数,即依赖项即可
在不配置的时候,useEffect 是默认在组件渲染后执行
配置为空数组,则 useEffect 仅在初次渲染后执行一次
配置为依赖项数组,则 useEffect 会在依赖项发生更改时执行
若要解决上面示例 bug 代码的矛盾,直接配一个空数组就行
下面我们再来看一个配置依赖项的例子,我们设计俩新的组件来说明:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 import { useState } from "react" ;import UseEffectExample from "./useEffectExample" ;const ProductList = ( ) => { const [category, setCategory] = useState<string >("" ); return ( <> <select className ="form-select" onChange ={(e) => setCategory(e.target.value)} > <option value ="" > Select Category</option > <option value ="aaa" > aaa</option > <option value ="bbb" > bbb</option > <option value ="ccc" > ccc</option > </select > <UseEffectExample category ={category} /> </> ); };export default ProductList ;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import { useEffect } from "react" ;const UseEffectExample = ({ category }: { category: string } ) => { useEffect (() => { console .log ("Fetching products in" , category); }, [category]); return ( <> <h1 > Category: {category}</h1 > </> ); };export default UseEffectExample ;
如果不看 useEffect 的话,这只是一个简单的状态传递的例子:状态在父级组件被选择更改,在子组件中通过 props 传进去,然后发生修改
现在假设我想在选择后做一些事,比如通过这个 category 向后端查询一批数据,必然就会用到 useEffect。简化起见这里就使用一个控制台打印来代替
如果我不配置 useEffect 的依赖项,那么当 category 被更改时,这段代码就可能不会被执行,所以这里就需要配置依赖项数组,直接设置为 props 里传进来的 category,于是这段代码就可以在每次 category 发生更改时被执行了
Effect 清理 在某些场景下我们需要对 effect 对应的清理函数,例如:我们有一个聊天服务器,渲染组件的时候连接进去,撤销组件的时候取消连接,这里取消连接就是我们需要的清理函数
同样以控制台打印为代替,我们做一个示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 import { useEffect } from "react" ;const UseEffectExample = ({ category }: { category: string } ) => { const connect = ( ) => { console .log ("Connecting to the database" ); }; const disconnect = ( ) => { console .log ("Disconnecting from the database" ); }; useEffect (() => { connect (); return () => disconnect (); }, [category]); return ( <> <h1 > Category: {category}</h1 > </> ); };export default UseEffectExample ;
这里被 return 的函数就是我们的 cleanup fuction,在 useEffect 的定义中,它将在下一次 effect 执行前,先执行这个清理函数
Fetching Data 现在我们开始正式学习连接后端相关的知识。然而我们现在手上并没有后端,所以可以使用这个网站:
JSONPlaceholder - Free Fake REST API
它可以提供一个虚假的后端,帮助我们练习
渲染数据 React 对于获取数据本身有一个通用的函数叫 fetch,但是我们并不打算用,我们要用的是 axios 库,所以安装它:
1 npm i axios@latest --save
然后用它给 url 发送 http 请求就可以了,这里用的是 get 方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 import axios from "axios" ;import { useEffect, useState } from "react" ;interface User { id : number ; name : string ; }const UserList = ( ) => { const [users, setUsers] = useState<User []>([]); useEffect (() => { axios .get <User []>("https://jsonplaceholder.typicode.com/users" ) .then ((res ) => { setUsers (res.data ); }); }, []); return ( <> <h1 > User List</h1 > <ul > {users.map((user) => ( <li className ="list-group-item" key ={user.id} > {user.name} </li > ))} </ul > </> ); };export default UserList ;
注意这里的 then 方法:由于 get 实际上并不是瞬间完成的,所以这里会返回一个叫做 promise 的对象,这是异步操作的结果。而 promise 有个方法叫做 then,意思就是如果 get 操作成功后该干什么,其内部参数为一个箭头函数,箭头函数又有一个参数 response 表示 get 成功后返回的结果,那么我们这里使用 res.data 即可表示获取到的 user 数组
另一个需要注意的点是这里的 interface,它实际上是帮助我们改善 coding 体验的,因为 ts 本身并不知道 data 返回的类型,我可以把感兴趣的属性封装成接口,再用泛型接口传就可以了
处理错误 相应的,不是每次 then 都能 then 出来,有时候 then 不出来就会出问题,这时候 axios 同样为我们提供了很好的工具来捕捉错误——catch 方法,跟在 then 后面就行:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 const UserList = ( ) => { const [users, setUsers] = useState<User []>([]); const [error, setError] = useState<string >("" ); useEffect (() => { axios .get <User []>("https://jsonplaceholder.typicode.com/users" ) .then ((res ) => { setUsers (res.data ); }) .catch ((err ) => { setError (err.message ); }); }, []); return ( <> {error && <div className ="alert alert-danger" > {error}</div > } <h1 > User List</h1 > <ul > {users.map((user) => ( <li className ="list-group-item" key ={user.id} > {user.name} </li > ))} </ul > </> ); };
最后搭配上经典的&&运算符处理错误信息显示即可
中断请求 上文介绍 effect 的时候有提到清理函数,它在获取数据时也是有必要存在的。例如当用户离开了我们的界面时,我们自然也就不再需要获取数据了。
处理这样的逻辑需要用到 AbortController,它是浏览器自带的,专门用来做这样的事,我们将这个功能加入我们的 UserList
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 useEffect (() => { const controller = new AbortController (); axios .get <User []>("https://jsonplaceholder.typicode.com/users" , { signal : controller.signal , }) .then ((res ) => { setUsers (res.data ); }) .catch ((err ) => { if (err instanceof CanceledError ) return ; setError (err.message ); console .log (err); }); return () => controller.abort (); }, []);
在这里:
AbortController 是遥控器
signal 是遥控器和电视之间的红外线信号
abort() 是按下关机键
请求是电视
于是在撤销组件时,我们的控制器就发出了相应的 abort 信号,并通过 signal 传给 axios,中断这个请求
注意:controller 必须在 useEffect 内部创建,因为 AbortController 的状态会持续存在。一旦一个 controller 被 abort,它就永久处于 aborted 状态,不能被重用。所以我们在 useEffect 内创建就相当于每次挂载组件就有一个新的 controller,abort 后就没了。
如果在外部创建的话,一方面违背了 pure component 的要求,另一方面在严格模式下,第一次卸载组件时的 abort 操作会禁止掉第二次的挂载,这也可以看出严格模式对于检查我们的组件的作用。
显示加载器 如果想在数据加载时显示加载器,使用状态变量即可,全量的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 import axios, { CanceledError } from "axios" ;import { useEffect, useState } from "react" ;interface User { id : number ; name : string ; }const UserList = ( ) => { const [users, setUsers] = useState<User []>([]); const [error, setError] = useState<string >("" ); const [isLoading, setIsLoading] = useState<boolean >(false ); useEffect (() => { const controller = new AbortController (); setIsLoading (true ); axios .get <User []>("https://jsonplaceholder.typicode.com/users" , { signal : controller.signal , }) .then ((res ) => { setUsers (res.data ); setIsLoading (false ); }) .catch ((err ) => { if (err instanceof CanceledError ) return ; setError (err.message ); setIsLoading (false ); }); return () => controller.abort (); }, []); return ( <> {error && <div className ="alert alert-danger" > {error}</div > } <h1 > User List</h1 > {isLoading && <div className ="spinner-border" > </div > } // 显示加载器,这是bootstrap里的类 <ul className ="list-group" > {users.map((user) => ( <li className ="list-group-item" key ={user.id} > {user.name} </li > ))} </ul > </> ); };export default UserList ;
理论上可以使用 finally 方法来避免代码冗余,但是它在严格模式下会失效,所以弃之
Changing Data 下一步是更改数据,通常对于更改数据的操作而言有两种更新方式:
乐观更新:即先更新前端,再给后端发信息,如果没啥问题就 ok,有问题另当别论
悲观更新:即先给后端发信息,ok 了再更新前端,有问题就直接中止
区别就是假设大多数对后端的操作是成功还是失败,对于用户体验而言,当然是乐观更新更好,我们这里也选择乐观更新
delete 先看删除的操作如何实现,第一步是添加相应的按钮:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 <ul className="list-group " > {users.map ((user ) => ( <li className ="list-group-item d-flex justify-content-between" key ={user.id} > {user.name} <button className ="btn btn-outline-danger" onClick ={() => handleDelete(user)} > Delete </button > </li > ))} </ul>
注意这里的 CSS 类,它可以帮我们将按钮和列表内容推到两边,美观一些
下一步是写好相应的 handler:
1 2 3 4 5 6 7 8 9 10 const handleDelete = (user : User ) => { const originalUsers = [...users]; setUsers (users.filter ((u ) => u.id !== user.id )); axios .delete (`https://jsonplaceholder.typicode.com/users/${user.id} ` ) .catch ((err ) => { setError (err.message ); setUsers (originalUsers); }); };
注意这里先保存旧数据的操作:使用乐观更新时,如果后端更新失败,我们是需要将前端复原的
add 添加数据的方法如出一辙,设置一个按钮用来添加:
1 2 3 <button className="btn btn-primary mb-3" onClick={handleAdd}> Add </button>
本来这里应该用表单的,不过这里就省略了,我们硬编码一个给后端:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 const handleAdd = ( ) => { const originalUsers = [...users]; const newUser = { id : 0 , name : "New User" }; setUsers ([newUser, ...users]); axios .post ("https://jsonplaceholder.typicode.com/users" , newUser) .then ((res ) => { setUsers ([res.data , ...users]); }) .catch ((err ) => { setError (err.message ); setUsers (originalUsers); }); };
这时候使用的方法就是 post 了,把新的数据发上去,并在前端设置。
为什么需要设置两次?因为第一次设置的时候是乐观更新的设置,id 是我随便取的;第二次设置相当于把后端发来的真 id 给灌注进去了
为了方便可读性还可以这样:
1 2 3 .then (({ data: savedUser } ) => { setUsers ([savedUser, ...users]); })
update 最后是更新数据,我们这里同样使用硬编码来模拟(实际肯定是用表单的)
首先是按钮:
1 2 3 4 5 6 7 8 9 10 11 <div> <button className ="btn btn-outline-secondary mx-1" onClick ={() => handleUpdate(user)} > Update </button > <button className ="btn btn-outline-danger" onClick ={() => handleDelete(user)}> Delete </button > </div>
为了排版好看我们把它们放在了一个 div 里
下一步写好 handler:
1 2 3 4 5 6 7 8 9 10 11 const handleUpdate = (user : User ) => { const originalUsers = [...users]; const updatedUser = { ...user, name : user.name + "!" }; setUsers (users.map ((u ) => (u.id === user.id ? updatedUser : u))); axios .patch (`https://jsonplaceholder.typicode.com/users/${user.id} ` , updatedUser) .catch ((err ) => { setError (err.message ); setUsers (originalUsers); }); };
唯一不同的是对后端使用的方法:对于更新对象来说使用 put 和 patch 都是可以的,具体用什么根据后端决定,这里就直接用 patch
创建可重用 API Client 现在我们的代码有些冗杂了,我们可以将所有相关的逻辑提取到一个单独的文件中作为 client,这样我们的组件就不需要直接调用 axios,而是使用我们自己写的 client 去和后端通信。
这里就直接给出最后的代码:
首先是 api-client.ts,它内部封装了 axios 的初步逻辑
1 2 3 4 5 6 7 import axios, { CanceledError } from "axios" ;export default axios.create ({ baseURL : "https://jsonplaceholder.typicode.com" , });export { CanceledError };
其次是 user-service.ts,它内部利用 api-client 的操作实现了对这里 user 的所有操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 import apiClient from "./api-client" ;export interface User { id : number ; name : string ; }class UserService { getAllUsers ( ) { const controller = new AbortController (); const request = apiClient.get <User []>("/users" , { signal : controller.signal , }); return { request, cancel : () => controller.abort () }; } deleteUser (id : number ) { return apiClient.delete (`/users/${id} ` ); } createUser (user : User ) { return apiClient.post ("/users" , user); } updateUser (user : User ) { return apiClient.patch (`/users/${user.id} ` , user); } }export default new UserService ();
最后是我们的 UserList.tsx,我们看看重构后的代码是什么样子的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 import { useEffect, useState } from "react" ;import { CanceledError } from "../services/api-client" ;import userService, { User } from "../services/user-service" ;const UserList = ( ) => { const [users, setUsers] = useState<User []>([]); const [error, setError] = useState<string >("" ); const [isLoading, setIsLoading] = useState<boolean >(false ); useEffect (() => { const { request, cancel } = userService.getAllUsers (); setIsLoading (true ); request .then ((res ) => { setUsers (res.data ); setIsLoading (false ); }) .catch ((err ) => { if (err instanceof CanceledError ) return ; setError (err.message ); setIsLoading (false ); }); return () => cancel (); }, []); const handleDelete = (user : User ) => { const originalUsers = [...users]; setUsers (users.filter ((u ) => u.id !== user.id )); userService.deleteUser (user.id ).catch ((err ) => { setError (err.message ); setUsers (originalUsers); }); }; const handleAdd = ( ) => { const originalUsers = [...users]; const newUser = { id : 0 , name : "New User" }; setUsers ([newUser, ...users]); userService .createUser (newUser) .then (({ data: savedUser } ) => { setUsers ([savedUser, ...users]); }) .catch ((err ) => { setError (err.message ); setUsers (originalUsers); }); }; const handleUpdate = (user : User ) => { const originalUsers = [...users]; const updatedUser = { ...user, name : user.name + "!" }; setUsers (users.map ((u ) => (u.id === user.id ? updatedUser : u))); userService.updateUser (updatedUser).catch ((err ) => { setError (err.message ); setUsers (originalUsers); }); }; return ( ... ); };export default UserList ;
这样代码就清爽了许多,同时组件里不需要知道任何和后端相关的东西
创建通用 HTTP Service 进一步重构,现在我们的 Client 只适用于 user,如果我要 post 呢?如果我要 todo 呢?我们可以创建一个完全通用的 HTTP Service 来处理这些逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 import apiClient from "./api-client" ;interface Entity { id : number ; }class HttpService { endpoint : string ; constructor (endpoint : string ) { this .endpoint = endpoint; } getAll<T>() { const controller = new AbortController (); const request = apiClient.get <T[]>(this .endpoint , { signal : controller.signal , }); return { request, cancel : () => controller.abort () }; } delete (id : number ) { return apiClient.delete (`${this .endpoint} /${id} ` ); } create<T>(entity : T) { return apiClient.post (this .endpoint , entity); } update<T extends Entity >(entity : T) { return apiClient.patch (`${this .endpoint} /${entity.id} ` , entity); } }const create = (endpoint : string ) => new HttpService (endpoint);export default create;
这样在 user-service 里只需要处理好 user 的 interface 和导出这里的 HttpService 实例就行了
1 2 3 4 5 6 7 8 import create from "./http-service" ;export interface User { id : number ; name : string ; }export default create ("/users" );
最后不要忘了在组件中修改名称~
创建自定义 Hook 最后一部分是创建自定义的 hook,用来集成所有钩子相关的逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 import { CanceledError } from "axios" ;import { useState, useEffect } from "react" ;import userService, { User } from "../services/user-service" ;const useUsers = ( ) => { const [users, setUsers] = useState<User []>([]); const [error, setError] = useState<string >("" ); const [isLoading, setIsLoading] = useState<boolean >(false ); useEffect (() => { const { request, cancel } = userService.getAll <User >(); setIsLoading (true ); request .then ((res ) => { setUsers (res.data ); setIsLoading (false ); }) .catch ((err ) => { if (err instanceof CanceledError ) return ; setError (err.message ); setIsLoading (false ); }); return () => cancel (); }, []); return { users, error, isLoading, setError, setUsers }; };export default useUsers;
这样我们在组件里就会变得非常清爽,而且我们的钩子也可以在其他地方使用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 import userService, { User } from "../services/user-service" ;import useUsers from "../hooks/useUsers" ;const UserList = ( ) => { const { users, error, isLoading, setError, setUsers } = useUsers (); const handleDelete = (user : User ) => { const originalUsers = [...users]; setUsers (users.filter ((u ) => u.id !== user.id )); userService.delete (user.id ).catch ((err ) => { setError (err.message ); setUsers (originalUsers); }); }; const handleAdd = ( ) => { const originalUsers = [...users]; const newUser = { id : 0 , name : "New User" }; setUsers ([newUser, ...users]); userService .create (newUser) .then (({ data: savedUser } ) => { setUsers ([savedUser, ...users]); }) .catch ((err ) => { setError (err.message ); setUsers (originalUsers); }); }; const handleUpdate = (user : User ) => { const originalUsers = [...users]; const updatedUser = { ...user, name : user.name + "!" }; setUsers (users.map ((u ) => (u.id === user.id ? updatedUser : u))); userService.update (updatedUser).catch ((err ) => { setError (err.message ); setUsers (originalUsers); }); }; return ( <> {error && <div className ="alert alert-danger" > {error}</div > } {isLoading && <div className ="spinner-border" > </div > } <button className ="btn btn-primary mb-3" onClick ={handleAdd} > Add </button > <ul className ="list-group " > {users.map((user) => ( <li className ="list-group-item d-flex justify-content-between" key ={user.id} > {user.name} <div > <button className ="btn btn-outline-secondary mx-1" onClick ={() => handleUpdate(user)} > Update </button > <button className ="btn btn-outline-danger" onClick ={() => handleDelete(user)} > Delete </button > </div > </li > ))} </ul > </> ); };export default UserList ;
到这里后端通信相关的内容就结束啦,所有 React 初级教程的笔记也到此为止(不包含他的 gameHub 实践项目,那个是类似 project 的东西)
下一部分是 mosh 的中级 React 课程,我们将学习更多的库和更多的高级方法。