如何使用React处理异步数据加载、延迟加载和代码拆分
作为 Write for DOnations 计划的一部分,作者选择了 Creative Commons 来接受捐赠。
介绍
作为 JavaScript Web 开发人员, 异步代码 使您能够运行代码的某些部分,而其他部分仍在等待数据或解析。 这意味着您的应用程序的重要部分不必等待不太重要的部分才呈现。 使用异步代码,您还可以通过请求和显示新信息来更新您的应用程序,即使在后台处理长函数和请求时也可以为用户提供流畅的体验。
在 React 开发中,异步编程存在独特的问题。 例如,当您使用 React 功能组件 时,异步函数可以创建无限循环。 当组件加载时,它可以启动一个异步函数,当异步函数解析时,它可以触发重新渲染,这将导致组件调用异步函数。 本教程将解释如何使用称为 useEffect 的特殊 Hook 来避免这种情况,它仅在特定数据更改时才运行函数。 这将让您有意识地运行异步代码,而不是在每个渲染周期中运行。
异步代码不仅限于对新数据的请求。 React 有一个用于 延迟加载 组件的内置系统,或者仅在用户需要它们时才加载它们。 当与 Create React App 中的默认 webpack 配置结合使用时,您可以拆分代码,将大型应用程序缩减为可以根据需要加载的较小部分。 React 有一个称为 Suspense
的特殊组件,它将在浏览器加载新组件时显示占位符。 在未来的 React 版本中,您将能够使用 Suspense
在嵌套组件中加载数据,而不会阻塞渲染。
在本教程中,您将通过创建一个显示河流信息并使用 setTimeout
模拟对 Web API 的请求的应用程序来处理 React 中的异步数据。 在本教程结束时,您将能够使用 useEffect
Hook 加载异步数据。 如果组件在数据解析之前卸载,您还可以安全地更新页面而不会产生错误。 最后,您将使用代码拆分将大型应用程序拆分为较小的部分。
先决条件
- 您将需要一个运行 Node.js 的开发环境; 本教程在 Node.js 版本 10.20.1 和 npm 版本 6.14.4 上进行了测试。 要在 macOS 或 Ubuntu 18.04 上安装它,请按照 如何在 macOS 上安装 Node.js 和创建本地开发环境中的步骤或 的 使用 PPA 部分安装如何在 Ubuntu 18.04 上安装 Node.js。
- 使用 Create React App 设置的 React 开发环境,删除了非必要的样板。 要进行此设置,请按照 步骤 1 — 创建一个空项目的如何管理 React 类组件上的状态教程 。 本教程将使用
async-tutorial
作为项目名称。 - 您将使用 React 事件和 Hooks,包括
useState
和useReducer
Hooks。 你可以在我们的 How To Handle DOM and Window Events with React 教程中了解事件,以及 Hooks 在 How to Manage State with Hooks on React Components。 - 您还需要 JavaScript 和 HTML 的基本知识,您可以在我们的 如何使用 HTML 构建网站系列 和 如何在 JavaScript 中编码 中找到这些知识。 CSS 的基本知识也会很有用,您可以在 Mozilla 开发者网络 上找到这些知识。
第 1 步 — 使用 useEffect
加载异步数据
在此步骤中,您将使用 useEffect
挂钩将异步数据加载到示例应用程序中。 您将使用 Hook 来防止不必要的数据获取,在数据加载时添加占位符,并在数据解析时更新组件。 在这一步结束时,您将能够使用 useEffect
加载数据,并在解决时使用 useState
Hook 设置数据。
要探索该主题,您将创建一个应用程序来显示有关世界上最长河流的信息。 您将使用模拟对外部数据源的请求的异步函数加载数据。
首先,创建一个名为 RiverInformation
的组件。 制作目录:
mkdir src/components/RiverInformation
在文本编辑器中打开 RiverInformation.js
:
nano src/components/RiverInformation/RiverInformation.js
然后添加一些占位符内容:
异步教程/src/components/RiverInformation/RiverInformation.js
import React from 'react'; export default function RiverInformation() { return( <div> <h2>River Information</h2> </div> ) }
保存并关闭文件。 现在您需要将新组件导入并渲染到您的根组件。 打开App.js
:
nano src/components/App/App.js
通过添加突出显示的代码来导入和渲染组件:
异步教程/src/components/App/App.js
import React from 'react'; import './App.css'; import RiverInformation from '../RiverInformation/RiverInformation'; function App() { return ( <div className="wrapper"> <h1>World's Longest Rivers</h1> <RiverInformation /> </div> ); } export default App;
保存并关闭文件。
最后,为了使应用程序更易于阅读,添加一些样式。 打开App.css
:
nano src/components/App/App.css
通过将 CSS 替换为以下内容,为 wrapper
类添加一些填充:
异步教程/src/components/App/App.css
.wrapper { padding: 20px }
保存并关闭文件。 当您这样做时,浏览器将刷新并呈现基本组件。
在本教程中,您将创建通用的 services 来返回数据。 服务是指可以重复使用以完成特定任务的任何代码。 您的组件不需要知道服务如何获取其信息。 它只需要知道服务将返回 Promise。 在这种情况下,将使用 setTimeout
模拟数据请求,它会在提供数据之前等待指定的时间。
在 src/
目录下新建一个名为 services
的目录:
mkdir src/services
该目录将保存您的异步函数。 打开一个名为 rivers.js
的文件:
nano src/services/rivers.js
在文件中,导出一个名为 getRiverInformation
的函数,该函数返回一个承诺。 在 Promise 中,添加一个 setTimeout
函数,它将在 1500
毫秒后解析 Promise。 这将给您一些时间来查看组件在等待数据解析时将如何呈现:
异步教程/src/services/rivers.js
export function getRiverInformation() { return new Promise((resolve) => { setTimeout(() => { resolve({ continent: 'Africa', length: '6,650 km', outflow: 'Mediterranean' }) }, 1500) }) }
在此代码段中,您正在对河流信息进行硬编码,但此函数将类似于您可能使用的任何异步函数,例如 API 调用。 重要的部分是代码返回一个承诺。
保存并关闭文件。
现在您有了一个返回数据的服务,您需要将它添加到您的组件中。 这有时会导致问题。 假设您在组件内部调用了异步函数,然后使用 useState
Hook 将数据设置为变量。 代码将是这样的:
import React, { useState } from 'react'; import { getRiverInformation } from '../../services/rivers'; export default function RiverInformation() { const [riverInformation, setRiverInformation] = useState({}); getRiverInformation() .then(d => { setRiverInformation(d) }) return( ... ) }
当你设置数据时,Hook 的改变会触发一个组件的重新渲染。 当组件重新渲染时,getRiverInformation
函数将再次运行,当它解析时,它会设置状态,这将触发另一个重新渲染。 循环将永远持续下去。
为了解决这个问题,React 有一个名为 useEffect
的特殊 Hook,它只会在特定数据发生变化时运行。
useEffect Hook 接受一个 function 作为第一个参数,一个触发器的 array 作为第二个参数。 该函数将在布局和绘制后的第一次渲染上运行。 之后,它只会在其中一个触发器发生变化时运行。 如果您提供一个空数组,它只会运行一次。 如果您不包含触发器数组,它将在每次渲染后运行。
打开RiverInformation.js
:
nano src/components/RiverInformation/RiverInformation.js
使用 useState
钩子创建一个名为 riverInformation
的变量和一个名为 setRiverInformation
的函数。 当异步函数解析时,您将通过设置 riverInformation
来更新组件。 然后用 useEffect
包装 getRiverInformation
函数。 确保传递一个空数组作为第二个参数。 当 promise 解决时,使用 setRiverInformation
函数更新 riverInformation
:
异步教程/src/components/RiverInformation/RiverInformation.js
import React, { useEffect, useState } from 'react'; import { getRiverInformation } from '../../services/rivers'; export default function RiverInformation() { const [riverInformation, setRiverInformation] = useState({}); useEffect(() => { getRiverInformation() .then(data => setRiverInformation(data) ); }, []) return( <div> <h2>River Information</h2> <ul> <li>Continent: {riverInformation.continent}</li> <li>Length: {riverInformation.length}</li> <li>Outflow: {riverInformation.outflow}</li> </ul> </div> ) }
异步函数解析后,使用新信息更新无序列表。
保存并关闭文件。 当您这样做时,浏览器将刷新,您将在函数解析后找到数据:
请注意,组件在数据加载之前呈现。 异步代码的优点是它不会阻塞初始渲染。 在这种情况下,您有一个组件显示没有任何数据的列表,但您也可以渲染微调器或可缩放矢量图形 (SVG) 占位符。
有时您只需要加载一次数据,例如,如果您正在获取用户信息或永远不会更改的资源列表。 但是很多时候你的异步函数需要一些参数。 在这些情况下,您需要在数据发生变化时触发使用 useEffect
钩子。
为了模拟这一点,向您的服务添加更多数据。 打开rivers.js
:
nano src/services/rivers.js
然后添加一个 对象,其中包含更多河流的数据。 根据 name
参数选择数据:
异步教程/src/services/rivers.js
const rivers = { nile: { continent: 'Africa', length: '6,650 km', outflow: 'Mediterranean' }, amazon: { continent: 'South America', length: '6,575 km', outflow: 'Atlantic Ocean' }, yangtze: { continent: 'Asia', length: '6,300 km', outflow: 'East China Sea' }, mississippi: { continent: 'North America', length: '6,275 km', outflow: 'Gulf of Mexico' } } export function getRiverInformation(name) { return new Promise((resolve) => { setTimeout(() => { resolve( rivers[name] ) }, 1500) }) }
保存并关闭文件。 接下来,打开 App.js
以便您可以添加更多选项:
nano src/components/App/App.js
在 App.js
内部,创建一个 有状态的 变量并使用 useState
钩子来保存选定的河流。 然后使用 onClick
处理程序为每条河流添加一个按钮,以更新选定的河流。 使用名为 name
的 prop 将 river
传递给 RiverInformation
:
异步教程/src/components/App/App.js
import React, { useState } from 'react'; import './App.css'; import RiverInformation from '../RiverInformation/RiverInformation'; function App() { const [river, setRiver] = useState('nile'); return ( <div className="wrapper"> <h1>World's Longest Rivers</h1> <button onClick={() => setRiver('nile')}>Nile</button> <button onClick={() => setRiver('amazon')}>Amazon</button> <button onClick={() => setRiver('yangtze')}>Yangtze</button> <button onClick={() => setRiver('mississippi')}>Mississippi</button> <RiverInformation name={river} /> </div> ); } export default App;
保存并关闭文件。 接下来,打开RiverInformation.js
:
nano src/components/RiverInformation/RiverInformation.js
拉入 name
作为道具并将其传递给 getRiverInformation
函数。 一定要将name
添加到useEffect
的数组中,否则不会重新运行:
异步教程/src/components/RiverInformation/RiverInformation.js
import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import { getRiverInformation } from '../../services/rivers'; export default function RiverInformation({ name }) { const [riverInformation, setRiverInformation] = useState({}); useEffect(() => { getRiverInformation(name) .then(data => setRiverInformation(data) ); }, [name]) return( <div> <h2>River Information</h2> <ul> <li>Continent: {riverInformation.continent}</li> <li>Length: {riverInformation.length}</li> <li>Outflow: {riverInformation.outflow}</li> </ul> </div> ) } RiverInformation.propTypes = { name: PropTypes.string.isRequired }
在这段代码中,您还使用 PropTypes
添加了一个弱类型系统,这将确保 prop 是一个字符串。
保存文件。 当您这样做时,浏览器将刷新,您可以选择不同的河流。 请注意单击和数据呈现之间的延迟:
如果您从 useEffect
数组中遗漏了 name
属性,您将在 浏览器控制台 中收到构建错误。 它会是这样的:
ErrorCompiled with warnings. ./src/components/RiverInformation/RiverInformation.js Line 13:6: React Hook useEffect has a missing dependency: 'name'. Either include it or remove the dependency array react-hooks/exhaustive-deps Search for the keywords to learn more about each warning. To ignore, add // eslint-disable-next-line to the line before.
此错误告诉您效果中的函数具有您未明确设置的依赖项。 在这种情况下,很明显该效果不起作用,但有时您可能会将 prop 数据与组件内的状态数据进行比较,这可能会导致无法跟踪数组中的项目。
最后要做的就是在你的组件中添加一些防御性编程。 这是一个强调应用程序高可用性的设计原则。 您希望确保即使数据的形状不正确或者您根本没有从 API 请求中获取任何数据,您的组件也会呈现。
就像您的应用程序现在一样,效果将使用它接收到的任何类型的数据更新 riverInformation
。 这通常是一个对象,但如果不是,您可以使用 optional chaining 来确保不会抛出错误。
在 RiverInformation.js
中,将对象点链接的实例替换为可选链接。 要测试它是否有效,请从 useState
函数中删除默认对象 {}
:
异步教程/src/components/RiverInformation/RiverInformation.js
import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import { getRiverInformation } from '../../services/rivers'; export default function RiverInformation({ name }) { const [riverInformation, setRiverInformation] = useState(); useEffect(() => { getRiverInformation(name) .then(data => setRiverInformation(data) ); }, [name]) return( <div> <h2>River Information</h2> <ul> <li>Continent: {riverInformation?.continent}</li> <li>Length: {riverInformation?.length}</li> <li>Outflow: {riverInformation?.outflow}</li> </ul> </div> ) } RiverInformation.propTypes = { name: PropTypes.string.isRequired }
保存并关闭文件。 当您这样做时,即使代码引用 undefined
上的属性而不是对象,文件仍将加载:
防御性编程通常被认为是一种最佳实践,但当您无法保证响应时,它对 API 调用等异步函数尤其重要。
在这一步中,您在 React 中调用了异步函数。 您使用 useEffect
钩子在不触发重新渲染的情况下获取信息,并通过向 useEffect
数组添加条件来触发新的更新。
在下一步中,您将对您的应用程序进行一些更改,以便它仅在安装组件时更新组件。 这将帮助您的应用避免内存泄漏。
第 2 步 — 防止未安装组件出现错误
在此步骤中,您将阻止未安装组件的数据更新。 由于您永远无法确定数据何时会通过异步编程解析,因此始终存在在删除组件后数据会解析的风险。 在未挂载的组件上更新数据效率低下,并且可能会引入 内存泄漏 ,在这种情况下,您的应用程序使用的内存超出了它的需要。
到此步骤结束时,您将知道如何通过在 useEffect
Hook 中添加保护以仅在安装组件时更新数据来防止内存泄漏。
当前组件将始终被挂载,因此在将组件从 DOM 中删除后,代码不会尝试更新组件,但大多数组件并不那么可靠。 当用户与应用程序交互时,它们将在页面中添加和删除。 如果在异步函数解析之前从页面中删除组件,则可能会发生内存泄漏。
要测试问题,请更新 App.js
以便能够添加和删除河流详细信息。
打开App.js
:
nano src/components/App/App.js
添加一个按钮来切换河流的详细信息。 使用 useReducer
钩子创建一个函数来切换细节和一个变量来存储切换状态:
异步教程/src/components/App/App.js
import React, { useReducer, useState } from 'react'; import './App.css'; import RiverInformation from '../RiverInformation/RiverInformation'; function App() { const [river, setRiver] = useState('nile'); const [show, toggle] = useReducer(state => !state, true); return ( <div className="wrapper"> <h1>World's Longest Rivers</h1> <div><button onClick={toggle}>Toggle Details</button></div> <button onClick={() => setRiver('nile')}>Nile</button> <button onClick={() => setRiver('amazon')}>Amazon</button> <button onClick={() => setRiver('yangtze')}>Yangtze</button> <button onClick={() => setRiver('mississippi')}>Mississippi</button> {show && <RiverInformation name={river} />} </div> ); } export default App;
保存文件。 当您这样做时,浏览将重新加载,您将能够切换详细信息。
单击一条河流,然后立即单击切换详细信息按钮以隐藏详细信息。 React 将生成一个错误警告,指出存在潜在的内存泄漏。
要解决此问题,您需要取消或忽略 useEffect
中的异步函数。 如果您正在使用诸如 RxJS 之类的库,则可以通过在 useEffect
Hook 中返回一个函数来取消组件卸载时的异步操作。 在其他情况下,您需要一个变量来存储挂载状态。
打开RiverInformation.js
:
nano src/components/RiverInformation/RiverInformation.js
在 useEffect
函数中,创建一个名为 mounted
的变量并将其设置为 true
。 在 .then
回调中,如果 mounted
为真,则使用条件设置数据:
异步教程/src/components/RiverInformation/RiverInformation.js
import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import { getRiverInformation } from '../../services/rivers'; export default function RiverInformation({ name }) { const [riverInformation, setRiverInformation] = useState(); useEffect(() => { let mounted = true; getRiverInformation(name) .then(data => { if(mounted) { setRiverInformation(data) } }); }, [name]) return( <div> <h2>River Information</h2> <ul> <li>Continent: {riverInformation?.continent}</li> <li>Length: {riverInformation?.length}</li> <li>Outflow: {riverInformation?.outflow}</li> </ul> </div> ) } RiverInformation.propTypes = { name: PropTypes.string.isRequired }
现在您有了变量,您需要能够在组件卸载时翻转它。 使用 useEffect
钩子,您可以返回一个函数,该函数将在组件卸载时运行。 返回一个将 mounted
设置为 false
的函数:
异步教程/src/components/RiverInformation/RiverInformation.js
import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import { getRiverInformation } from '../../services/rivers'; export default function RiverInformation({ name }) { const [riverInformation, setRiverInformation] = useState(); useEffect(() => { let mounted = true; getRiverInformation(name) .then(data => { if(mounted) { setRiverInformation(data) } }); return () => { mounted = false; } }, [name]) return( <div> <h2>River Information</h2> <ul> <li>Continent: {riverInformation?.continent}</li> <li>Length: {riverInformation?.length}</li> <li>Outflow: {riverInformation?.outflow}</li> </ul> </div> ) } RiverInformation.propTypes = { name: PropTypes.string.isRequired }
保存文件。 当您这样做时,您将能够切换详细信息而不会出现错误。
卸载时,组件 useEffect
会更新变量。 异步函数仍将解析,但不会对未安装的组件进行任何更改。 这将防止内存泄漏。
在此步骤中,您仅在安装组件时才使您的应用程序更新状态。 您更新了 useEffect
挂钩以跟踪组件是否已安装,并返回一个函数以在组件卸载时更新值。
在下一步中,您将异步加载组件以将代码拆分成更小的包,用户将根据需要加载这些包。
第 3 步 — 使用 Suspense
和 lazy
延迟加载组件
在这一步中,您将使用 React Suspense
和 lazy
拆分代码。 随着应用程序的增长,最终构建的大小也随之增长。 您可以将代码拆分成更小的块,而不是强迫用户下载整个应用程序。 React Suspense
和 lazy
与 webpack 和其他构建系统一起使用,将您的代码分成更小的部分,用户可以按需加载。 未来,您将能够使用 Suspense
加载各种数据,包括 API 请求。
在这一步结束时,您将能够异步加载组件,将大型应用程序分解为更小、更集中的块。
到目前为止,您只使用了异步加载数据,但您也可以异步加载组件。 此过程通常称为 代码拆分 ,有助于减少代码包的大小,因此如果您的用户只使用其中的一部分,他们就不必下载完整的应用程序。
大多数时候,您静态导入代码,但您可以通过将 import
作为函数而不是语句调用来动态导入代码 [1]。 代码将是这样的:
import('my-library') .then(library => library.action())
React 为您提供了一组额外的工具,称为 lazy 和 Suspense
。 React Suspense
最终会扩展为 处理数据加载 ,但现在你可以使用它来加载组件。
打开App.js
:
nano src/components/App/App.js
然后从 react
导入 lazy
和 Suspense
:
异步教程/src/components/App/App.js
import React, { lazy, Suspense, useReducer, useState } from 'react'; import './App.css'; import RiverInformation from '../RiverInformation/RiverInformation'; function App() { const [river, setRiver] = useState('nile'); const [show, toggle] = useReducer(state => !state, true); return ( <div className="wrapper"> <h1>World's Longest Rivers</h1> <div><button onClick={toggle}>Toggle Details</button></div> <button onClick={() => setRiver('nile')}>Nile</button> <button onClick={() => setRiver('amazon')}>Amazon</button> <button onClick={() => setRiver('yangtze')}>Yangtze</button> <button onClick={() => setRiver('mississippi')}>Mississippi</button> {show && <RiverInformation name={river} />} </div> ); } export default App;
lazy
和 Suspsense
有两个不同的工作。 您使用 lazy
函数动态导入组件并将其设置为变量。 Suspense
是一个内置组件,用于在加载代码时显示回退消息。
将 import RiverInformation from '../RiverInformation/RiverInformation';
替换为对 lazy
的调用。 将结果分配给名为 RiverInformation
的变量。 然后将 {show && <RiverInformation name={river} />}
与 Suspense
组件和带有 Loading Component
消息的 <div>
包装到 fallback
道具:
异步教程/src/components/App/App.js
import React, { lazy, Suspense, useReducer, useState } from 'react'; import './App.css'; const RiverInformation = lazy(() => import('../RiverInformation/RiverInformation')); function App() { const [river, setRiver] = useState('nile'); const [show, toggle] = useReducer(state => !state, true); return ( <div className="wrapper"> <h1>World's Longest Rivers</h1> <div><button onClick={toggle}>Toggle Details</button></div> <button onClick={() => setRiver('nile')}>Nile</button> <button onClick={() => setRiver('amazon')}>Amazon</button> <button onClick={() => setRiver('yangtze')}>Yangtze</button> <button onClick={() => setRiver('mississippi')}>Mississippi</button> <Suspense fallback={<div>Loading Component</div>}> {show && <RiverInformation name={river} />} </Suspense> </div> ); } export default App;
保存文件。 当你这样做时,重新加载页面,你会发现组件是动态加载的。 如果你想看到加载信息,你可以在Chrome浏览器中throttle响应。
如果您导航到 Chrome 或 Firefox 中的 Network 选项卡,您会发现代码被分成不同的块。
每个 chunk 默认都有一个编号,但是通过 Create React App 结合 webpack,你可以通过动态导入添加注释来设置 chunk 名称。
在 App.js
中,在 import
函数内部添加 /* webpackChunkName: "RiverInformation" */
的注释:
异步教程/src/components/App/App.js
import React, { lazy, Suspense, useReducer, useState } from 'react'; import './App.css'; const RiverInformation = lazy(() => import(/* webpackChunkName: "RiverInformation" */ '../RiverInformation/RiverInformation')); function App() { const [river, setRiver] = useState('nile'); const [show, toggle] = useReducer(state => !state, true); return ( <div className="wrapper"> <h1>World's Longest Rivers</h1> <div><button onClick={toggle}>Toggle Details</button></div> <button onClick={() => setRiver('nile')}>Nile</button> <button onClick={() => setRiver('amazon')}>Amazon</button> <button onClick={() => setRiver('yangtze')}>Yangtze</button> <button onClick={() => setRiver('mississippi')}>Mississippi</button> <Suspense fallback={<div>Loading Component</div>}> {show && <RiverInformation name={river} />} </Suspense> </div> ); } export default App;
保存并关闭文件。 当你这样做时,浏览器将刷新并且 RiverInformation
块将有一个唯一的名称。
在此步骤中,您异步加载了组件。 您使用 lazy
和 Suspense
动态导入组件并在组件加载时显示加载消息。 您还为 webpack 块提供了自定义名称,以提高可读性和调试。
结论
异步函数创建高效的用户友好型应用程序。 然而,它们的优势伴随着一些微妙的成本,这些成本可能会演变成程序中的错误。 您现在拥有的工具可以让您将大型应用程序拆分为更小的部分并加载异步数据,同时仍为用户提供可见的应用程序。 您可以使用这些知识将 API 请求和异步数据操作合并到您的应用程序中,从而创建快速可靠的用户体验。
如果您想阅读更多 React 教程,请查看我们的 React 主题页面 ,或返回 如何在 React.js 系列页面中编码 。