React是用于创建界面或前端的一个库,而应用程序还需要运行在服务器的后端支持,我们可以将后端理解成给前端提供动力的引擎,它提供业务逻辑、数据或安全验证等。
很多语言或框架可以做后端,比如:
Express.js、Django、Ruby on Rails、Spring、ASP.NET Core。
后端开发是另一个主题,如果想了解Django的话,可以查看本人之前写的《掌握Django》系列。作为React开发人员,需要知道如何连接后端。
理解Effect 回调钩子
在介绍连接后端之前,必须先了解Effect Hook。
本系列《管理组件状态》一文中,我们提到过React组件要求是纯的组件,即同样的输入,输出都是一致的,要做到这点,就要求将更改放在组件渲染阶段之外。
组件里是有些是不用返回任何jsx的更改代码的,比如:将数据存在本地缓存,调用服务器获取数据,手动修改DOM元素。这些更改的操作该放在何处呢?
这就要用到 Effect 钩子了!
利用Effect 钩子,我们可以在渲染完组件后执行一些代码片段。
看下面的例子。我们有一个Ref钩子 ref,与input关联,调用focus()设置焦点:…ref.current.focus(),这属于在组件内修改,组件就不纯了。我们可以使用Effect钩子useEffect 将这些代码包在里面,这样React就会在组件渲染完成后再执行这些代码。
使用Effect钩子,我们有机会执行一些操作,比如缓存到浏览器本地,获取数据库并保存,以及手动修改Dom元素等。
import { useEffect, useRef } from "react"; function App() { const ref = useRef<HTMLInputElement>(null); useEffect(() => { if (ref.current) ref.current.focus(); }); return ( <div> <input ref={ref} type="text" className="form-control" /> </div> ); } export default App;
使用Effect钩子有一些注意项:
像State钩子和 Ref钩子一样,我们只能在组件的顶部调用,不能在循环语句或If语句中使用。
可以多次调用Effect钩子以满足不同的用途。比如我们再增加一个Effect钩子用于修改标题。
... function App() { const ref = useRef<HTMLInputElement>(null); useEffect(() => { if (ref.current) ref.current.focus(); }); useEffect(() => { document.title = "My App"; }); ...
Effect 的依赖
前面介绍了Effect钩子里包含的函数默认每次在组件渲染完成时执行,我们可以修改控制函数的执行时机。看一个例子。
src–components下新建ProductList.tsx组件。
组件里有名为products的State,初始为空列表,这里我们调用useState时给定了<string[]>的类型。
假设需要从后端获取ProductList,获取之后再设置State,代码中我们简单在控制台输出一句表示在Fetching products,然后设置State,我们模拟获取到了2个产品。我们将这个操作用Effect钩子useEffect()包起来。
import React, { useEffect, useState } from "react"; const ProductList = () => { const [products, setProducts] = useState<string[]>([]); useEffect(() => { console.log("Fetching products"); setProducts(["Clothing", "Household"]); }); return <div>ProductList</div>; }; export default ProductList;
接着在App组件中调用ProductList,保存执行,检查网页控制台,发现有无限循环的错误。原因是:
组件渲染完成之后React执行Effect钩子,而Effect里面有一条setProduct语句会导致再次渲染,然后再执行Effect,再渲染,造成了无限循环。
解决这个问题是用Effect的依赖,useEffect()还有第2个可选参数,是个列表,列表元素可以是各个State或Props,表示Effect依赖这些State和Props的变化,有变动才执行Effect。
这里我们指供了空列表 [],表示不依赖任何State和Effect,这样Effect就只执行一次,不会再次执行造成无限循环。
... useEffect(() => { ... }, []); ...
再进一步,我们希望在App组件中添加下拉选项框,选中的category将作为Props传到ProductList展示。
App.tsx如下,没什么新鲜的东西。
... function App() { const [category, setCategory] = useState(""); return ( <div> <select className="form-control" onChange={(event) => setCategory(event.target.value)} > <option value=""></option> <option value="Clothing">Clothing</option> <option value="Household">Household</option> </select> <ProductList category={category} /> </div> ); } export default App;
接着修改ProductList.tsx。需要定义一个Props的interface,属性是category,这里我们就使用简写方式,直接写在组件参数里面,对于单行简单的Props都可以类似这样简化处理。
在useEffect()的第2个参数的列表项里,我们提供了Props的category,这样当category有变化时,就会执行该Effect里的代码。如果在这里不加category,下拉选项框变化时将不会执行。
... const ProductList = ({ category }: { category: string }) => { ... useEffect(() => { console.log("Fetching products in", category); ... }, [category]); ...
Effect 清理
Effect有时需要清理,比如一个聊天组件连接服务器,不展示时就要断开服务器的连接。
下面的代码,先定义两个函数connect和disconnect分别表示连接服务器和断开服务器,在Effect中,先调用connect连接服务器,然后做一些工作,最后可选返回一个函数,该函数里可以做些Effect清理工作,这里就是调用disconnect。
const connect = () => console.log("Connecting"); const disconnect = () => console.log("Disconnecting"); function App() { useEffect(() => { connect(); // 一些代码 return () => disconnect(); }); return <div></div>; } export default App;
一般而言,我们在Effect中做了什么就要停止或撤回什么。我们连接或订阅了什么,清理函数就应该断开或退订。或者,我们显示某个对话框,清理时就得隐藏掉。再或者我们在Effect中我们从服务器获取数据,清理函数应该中断数据的获取或忽略掉数据。
我们保存运行一下,在控制台能看到3条信息。
你知道为什么是3条信息吗?
前面我们说过在开发模式下,React是在Strict模式运行,该模式需要执行2次,第1次执行时运行了connect,由于要执行第2次,就要先unmount(卸载)组件再mount,unmount时就触发了清理代码,执行了disconnect。
获取数据
了解了Effect,现在开始介绍后端的连接。
我们需要一个模拟的后端。打开网址:
https://jsonplaceholder.typicode.com/
我们能看一些免费使用的访问端点。点击可以查看相应的数据。
有两个方法可以获取后端数据。
大多数现代浏览器都支持fetch方法,不过很多人可能更喜欢axios,本节也使用它。
第一步安装:
npm i axios@1.3.4
编辑App.tsx:
首先导入axios, 在useEffect中调用axios.get方法,获取数据并不是马上就能得到的,这是异步的过程,我们会得到一个Promise对象,该对象在异步调用完成后给到我们一个结果或失败,有一个then方法。在then方法中,我们可以对结果response(这里简写res)进行处理。特别注意useEffect的第2个参数我们设了空列表 [] ,这非常重要,否则就像前面介绍的那样出现无限循环。
我们可以查看一下response的结构,
有config,data,headers,request,status,statusText等属性,data的数据就是我们调用得到的数据,假设我们只关心id和name,可以定义一个User的interface,在axios.get<User[]>(“….”)… 时指定返回的数据结构为User[]。
返回的数据我们要用State保存,定义users的State,并且也指定类型:
… = useState<User[]>([]),初始为空列表。在axios.get返回Promise的then方法中,我们调用setUsers。
最后一步,我们数据展示在页面,用users.map方法列出名字。
... import axios from "axios"; interface User { id: number; name: string; } function App() { const [users, setUsers] = useState<User[]>([]); useEffect(() => { axios .get<User[]>("https://jsonplaceholder.typicode.com/users") .then((res) => setUsers(res.data)); }, []); return ( <ul> {users.map((user) => ( <li key={user.id}>{user.name}</li> ))} </ul> ); } export default App;
保存,效果如下:
理解HTTP请求
HTTP是一个协议,是构成WEB网站的基础。客户端浏览器发送请求,WEB服务返回响应。
检查网页,“网络”-筛选”Fetch/XHR”,其中XHR是XML HTTP request的缩写。
如图我们能看到请求包的大小,以及花费时间。
点击可以进一步查看详细。
任何一个请求和响应都至少有两部分Header和Body。Header是一些元数据,而Body则是提供或响应的数据。
预览标签下可以查看响应结果的漂亮的格式,而响应标签下则显示文本格式。
处理错误
HTTP请求过程中,因为网络及服务器等原因,可能会出现各种错误,React程序员需要捕捉处理。
axios.get方法得到一个Promise,它除了then方法,还有catch方法,就是用于提供处理错误的函数。我们故意设置一个错误的xusers端点,并添加error的State,在catch的箭头函数中调用setError。
再到jsx渲染页面中判断是否有error,有的话就显示错误信息。
import { useEffect, useState } from "react"; import axios from "axios"; interface User { id: number; name: string; } function App() { const [users, setUsers] = useState<User[]>([]); const [error, setError] = useState(""); useEffect(() => { axios .get<User[]>("https://jsonplaceholder.typicode.com/xusers") .then((res) => setUsers(res.data)) .catch((err) => setError(err.message)); }, []); return ( <> {error && <p className="text-danger">{error}</p>} <ul> {users.map((user) => ( <li key={user.id}>{user.name}</li> ))} </ul> </> ); } export default App;
效果如图。
使用Async和Await
Axios.get方法得到一个Promise,promise要么得到一个正确的响应res,要么是被拒绝了而得到错误err,相应地我们可以用then和catch来处理,非常简洁。
get –> promise –> res / err
有些人可能不喜欢用Promise,不习惯用then和catch,这里就来介绍另外一种方式,Async和Await。
在axios.get前加上await,会在Promise之前拦截处理,这样可以返回结果res,使用await的函数需要加上async,由于不能直接加Effect上,这里添加一个函数fetchUsers。
fetchUsers函数里,await需要手动捕捉错误,得加上try…catch结构,在catch类型这里,指定类型为AxiosError。
最后还要明确指示执行fetchUsers。
import axios, { AxiosError } from "axios"; ...... useEffect(() => { const fetchUsers = async () => { try { const res = await axios.get<User[]>( "https://jsonplaceholder.typicode.com/xusers" ); setUsers(res.data); } catch (err) { setError((err as AxiosError).message); } }; fetchUsers(); }, []); ......
await 和 async已经存在了很长时间了,非常可靠,但与Promise相比,代码明显更长,结构也更丑陋,建议用Promise的then、catch处理。当然你如果就是喜欢用await和async,那也完全没有问题。
取消获取数据请求
想象一下这个场景,组件正从后端获取数据,但用户已导航到了其他页面,这时我们就不必等到数据获取完毕并进行渲染,而是应该取消这个获取过程。作为最佳实践,我们在Effect中进行数据获取时,需返回一个Effect清理函数。
修改下代码,先创建一个AbortController对象,该对象用于终止异步操作,异步操作主要是指类似获取后端数据需要花较长时间的操作。再在axios.get方法添加第2个参数,这里提供键值 为signal的对象为前面建的AboutController的signal:
{signal:controller.signal}
再在Effect返回时设置箭头函数,中断该controller指示(signal)的操作:
()=>controller.abort()
取消数据请求也会被捕捉到CanceledError类型的错误,需要判断并忽略掉。
...... 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); }); return () => controller.abort(); }, []); ......
保存并检查网页,在网络标签下,我们能看到取消的请求。
开发模式下,React启用了strict模式运行2次组件,第2次运行相当于导航到其他页面,这样我们就能看到取消的信息,使用这种Effect清除方式,我们在strict模式下也不必担心会执行2次从后端获取数据的操作。
显示加载指示
在获取数据时,需要给出加载图标,提示用户耐心等待。
添加isLoading的State,默认是false,在准备执行axios.get之时设置为false,然后在then和catch中分别设置回false,这稍微有些重复,按理只需在finally中执行就行,比如 .finally(()=>setLoading(false)),但在strict模式中,仅在finally中执行不能正确工作,这里我们就分别在then和catch中执行。
接着在页面上展示出来,使用了bootstrap的 spinner-border。代码如下。
...... function App() { ...... const [isLoading, setLoading] = useState(false); useEffect(() => { ...... setLoading(true); axios .get<User[]>(......) .then((res) => { ...... setLoading(false); }) .catch((err) => { ...... setLoading(false); }); ...... }, []); return ( ...... {isLoading && <div className="spinner-border"></div>} ......
由于数据量不大,执行速度很快,我们没机会看到加载图标,可以模拟慢速网络进行查看。
删除数据
为了删除记录,我们添加删除按钮。按钮的使用btn-outline-danger。为了排版美观,在ul里使用 list-group,每项 li 使用 list-group-item,这些都是比较常用的bootstrap的css。为了名字靠左侧,而按钮靠右侧,使用 d-flex 和 justify-content-between,d-flext表示变成flex弹性容器,而justify-content-between 表示以合适的间距排版,这里2个元素,就是一个靠左一个靠右,这也是常用的bootstrap的css,要学会使用。
...... function App() { ...... const deleteUser = (user: User) => { const originalUsers = [...users]; setUsers(users.filter((u) => u.id !== user.id)); axios .delete("https://jsonplaceholder.typicode.com/xusers/" + user.id) .catch((err) => { setError(err.message); setUsers(originalUsers); }); }; return ( <> ...... <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={() => deleteUser(user)} > Delete </button> </li> ))} </ul> </> ); } export default App;
接着处理删除事件,一般有两种方式:乐观更新和悲观更新。乐观更新假设后端连接处理会成功,先更新组件,再连后端服务器;悲观方式则假设服务器处理会失败,先连接后端服务器处理,成功后再更新组件。
乐观更新用户体验更好,因为反馈是即时的,而悲观更新则稍有些延迟。一般而言,在可能的情况下尽量使用乐观更新方式。
上面我们的代码就是采用乐观更新方式,点删除按钮时,先更新页面setUsers(),再调用服务器 axios.delete()。delete()方法删除成功后不需要做什么,所以then方法就不调用,只捕捉错误用catch。
在catch中做两件事情,一件是显示错误信息,另一件则是恢复原先的页面,所以事先用originalUsers保存了原来的users。
删除出错的效果如下。
创建数据
为在列表上添加按钮Add,使用btn-primary和mb-3,mb-3是为了间隔美观,处理函数是addUser,也使用乐观更新方式先更新组件后调用服务器。
一般而言,创建数据需要一个form,但这会分散本节要介绍内容的注意力,所以就直接硬编码一个newUser,而不是从form获取。
创建数据使用 axios.post 方法并携带 数据 newUser,Promise返回调用then,这里我们直接解构了res的data并重命名为savedUser,这样代码可读性更强。
同样的,调用失败也恢复原有的State,这与前面删除的操作类似,不再赘述。
...... function App() { ...... const addUser = () => { const originalUser = [...users]; const newUser = { id: 0, name: "kelemi" }; setUsers([newUser, ...users]); axios .post("https://jsonplaceholder.typicode.com/xusers/", newUser) .then(({ data: savedUser }) => setUsers([savedUser, ...users])) .catch((err) => { setError(err.message); setUsers(originalUser); }); }; return ( <> ...... <button className="btn btn-primary mb-3" onClick={addUser}> Add </button> ...... </> ); } export default App;
效果如下:
更新数据
在每个User行中,添加Update按钮,为了不使排版混乱,需要将 Update按钮和Delete按钮包在一个div里,添加Update按钮的 mx-1 是为了设置两个按钮间的水平间隔为1。
接着处理更新操作,为了不分心,也没用form直接硬编码在原有name上添加”!”。更新有两个方法:patch 和 put。put是更新整个对象,而patch是只更新1个或多个属性,这也要看后端服务器的支持,有些后端很可能只支持 put 而不支持 patch,这里我们使用 patch。
patch操作使用乐观更新方式,与前面类似。
...... function App() { ...... const updateUser = (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/xusers/" + user.id, updatedUser ) .catch((err) => { setError(err.message); setUsers(originalUsers); }); }; return ( <> ...... <ul className="list-group"> {users.map((user) => ( <li ......> {user.name} <div> <button className="btn btn-outline-secondary mx-1" onClick={() => updateUser(user)} > Update </button> <button ...... > Delete </button> </div> </li> ))} </ul> </> ); } export default App;
如下:
提取可重用的apiClient
目前我们的App.tsx稍有点规模了,我们来优化下。
首先我们看到,每次调用axios时都需要写完整的API端点 ,这显然是重复的,我们来提取优化。
在src文件夹下新建文件夹services,再在其下新建 api-client.ts。
将axios访问后端服务器端点相关的都放在该模块下,axios.create方法创建API访问端点的基础信息,我们设置baseURL,实际的生产环境中可能需要设置headers,比如有api-key信息等,这里暂且不设。并默认导出 export default。
另外导出在APP组件中使用到的axios的 CanceledError。
import axios,{CanceledError} from "axios"; export default axios.create({ baseURL:'https://jsonplaceholder.typicode.com', headers:{ 'api-key':'...' } }) export {CanceledError};
再回App.tsc,删除导入axios 的语句。添加导入api-client。
将原先使用axios的地方,比如axios.get改成apiClient.get,且端点信息不用写全,因为baseURL已在apiClient中设置了,只需写后面的部分如“/users/” 即可。
...... import apiClient, { CanceledError } from "./services/api-client"; ...... apiClient .get<User[]>("/users/",......) ......
这样修改后,代码就简洁了一点。
提取用户服务
再看App.tsx组件,我们发现它太了解Http细节了,比如AbortController这完全是Http的东西,它也了解各个API端点细节,以及get、post、patch、delete等操作方法,这个其实不应该是App组件关心的细节,它只需关心与用户的交互即可。在其他场景下,也可能用到这个users信息,更好的方式是将用户服务提取出来。
src/services下新建文件 user-service.ts,先将获取用户数据提取到该文件,函数名为getAllUsers,我们将原在App.tsx里Effect里的逻辑移到这里,由于用到User的数据结构,也将该interface移到这里并export以便App组件其他逻辑用。
AbortController完全属于HTTP层面的事情,我们也移到这里。getAllUsers返回request和cancel,request是一个Promise,调用它的程序可以继续用then或catch等,cancel用于清除Effect。
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, cancle:() => controller.abort()} } } export default new UserService();
回到App.tsx,修改useEffect部分。
...... useEffect(() => { setLoading(true); const { request, cancle } = userService.getAllUsers(); request .then((res) => { setUsers(res.data); setLoading(false); }) .catch((err) => { if (err instanceof CanceledError) return; setError(err.message); setLoading(false); }); return () => cancle(); }, []); ......
其他的deleteUser, createUser, updateUser也作类似重构,不详述了,user-service.ts如下。
...... class UserService{ ...... 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) } } ......
App.tsx调用userService的相应方法即可,这样就消除了在App组件中HTTP细节处理的代码,最后App组件也不再依赖 apiClient,可以取消导入。
创建通用的HTTP服务
我们已创建了useService服务,这使我们的App完全跟Http实现细节脱离,这很好。但还有些问题,比如后续我们需要实现 /posts 端点,类似地它也有获取所有posts、新增、删除、更新等操作,唯一与 /users 不同的就是API端点的不同。本节我们借助Typescript的魔法,实现一个通用的HTTP服务。
src/services文件夹下新建 http-service.ts文件。
先将user-service.ts文件的内容全部复制到http-service.ts里,然后逐行进行修改。
先删除 interface User,因为这是特定的对User的使用,在通用实现里要么删除要么改成通用化。
按F2将类UserService改名为HttpService,再将getAllUsers改名为getAll这样通用的名字。getAll里用到 User这个数据结构,我们将其改成T,函数也变成 getAll<T>,T 相当于占位符,这样可以由调用者提供结构。
数据结构解决了,还有一个就是端点名,我们可以让调用者在调用方法时提供端点名,类似 httpService.getAll(‘/users’),但这稍点麻类烦,更好的方法是使用构造函数,在创建类的实例时提供。这里设置endpoint属性,构造函数 constructor获取该字符串参数并设置endpoint。
同样的,deleteUser, createUser也改成 delete, create,也利用 <T> 占位符代表数据结构。
update稍有些不同,因为它有2个参数,第1个是实体的id,而实体不一定有id属性,所以要限制数据实体必须有 id ,创建Entity的interface,定义一个属性id,update方法的里点位符 <T> 继承于 Entity。
最后是导出类的实例给其他模块调用,自然不能硬编码写明端点,我们创建一个箭头函数 create,该函数需要有一个endpoint参数,接着export default create 。
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, cancle:() => 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.ts,保留原定义的User的数据结构,调用create函数并提供端点名称 “/users”,其他代码均删除,然后导出create即可。另外apiClient已用不到了,不用再导入。
import create from "./http-service" export interface User { id: number; name: string; } export default create('/users');
回到App.tsx,修改userService的方法比如deleteUser改delete,以及在userService.getAll<User>() 提供数据结构 <User>即可。这里不再详列。
按这种方法提取后,后续需要处理 posts 等其他API端点时,也只需提供数据结构和端点字符串即可,可重用性非常强。
创建自定义的数据获取钩子
目前我们的组件很不错,不过也有问题。假设我们要创建另外一个组件也需要获取users信息,我们要创建 users, error, isLoading三个State钩子,在获取数据时也需要用到Effect钩子,显然这些代码与App组件的代码重复了。
钩子(Hook)其实就是一些功能性逻辑,我们可以将它放在自定义钩子或自定义函数里,这样就可以在各个组件间重用。看看我们怎么做?
src文件夹下新建hooks文件夹,再在hooks下新建 useUsers.ts文件,按照惯例,Hook 都以use开头,像我们学过的useState, useEffect, useRef一样,这是个普通typescript文件,以ts结尾。
我们将App组件的相关Hook全移到useUsers中,并导入必要的模块,可以在vscode错误提示处按 【ctrl+.】 自动导入相关模块。在uerUsers最后return 相关 Hook, 比如users, error, isLoading, setUsers, setError等。
import { useEffect, useState } from "react"; import userService, { User } from "../services/user-service"; import { CanceledError } from "../services/api-client"; const useUsers = () =>{ const [users, setUsers] = useState<User[]>([]); const [error, setError] = useState(""); const [isLoading, setLoading] = useState(false); useEffect(() => { setLoading(true); const { request, cancle } = userService.getAll<User>(); request .then((res) => { setUsers(res.data); setLoading(false); }) .catch((err) => { if (err instanceof CanceledError) return; setError(err.message); setLoading(false); }); return () => cancle(); }, []); return {users, error, isLoading, setUsers, setError} } export default useUsers;
回到App组件,使用useUsers钩子并解构相应Hook使用即可。
...... function App() { const { users, error, isLoading, setUsers, setError } = useUsers(); ......
其他组件需要用到Users钩子时,也可按这个方法使用。
小结
本文详细介绍了React连接后端的相关知识。下一篇开始将分几篇从头开始创建一个游戏网站项目。