在 React 中实现无限滚动和图像延迟加载
已发表: 2022-03-10HTML Intersection Observer API 在 React 功能组件中实现无限滚动和图像延迟加载。 在这个过程中,我们将学习如何使用一些 React 的钩子以及如何创建自定义钩子。如果您一直在寻找分页的替代方法,无限滚动是一个很好的考虑。 在本文中,我们将在 React 功能组件的上下文中探索 Intersection Observer API 的一些用例。 读者应该具备 React 功能组件的工作知识。 对 React 钩子有一定的了解是有益的,但不是必需的,因为我们将看一些。
我们的目标是,在本文的最后,我们将使用原生 HTML API 实现无限滚动和图像延迟加载。 我们也会学到更多关于 React Hooks 的东西。 有了它,您可以在必要时在您的 React 应用程序中实现无限滚动和图像延迟加载。
让我们开始吧。
使用 React 和 Leaflet 创建地图
从 CSV 或 JSON 文件中获取信息不仅复杂,而且乏味。 以视觉辅助的形式表示相同的数据更简单。 Shajia Abidi 解释了 Leaflet 工具的强大功能,以及如何创建许多不同类型的地图。 阅读相关文章 →
交叉口观察者 API
根据 MDN 文档,“Intersection Observer API 提供了一种异步观察目标元素与祖先元素或顶级文档视口的交集变化的方法”。
这个 API 允许我们实现一些很酷的功能,例如无限滚动和图像延迟加载。 通过调用其构造函数并向其传递回调和选项对象来创建交叉点观察器。 只要一个名为target的元素与设备视口或指定的元素(称为root )相交,就会调用回调。 我们可以在选项参数中指定自定义根或使用默认值。
let observer = new IntersectionObserver(callback, options);该 API 易于使用。 一个典型的例子如下所示:
var intObserver = new IntersectionObserver(entries => { entries.forEach(entry => { console.log(entry) console.log(entry.isIntersecting) // returns true if the target intersects the root element }) }, { // default options } ); let target = document.querySelector('#targetId'); intObserver.observe(target); // start observation entries是IntersectionObserverEntry对象的列表。 IntersectionObserverEntry对象描述了一个观察到的目标元素的交集变化。 请注意,回调不应处理任何耗时的任务,因为它在主线程上运行。
Intersection Observer API 目前享有广泛的浏览器支持,如 caniuse 所示。

您可以在资源部分提供的链接中阅读有关 API 的更多信息。
现在让我们看看如何在真正的 React 应用程序中使用这个 API。 我们应用程序的最终版本将是一个无限滚动的图片页面,并且每个图片都会延迟加载。
使用useEffect Hook 进行 API 调用
要开始,请从此 URL 克隆启动项目。 它具有最少的设置和定义的一些样式。 我还在public/index.html文件中添加了指向Bootstrap的 CSS 的链接,因为我将使用它的类进行样式设置。
如果您愿意,请随意创建一个新项目。 如果你想跟随 repo,请确保你安装了yarn包管理器。 您可以在此处找到适用于您的特定操作系统的安装说明。
对于本教程,我们将从公共 API 中获取图片并将其显示在页面上。 我们将使用 Lorem Picsum API。
对于本教程,我们将使用端点https://picsum.photos/v2/list?page=0&limit=10 ,它返回图片对象数组。 为了获得接下来的十张图片,我们将 page 的值更改为 1,然后是 2,以此类推。
我们现在将一块一块地构建 App 组件。
打开src/App.js并输入以下代码。
import React, { useEffect, useReducer } from 'react'; import './index.css'; function App() { const imgReducer = (state, action) => { switch (action.type) { case 'STACK_IMAGES': return { ...state, images: state.images.concat(action.images) } case 'FETCHING_IMAGES': return { ...state, fetching: action.fetching } default: return state; } } const [imgData, imgDispatch] = useReducer(imgReducer,{ images:[], fetching: true}) // next code block goes here } 首先,我们定义了一个 reducer 函数imgReducer 。 这个 reducer 处理两个动作。
-
STACK_IMAGES动作连接images数组。 -
FETCHING_IMAGES操作在true和false之间切换fetching变量的值。
下一步是将这个减速器连接到一个useReducer钩子。 完成后,我们会返回两件事:
-
imgData,其中包含两个变量:images是图片对象的数组。fetching是一个布尔值,它告诉我们 API 调用是否正在进行。 -
imgDispatch,这是一个用于更新 reducer 对象的函数。
您可以在 React 文档中了解有关useReducer挂钩的更多信息。
代码的下一部分是我们进行 API 调用的地方。 将以下代码粘贴到App.js中上一个代码块的下方。
// make API calls useEffect(() => { imgDispatch({ type: 'FETCHING_IMAGES', fetching: true }) fetch('https://picsum.photos/v2/list?page=0&limit=10') .then(data => data.json()) .then(images => { imgDispatch({ type: 'STACK_IMAGES', images }) imgDispatch({ type: 'FETCHING_IMAGES', fetching: false }) }) .catch(e => { // handle error imgDispatch({ type: 'FETCHING_IMAGES', fetching: false }) return e }) }, [ imgDispatch ]) // next code block goes here 在useEffect挂钩中,我们使用fetch API 调用 API 端点。 然后,我们通过调度STACK_IMAGES操作使用 API 调用的结果更新图像数组。 一旦 API 调用完成,我们还会分派FETCHING_IMAGES操作。
下一个代码块定义函数的返回值。 在useEffect挂钩后输入以下代码。
return ( <div className=""> <nav className="navbar bg-light"> <div className="container"> <a className="navbar-brand" href="/#"> <h2>Infinite scroll + image lazy loading</h2> </a> </div> </navv <div id='images' className="container"> <div className="row"> {imgData.images.map((image, index) => { const { author, download_url } = image return ( <div key={index} className="card"> <div className="card-body "> <img alt={author} className="card-img-top" src={download_url} /> </div> <div className="card-footer"> <p className="card-text text-center text-capitalize text-primary">Shot by: {author}</p> </div> </div> ) })} </div> </div> </div> ); 为了显示图像,我们在imgData对象中映射图像数组。
现在启动应用程序并在浏览器中查看页面。 您应该看到图像很好地显示在响应式网格中。
最后一点是导出 App 组件。
export default App; 
此时对应的分支是 01-make-api-calls。
现在让我们通过在页面滚动时显示更多图片来扩展它。
实现无限滚动
我们的目标是在页面滚动时呈现更多图片。 从 API 端点的 URL, https://picsum.photos/v2/list?page=0&limit=10 ,我们知道要获得一组新照片,我们只需要增加page的值。 当我们的图片用完时,我们也需要这样做。 出于我们的目的,当我们点击页面底部时,我们会知道我们已经用完了图像。 是时候看看 Intersection Observer API 如何帮助我们实现这一目标了。
打开src/App.js并在imgReducer pageReducer
// App.js const imgReducer = (state, action) => { ... } const pageReducer = (state, action) => { switch (action.type) { case 'ADVANCE_PAGE': return { ...state, page: state.page + 1 } default: return state; } } const [ pager, pagerDispatch ] = useReducer(pageReducer, { page: 0 }) 我们只定义一种动作类型。 每次触发ADVANCE_PAGE动作时, page的值都会增加 1。
更新fetch函数中的 URL 以动态接受页码,如下所示。
fetch(`https://picsum.photos/v2/list?page=${pager.page}&limit=10`) 将imgData添加到pager.page旁边的依赖数组中。 这样做可确保 API 调用在pager.page更改时运行。
useEffect(() => { ... }, [ imgDispatch, pager.page ]) 在 API 调用的useEffect挂钩之后,输入以下代码。 同时更新您的导入行。
// App.js import React, { useEffect, useReducer, useCallback, useRef } from 'react'; useEffect(() => { ... }, [ imgDispatch, pager.page ]) // implement infinite scrolling with intersection observer let bottomBoundaryRef = useRef(null); const scrollObserver = useCallback( node => { new IntersectionObserver(entries => { entries.forEach(en => { if (en.intersectionRatio > 0) { pagerDispatch({ type: 'ADVANCE_PAGE' }); } }); }).observe(node); }, [pagerDispatch] ); useEffect(() => { if (bottomBoundaryRef.current) { scrollObserver(bottomBoundaryRef.current); } }, [scrollObserver, bottomBoundaryRef]); 我们定义了一个变量bottomBoundaryRef并将其值设置为useRef(null) 。 useRef让变量在组件渲染中保留它们的值,即当包含的组件重新渲染时,变量的当前值仍然存在。 更改其值的唯一方法是重新分配该变量的.current属性。
在我们的例子中, bottomBoundaryRef.current以null值开始。 随着页面渲染周期的进行,我们将其当前属性设置为节点<div id='page-bottom-boundary'> 。
我们使用赋值语句ref={bottomBoundaryRef}告诉 React 将bottomBoundaryRef.current设置为声明此赋值的 div。
因此,
bottomBoundaryRef.current = null在渲染周期结束时,变为:
bottomBoundaryRef.current = <div></div>我们马上就会看到这个任务在哪里完成。

接下来,我们定义一个scrollObserver函数,在其中设置观察者。 这个函数接受一个DOM节点来观察。 这里要注意的要点是,每当我们到达观察的交叉路口时,我们都会调度ADVANCE_PAGE动作。 效果是将pager.page的值增加 1。一旦发生这种情况,将其作为依赖项的useEffect挂钩将重新运行。 这个重新运行,反过来,用新的页码调用 fetch 调用。
事件游行看起来像这样。
在观察下命中交叉点→调用ADVANCE_PAGE动作→将pager.page的值增加 1→用于获取调用运行的useEffect钩子→运行fetch调用→返回的图像连接到images数组。
我们在useEffect钩子中调用scrollObserver ,以便该函数仅在任何钩子的依赖项发生变化时运行。 如果我们没有在useEffect挂钩中调用该函数,该函数将在每个页面渲染时运行。
回想一下bottomBoundaryRef.current指的是<div id="page-bottom-boundary" style="border: 1px solid red;"></div> 。 在将其传递给scrollObserver之前,我们检查它的值是否不为空。 否则, IntersectionObserver构造函数将返回错误。
因为我们在scrollObserver挂钩中使用了useEffect ,所以我们必须将其包装在useCallback挂钩中以防止无休止的组件重新渲染。 您可以在 React 文档中了解有关 useCallback 的更多信息。
在<div id='images'> div 之后输入以下代码。
// App.js <div id='image'> ... </div> {imgData.fetching && ( <div className="text-center bg-secondary m-auto p-3"> <p className="m-0 text-white">Getting images</p> </div> )} <div id='page-bottom-boundary' style={{ border: '1px solid red' }} ref={bottomBoundaryRef}></div> 当 API 调用开始时,我们将fetching设置为true ,并且文本Getting images变得可见。 完成后,我们将fetching设置为false ,文本被隐藏。 我们还可以通过在构造函数选项对象中设置不同的threshold ,在准确到达边界之前触发 API 调用。 最后的红线让我们准确地看到我们何时到达页面边界。
此时对应的分支是02-infinite-scroll。
我们现在将实现图像延迟加载。
实现图像延迟加载
如果您在向下滚动时检查网络选项卡,您会看到,只要您点击红线(底部边界),就会发生 API 调用,并且即使您还没有查看,所有图像也会开始加载他们。 这可能不是理想的行为有多种原因。 我们可能希望保存网络调用,直到用户想要查看图像。 在这种情况下,我们可以选择延迟加载图像,即在图像滚动到视图之前我们不会加载图像。
打开src/App.js 。 在无限滚动功能下方,输入以下代码。
// App.js // lazy loads images with intersection observer // only swap out the image source if the new url exists const imagesRef = useRef(null); const imgObserver = useCallback(node => { const intObs = new IntersectionObserver(entries => { entries.forEach(en => { if (en.intersectionRatio > 0) { const currentImg = en.target; const newImgSrc = currentImg.dataset.src; // only swap out the image source if the new url exists if (!newImgSrc) { console.error('Image source is invalid'); } else { currentImg.src = newImgSrc; } intObs.unobserve(node); // detach the observer when done } }); }) intObs.observe(node); }, []); useEffect(() => { imagesRef.current = document.querySelectorAll('.card-img-top'); if (imagesRef.current) { imagesRef.current.forEach(img => imgObserver(img)); } }, [imgObserver, imagesRef, imgData.images]); 与scrollObserver ,我们定义了一个函数imgObserver ,它接受一个要观察的节点。 当页面遇到由en.intersectionRatio > 0确定的交叉点时,我们交换元素上的图像源。 请注意,在进行交换之前,我们首先检查新图像源是否存在。 与scrollObserver函数一样,我们将 imgObserver 包装在useCallback挂钩中,以防止无休止的组件重新渲染。
另请注意,一旦完成替换,我们就会停止观察img元素。 我们使用unobserve方法来做到这一点。
在下面的useEffect钩子中,我们使用document.querySelectorAll抓取页面上所有具有.card-img-top类的图像。 然后我们遍历每个图像并在其上设置一个观察者。
请注意,我们添加了imgData.images作为useEffect挂钩的依赖项。 当这种变化时,它会触发useEffect钩子,然后imgObserver会被每个<img className='card-img-top'>元素调用。
如下所示更新<img className='card-img-top'/>元素。
<img alt={author} data-src={download_url} className="card-img-top" src={'https://picsum.photos/id/870/300/300?grayscale&blur=2'} /> 我们为每个<img className='card-img-top'/>元素设置一个默认来源,并将我们想要显示的图像存储在data-src属性中。 默认图像通常具有较小的尺寸,以便我们尽可能少地下载。 当<img/>元素出现时, data-src属性的值将替换默认图像。
在下图中,我们看到默认的灯塔图像仍显示在某些空间中。

此时对应的分支是03-lazy-loading。
现在让我们看看我们如何抽象所有这些函数,以便它们可以重用。
将 Fetch、无限滚动和延迟加载抽象到自定义 Hooks 中
我们已经成功实现了 fetch、无限滚动和图像延迟加载。 我们的应用程序中可能有另一个组件需要类似的功能。 在这种情况下,我们可以抽象和重用这些函数。 我们所要做的就是将它们移动到一个单独的文件中,然后将它们导入我们需要的地方。 我们想把它们变成自定义钩子。
React 文档将自定义 Hook 定义为名称以"use"开头的 JavaScript 函数,并且可以调用其他钩子。 在我们的例子中,我们想要创建三个钩子, useFetch 、 useInfiniteScroll 、 useLazyLoading 。
在src/文件夹中创建一个文件。 将其命名为customHooks.js并将下面的代码粘贴到其中。
// customHooks.js import { useEffect, useCallback, useRef } from 'react'; // make API calls and pass the returned data via dispatch export const useFetch = (data, dispatch) => { useEffect(() => { dispatch({ type: 'FETCHING_IMAGES', fetching: true }); fetch(`https://picsum.photos/v2/list?page=${data.page}&limit=10`) .then(data => data.json()) .then(images => { dispatch({ type: 'STACK_IMAGES', images }); dispatch({ type: 'FETCHING_IMAGES', fetching: false }); }) .catch(e => { dispatch({ type: 'FETCHING_IMAGES', fetching: false }); return e; }) }, [dispatch, data.page]) } // next code block here useFetch钩子接受一个调度函数和一个数据对象。 调度函数将来自 API 调用的数据传递给App组件,而数据对象让我们更新 API 端点 URL。
// infinite scrolling with intersection observer export const useInfiniteScroll = (scrollRef, dispatch) => { const scrollObserver = useCallback( node => { new IntersectionObserver(entries => { entries.forEach(en => { if (en.intersectionRatio > 0) { dispatch({ type: 'ADVANCE_PAGE' }); } }); }).observe(node); }, [dispatch] ); useEffect(() => { if (scrollRef.current) { scrollObserver(scrollRef.current); } }, [scrollObserver, scrollRef]); } // next code block here useInfiniteScroll钩子接受一个scrollRef和一个dispatch函数。 scrollRef帮助我们设置观察者,正如我们在实现它的部分中已经讨论的那样。 dispatch 函数提供了一种方法来触发更新 API 端点 URL 中的页码的操作。
// lazy load images with intersection observer export const useLazyLoading = (imgSelector, items) => { const imgObserver = useCallback(node => { const intObs = new IntersectionObserver(entries => { entries.forEach(en => { if (en.intersectionRatio > 0) { const currentImg = en.target; const newImgSrc = currentImg.dataset.src; // only swap out the image source if the new url exists if (!newImgSrc) { console.error('Image source is invalid'); } else { currentImg.src = newImgSrc; } intObs.unobserve(node); // detach the observer when done } }); }) intObs.observe(node); }, []); const imagesRef = useRef(null); useEffect(() => { imagesRef.current = document.querySelectorAll(imgSelector); if (imagesRef.current) { imagesRef.current.forEach(img => imgObserver(img)); } }, [imgObserver, imagesRef, imgSelector, items]) } useLazyLoading钩子接收一个选择器和一个数组。 选择器用于查找图像。 数组中的任何更改都会触发useEffect挂钩,该挂钩会在每个图像上设置观察者。
我们可以看到它与我们在src/App.js中的功能相同,我们已将其提取到一个新文件中。 现在的好处是我们可以动态地传递参数。 现在让我们在 App 组件中使用这些自定义钩子。
打开src/App.js 。 导入自定义钩子并删除我们为获取数据、无限滚动和图像延迟加载定义的函数。 留下 reducer 和我们使用useReducer的部分。 粘贴下面的代码。
// App.js // import custom hooks import { useFetch, useInfiniteScroll, useLazyLoading } from './customHooks' const imgReducer = (state, action) => { ... } // retain this const pageReducer = (state, action) => { ... } // retain this const [pager, pagerDispatch] = useReducer(pageReducer, { page: 0 }) // retain this const [imgData, imgDispatch] = useReducer(imgReducer,{ images:[], fetching: true }) // retain this let bottomBoundaryRef = useRef(null); useFetch(pager, imgDispatch); useLazyLoading('.card-img-top', imgData.images) useInfiniteScroll(bottomBoundaryRef, pagerDispatch); // retain the return block return ( ... ) 我们已经在无限滚动部分讨论了bottomBoundaryRef 。 我们将pager对象和imgDispatch函数传递给useFetch 。 useLazyLoading接受类名.card-img-top 。 注意. 包含在类名中。 通过这样做,我们不需要指定它document.querySelectorAll 。 useInfiniteScroll接受 ref 和 dispatch 函数来增加page的值。
此时对应的分支是04-custom-hooks。
结论
HTML 在提供漂亮的 API 来实现很酷的特性方面做得越来越好。 在这篇文章中,我们已经看到在 React 功能组件中使用交集观察器是多么容易。 在这个过程中,我们学习了如何使用 React 的一些钩子,以及如何编写自己的钩子。
资源
- “无限滚动 + 图像延迟加载”,Orji Chidi Matthew,GitHub
- “无限滚动、分页或“加载更多”按钮? 电子商务中的可用性调查结果,”Christian Holst,Smashing Magazine
- “Lorem Picsum”,大卫·马比和 Nijiko Yonskai
- “IntersectionObserver 的出现”,Surma,Web Fundamentals
- 我可以使用...
IntersectionObserver - “Intersection Observer API”,MDN 网络文档
- “组件和道具”,反应
- “
useCallback”,反应 - “
useReducer”,反应
