八、处理数据
在 JavaScript 中,最常使用 fetch 发起 HTTP 请求。
fetch(`https://api.github.com/users/moonhighway`) .then(response => response.json()) .then(console.log) .catch(console.error);
- fetch 函数返回一个 promise。
- 这里,我们向指定的 URL 发起一个异步请求。
- 传送完毕后,信息通过 .then(callback) 方法传给一个回调。
- GitHub API 响应的数据是 JSON 格式,而且包含中 HTTP 响应的主体中,因此我们调用 response.json() 获取数据,把数据解析为 JSON 格式。
- 获取响应信息后,把数据输出到控制台中。
- 如果有什么地方出错了,把错误传给 console.error 方法。
promise 还可以使用 async/await 处理。由于 fetch 返回一个 promise,因此我们可以在 async 函数中异步等待(await)请求:
async function requestGithubUser(githubLogin) { try { const response = await fetch( `https://api.github.com/users/${githubLogin}` ); const userData = await response.json(); console.log(userData); } catch (error) { console.error(error); } } requestGithubUser("moonhighway");
- 这与前面串接 .then 函数处理请求的效果是完全一样的。
- 在异步等待一个 promise 时,下一行代码直到 promise 得到解决后才执行。
- 以这种方式在代码中处理 promise 比较简洁。
一般来说,创建数据使用 POST 请求,修改数据使用 PUT 请求。fetch 函数的第二个参数接收一个选项对象,供 fetch 创建 HTTP 请求。
fetch("/create/user", { method: "POST", body: JSON.stringify({ username, password, bio }) })
- JSON.stringify 是 JavaScript 中的一个函数,用于将一个 JavaScript 对象或值转换成一个 JSON 格式的字符串。
- 这个请求使用 POST 方法创建一个新用户。username、password 和用户的 bio 以字符串的形式在请求的 body 中发送。
上传文件需要使用一种不同的 HTTP 请求:multipart-formdata 请求。这种请求告诉服务器,请求的主体中有一个或多个文件。在 JavaScript 中发起这种请求,只需在请求的主体中传送一个 FormData 对象。
const formData = new FormData(); formData.append("username", "xx"); formData.append("fullname", "yy"); formData.append("avatar", imgFile); fetch("/create/user", { method: "POST", body: formData })
- 通过一个 formData 对象随请求一起传送 username、fullname 和 avatar 图像。
有时,我们要获得授权才能发起请求。通常,授权意味着获取个人或敏感数据。而且基本上要求用户通过 POST、PUT 或 DELETE 请求在服务器上执行一定的操作。
一般情况下,我们在每个请求中添加一个唯一的令牌(token),供服务识别用户的身份。这个令牌通常在 Authorization 首部中添加。
fetch(`https://api.github.com/users/${login}`, { method: "POST", headers: { Authorization: `Bearer ${token}` } });
- 令牌通常在用户使用用户名和密码登录服务后获取。此外,也可以使用开放标准协议 OAuth 从第三方(例如 GitHub 或 Facebook)获取。
GitHub 可为你生成一个个人用户令牌。首先登录 GitHub,依次进入 Settings -> Developer Settings -> Personal Access Tokens。然后创建具有特定读写规则的令牌,最后使用令牌从 GitHub API 中获取个人信息。随 fetch 请求一起发送 Personal Access Tokens,GitHub 将提供更多有关账户的隐私信息。
在 React 组件中获取数据要合理使用 useState 和 useEffect 钩子。前者的作用是把响应存储在状态中,后者的作用是发起 fetch 请求。
import React, { useState, useEffect } from "react"; function GitHubUser({ login }) { const [data, setData] = useState(); useEffect(() => { if (!login) return; fetch(`https://api.github.com/users/${login}`) .then(response => response.json()) .then(setData) .catch(console.error); }, [login]); if (data) return <pre>{JSON.stringify(data, null, 2)}</pre>; return null; } export default function App() { return <GitHubUser login="moonhighway" />; }
- 首次渲染时,由于 data 的初始值是 null,所以组件返回 null。组件返回 null 的意思是告诉 React,什么也不渲染。这样做没有问题,只是会看到一个黑屏。
- JSON.stringify 方法接受三个参数:要转换成字符串的 JSON 数据,用于替换 JSON 对象中属性的函数,以及格式化数据时使用的空格数量。
- 这里,我们把替换函数设为 null,即不做任何替换。2 表示格式化代码时使用的空格数量,即以两个空格缩进 JSON 字符串。
- 使用 pre 元素的目的是保留空白,确保最终渲染出来的 JSON 字符串具有良好的阅读性。
我们可以使用 Web Storage API 把数据存储在本地浏览器中。保存数据可以使用 window.localStorage 或 window.sessionStorage 对象。sessionStorage API 只把数据保存到用户会话中,关闭标签页或重启浏览器,保存的数据都将清空。而 localStorage 将无限期保存数据,除非主动删除。
JSON 数据应该以字符串的形式保存到浏览器存储空间中。这意味着,保存时要把对象转换成 JSON 对象,而在加载时则要把字符串解析为 JSON。下面是可用于保存和加载 JSON 数据的函数:
const loadJSON = key => key && JSON.parse(localStorage.getItem(key)); const saveJSON = (key, data) => localStorage.setItem(key, JSON.stringify(data));
- 如果没有数据,loadJSON 函数返回 null。
- 从 Web Storage 中加载数据、把数据保存到 Web Storage 中、把数据转换成字符串,以及解析 JSON 字符串,统统都是异步任务。loadJSON 和 saveJSON 都是异步函数。因此请注意,如果频繁调用这两个函数处理大量数据,可能会导致性能问题。为了性能,通常最好限制这两个函数的使用频率。
例子:
import React, { useState, useEffect } from "react"; const loadJSON = key => key && JSON.parse(localStorage.getItem(key)); const saveJSON = (key, data) => localStorage.setItem(key, JSON.stringify(data)); function GitHubUser({ login }) { const [data, setData] = useState(loadJSON(`user:${login}`)); useEffect(() => { if (!data) return; if (data.login === login) return; const { name, avatar_url, location } = data; saveJSON(`user:${login}`, { name, login, avatar_url, location }); }, [data]); useEffect(() => { if (!login) return; if (data && data.login === login) return; fetch(`https://api.github.com/users/${login}`) .then(response => response.json()) .then(setData) .catch(console.error); }, [login]); if (data) return <pre>{JSON.stringify(data, null, 2)}</pre>; return null; } export default function App() { return <GitHubUser login="moonhighway" />; }
- loadJSON 是异步函数,因此调用 useState 时可以使用它设定状态数据的初始值。
清空存储空间:
localStorage.clear();
sessionStorage 和 localStorage 均是 Web 开发者的得力武器。
- 离线时,我们可以使用本地数据,减少网络请求数,从而提升应用的性能。然而,一定要合理使用。实现离线存储增加了应用的复杂度,而且在开发的过程中很难处理。
- 另外,请勿使用 Web Storage 缓存数据。如果发现性能有波动,可以试着让 HTTP 处理缓存。添加 Cache-Control: max-age=<EXP_DATE> 首部后,浏览器将自动缓存内容。EXP_DATE 定义内容的失效日期。
HTTP 请求和 promise 均有三种状态:待定、成功(完成)和失败(被拒)。发送请求后等待响应期间,请求处于待定状态。响应只有两种可能,成功或失败。成功的响应表示顺利连接上服务器,收到了数据。对 promise 来说,成功的响应表示 promise 得到解决了。倘若在请求的过程中有什么地方出错了,那就可以说 HTTP 请求失败,或者 promise 被拒了。在这两种情况下,我们将收到一个 error 对象,说明具体情况。
发起 HTTP 请求时,这三种状态都要处理。
import { useState, useEffect } from "react"; function GitHubUser({ login }) { const [data, setData] = useState(); const [error, setError] = useState(); const [loading, setLoading] = useState(false); useEffect(() => { if (!login) return; setLoading(true); fetch(`https://api.github.com/users/${login}`) .then((data) => data.json()) .then(setData) .then(() => setLoading(false)) .catch(setError); }, [login]); if (loading) return <h1>loading...</h1>; if (error) return <pre>{JSON.stringify(error, null, 2)}</pre>; if (!data) return null; return ( <div className="githubUser"> <img src={data.avatar_url} alt={data.login} style={{ width: 200 }} /> <div> <h1>{data.login}</h1> {data.name && <p>{data.name}</p>} {data.location && <p>{data.location}</p>} </div> </div> ); } export default function App() { return <GitHubUser login="moonhighway" />; }
- 请求处理待定状态时,我们可以显示一个“loading…”消息;如果出错了,那就渲染 error 对象中的详情。
- 在生产环境中,我们可以进一步处理错误,比如说把跟踪到的错误记录到日志中,或者尝试再次发起请求。在开发环境中,只渲染错误详情没什么问题,反而能让开发者立即得到反馈。
- 有些服务器会在成功的响应中发送额外的失败消息。
在异步组件中,为了最大程度提升可重用性,经常利用渲染属性模式。采用这种模式创建组件,可以把开发应用所需的复杂机制或单调的样板代码抽离出来。
import React from "react"; const tahoe_peaks = [ { name: "Freel Peak", elevation: 10891 }, { name: "Monument Peak", elevation: 10067 }, { name: "Pyramid Peak", elevation: 9983 }, { name: "Mt. Tallac", elevation: 9735 } ]; function List({ data = [], renderItem, renderEmpty }) { return !data.length ? ( renderEmpty ) : ( <ul> {data.map((item, i) => ( <li key={i}>{renderItem(item)}</li> ))} </ul> ); } export default function App() { return ( <List data={tahoe_peaks} renderEmpty={<p>This list is empty</p>} renderItem={item => ( <> {item.name} - {item.elevation.toLocaleString()} </> )} /> ); }
生产环境中的应用通常要渲染大量数据,但是又不能一次性全部渲染。浏览器的渲染能力是有限的。渲染要耗费时间、占据处理能力及消耗内存,这三项都是有限制的。
在用户滚动界面的过程中,我们要消除用户已经看过的结果,并渲染位于屏幕范围以外的新结果,随时准备展示。这种解决方案一次只渲染11个元素,其他数据排队等候,后面再渲染。这种技术称为虚拟化。使用这种技术滚动特大型列表,数据量无穷尽时,浏览器也不会崩溃。
构建虚拟化列表组件要考虑很多事情。幸好,我们不用从头开始动手,社区已经开发了很多虚拟化列表组件,直接拿来使用即可。对浏览器渲染来说,最受欢迎的是 react-window 和 react-virtualized。虚拟化列表十分重要,React Native 甚至自带了一个这样的组件,即 FlatList。多数时候,我们无须自己动手构建虚拟化列表组件,知道如何使用就可以了。
为了实现虚拟化列表,我们需要大量数据。这里指的是大量虚拟数据。
npm i faker
- 安装 faker 之后,我们可以创建一组大型的虚拟数据。
import React from "react"; import faker from "faker"; const bigList = [...Array(5000)].map(() => ({ name: faker.name.findName(), email: faker.internet.email(), avatar: faker.internet.avatar() })); function List({ data = [], renderItem, renderEmpty }) { return !data.length ? ( renderEmpty ) : ( <ul> {data.map((item, i) => ( <li key={i}>{renderItem(item)}</li> ))} </ul> ); } export default function App() { const renderItem = item => ( <div style={{ display: "flex" }}> <img src={item.avatar} alt={item.name} width={50} /> <p> {item.name} - {item.email} </p> </div> ); return <List data={bigList} renderItem={renderItem} />; }
- 我们映射一个有五千个空值的数组,把空值替换成虚拟用户的信息,创建 bigList 变量。
- 安装 faker 之后,我们可以创建一组大型的虚拟数据。
下面使用 react-window 渲染这个虚拟用户列表:
npm -i react-window
- react-window 库提供了多个用于渲染虚拟列表的组件。
- 下面的示例中,我们使用 react-window 提供的 FixedSizeList 组件:
import React from "react"; import { FixedSizeList } from "react-window"; import faker from "faker"; const bigList = [...Array(5000)].map(() => ({ name: faker.name.findName(), email: faker.internet.email(), avatar: faker.internet.avatar() })); export default function App() { const renderRow = ({ index, style }) => ( <div style={{ ...style, ...{ display: "flex" } }}> <img src={bigList[index].avatar} alt={bigList[index].name} width={50} /> <p> {bigList[index].name} - {bigList[index].email} </p> </div> ); return ( <FixedSizeList height={window.innerHeight} width={window.innerWidth - 20} itemCount={bigList.length} itemSize={50} > {renderRow} </FixedSizeList> ); }
- 通过 itemSize 属性指定每一行占据的像素数。
- 渲染属性通过 children 属性传给 FixedSizeList。这种渲染属性模式时常用到。
一个请求有三种状态:待定、成功或失败。为了重用 fetch 请求的这个逻辑,我们可以自定义一个钩子。在整个应用中,只要想发起 fetch 请求,就可以在组件中使用这个钩子。下面创建 useFetch 钩子:
import React, { useState, useEffect } from "react"; function useFetch(uri) { const [data, setData] = useState(); const [error, setError] = useState(); const [loading, setLoading] = useState(true); useEffect(() => { if (!uri) return; fetch(uri) .then(data => data.json()) .then(setData) .then(() => setLoading(false)) .catch(setError); }, [uri]); return { loading, data, error }; }
钩子最大的作用是跨组件重用的功能。有时,在不同的组件中需要重复渲染同样的内容。一个应用中,fetch 请求的错误处理方式或许也应该保持一致。下面创建一个 Fetch 组件:
export default function Fetch({ uri, renderSuccess, loadingFallback = <p>loading...</p>, renderError = error => <pre>{JSON.stringify(error, null, 2)}</pre> }) { const { loading, data, error } = useFetch(uri); if (loading) return loadingFallback; if (error) return renderError(error); if (data) return renderSuccess({ data }); } // 如何使用 Fetch 组件如下: function GitHubUser({ login }) { return ( <Fetch uri={`https://api.github.com/users/${login}`} renderSuccess={UserDetails} /> ); } function UserDetails({ data }) { return ( <div className="githubUser"> <img src={data.avatar_url} alt={data.login} style={{ width: 200 }} /> <div> <h1>{data.login}</h1> {data.name && <p>{data.name}</p>} {data.location && <p>{data.location}</p>} </div> <UserRepositories login={data.login} onSelect={(repoName) => console.log(`${repoName} selected`)} /> </div> ); }
- 自定义钩子 useFetch 是一层抽象,抽离了发起 fetch 请求的机制。Fetch 组件又是一层抽象,抽离了处理渲染什么的机制。
下面创建一个名为 useIterator 的自定义钩子,迭代任意类型的对象数组:
export const useIterator = (items = [], initialValue = 0) => { const [i, setIndex] = useState(initialValue); const prev = useCallback(() => { if (i === 0) return setIndex(items.length - 1); setIndex(i - 1); }, [i]); const next = useCallback(() => { if (i === items.length - 1) return setIndex(0); setIndex(i + 1); }, [i]); const item = useMemo(() => items[i], [i]); return [item || items[0], prev, next]; };
- 使用这个钩子可以遍历任何数组。由于这个钩子返回数组中的元素,因此我们可以利用数组析构为值提供有意义的名称:
const [letter, previous, next] = useIterator([ "a", "b", "c" ])
- 这里,letter 的初始值是“a”。如果调用 next,组件将重新渲染,letter 的值变成“b”。再调用两次next,letter 的值又变成“a”,因为这个迭代器不会让 index 超出界限,绕了一圈又回到了数组的第一个元素。
- 这里,prev 和 next 都使用 useCallback 钩子创建。这样可以确保 prev 函数始终保持不变,除非 i 的值发生变化。同样,item 的值也始终指向同一个元素对象,除非 i 的值发生变化。
- 备忘这些值不会大幅提升性能,至少不足以让我们下决心增加代码的复杂度。然而,使用 useIterator 钩子时,备忘的值始终指向相同的对象和函数,这样方便比较值或者在依赖数组中使用。
- 使用这个钩子可以遍历任何数组。由于这个钩子返回数组中的元素,因此我们可以利用数组析构为值提供有意义的名称:
下面例子中,我们使用 useIterator 钩子,让用户遍览仓库列表:
import React from "react"; import { useIterator } from "./hooks"; import RepositoryReadme from "./RepositoryReadme"; export default function RepoMenu({ repositories, login }) { const [{ name }, previous, next] = useIterator(repositories); return ( <> <div style={{ display: "flex" }}> <button onClick={previous}><</button> <p>{name}</p> <button onClick={next}>></button> </div> <RepositoryReadme login={login} repo={name} /> </> ); }
<
是小于号的实体,显示一个小于号“<”。- 注意,使用数组析构可以为数组元素任意命名。即使在钩子中我们把函数命名为 prev 和 next,但是在使用钩子时,我们可以修改名称,改为 previous 和 next。
- RepositoryReadme 组件在下面第 28 点有说明。
瀑布式请求:一个接着一个地发起请求,前后请求之后有依赖关系,如果前一个请求出错了,后一个请求就不会再发起。
仓库的 README 文件是使用 Markdown 编写,这是一种文本格式,可使用 ReactMarkdown 组件渲染为 HTML。安装 react-markdown 包:
npm i react-markdown
请求仓库的 README 文件内容也涉及瀑布式请求。首先,要向仓库 README 文件的路由发起数据请求。GitHub 对这个路由的响应是关于仓库 README 文件的详情,而不是文件内容。不过,响应中有个 download_url,我们可以通过它请求 README 文件的内容。可见,为了获取 Markdown 内容,要多发起一次请求。这里两个请求可以在同一个异步函数中发起:
import React, { useState, useEffect, useCallback } from "react"; import ReactMarkdown from "react-markdown"; export default function RepositoryReadme({ repo, login }) { const [loading, setLoading] = useState(false); const [error, setError] = useState(); const [markdown, setMarkdown] = useState(""); const loadReadme = useCallback(async (login, repo) => { setLoading(true); const uri = `https://api.github.com/repos/${login}/${repo}/readme`; const { download_url } = await fetch(uri).then(res => res.json()); const markdown = await fetch(download_url).then(res => res.text()); setMarkdown(markdown); setLoading(false); }, []); useEffect(() => { if (!repo || !login) return; loadReadme(login, repo).catch(setError); }, [repo]); if (error) return <pre>{JSON.stringify(error, null, 2)}</pre>; if (loading) return <p>Loading...</p>; return <ReactMarkdown source={markdown} />; }
- 使用 useCallback 钩子把 loadReadme 函数添加到组件中,在首次渲染组件时备忘该函数。
所有请求都可在开发者工具的“Network”标签页中查看。在这个标签页中,你可以查看每一个请求,还可以限制网络速度,考察请求在较慢的网速下表现如何。
在 Google Chrome 中如果想限制网络速度,单击“Online”旁边的箭头,在打开的菜单中,你可以选择不同的速度,选择“Fast 3G”和“Slow 3G”对网络请求的速度限制较大。另外,“Network”标签页还显示有全部 HTTP 请求的时间线。你可以筛选时间线,只查看“XHR”请求,即只显示 fetch 发出的请求。
- 注意,加载图的标题是“Waterfall”。(读者笔记:除了名称、状态、类型、启动器、大小、时间之外,还可以像 Windows 任务管理器一样,勾选瀑布等其他列)
有时候,可以一次发送全部请求,提升应用的速度。这种情况下,不再像瀑布式那样一个接一个发起请求,而是并行(或同时)发起所有请求。前面的例子之所以发送瀑布式请求,是因为组件是一个套一个渲染的,前一个组件未渲染之前不能发起下一个请求。如果在同一级渲染这三个组件,所有请求同时并行发起。
我们并不能始终猜测出一开始要渲染什么数据。遇到这种情况,我们干脆不渲染组件,等到获得所需的数据再渲染。
export default function App() { const [login, setLogin] = useState(); const [repo, setRepo] = useState(); return ( <> <SearchForm value={login} onSearch={setLogin} /> {login && <GitHubUser login={login} />} {login && ( <UserRepositoties login={login} repo={repo} onSelect={setRepo} /> )} {login && repo && ( <RepositoryReadme login={login} repo={repo} /> )} </SearchForm> ); }
- 这里,在所需的属性获得值之前,不渲染对应的组件。
当 fetch 请求返回响应时,如果组件已经卸载完毕,试图修改已被卸载的组件的状态值会在控制台报错。
- 只要用户在较慢的网速下加载数据,就有可能遇到这个错误。
- 其实我们可以做些防护措施。首先,我们可以创建一个钩子,获知当前的组件有没有挂载:
export function useMountedRef() { const mounted = useRef(false); useEffect(() => { mounted.current = true; return () => (mounted.current = false); }); return mounted; }
- 如果组件被卸载了,状态虽被清除了,但是 ref 还在。
- 上面的 useEffect 没有依赖数组,每一次渲染组件都会调用,确保 ref 的值 true。只要卸载了组件,调用 useEffect 导致函数返回,ref 的值变成 false。
- 使用例子:
const mounted = useMountedRef(); const loadReadme = useCallback(async (login, repo) => { setLoading(true); const uri = `https://api.github.com/repos/${login}/${repo}/readme`; const { download_url } = await fetch(uri).then(res => res.json()); const markdown = await fetch(download_url).then(res => res.text()); if (mounted.current) { setMarkdown(markdown); setLoading(false); } }, []);
- 现在,我们可以通过一个 ref 判断组件有没有挂载。
React 为构建用户界面提供了一种声明式方案,GraphQL 为与 API 通信提供一种声明式方案。并行发起数据请求时,我们希望能同时获得所需的全部数据。GraphQL 就是为此而设计的。
- 为了从 GraphQL API 获取数据,我们仍然要向指定的 URI 发起 HTTP 请求。不过,还要随请求一起发送查询。GraphQL 查询是对想要请求的数据的一种声明式描述。服务负责解析这个描述,把请求的所有数据打包进一个响应中。
若想在 React 应用中使用 GraphQL,与之通信的后端服务要按照 GraphQL 规范构建。幸好,GitHub 也开放了 GraphQL API。多数 GraphQL 服务都提供有探索 GraphQL API 的方式。GitHub 提供的是 GraphQL Explorer(https://developer.github.com/v4/explorer)。
GraphQL 请求是在请求主体中包含查询的 HTTP 请求。我们可以使用 fetch 发起 GraphQL 请求。此外,也有一些专门的库和框架能辅助我们处理这类请求各方面的细节。
- GraphQL 不限于 HTTP。GraphQL 是一个规范,规定如何通过网络发起数据请求。理论上,GraphQL 适用于任何网络协议。而且,GraphQL 不限定必须使用某种编程语言。
安装 graphql-request 库:
npm i graphql-request