如何使用React构建自定义分页
介绍
我们经常参与构建需要从远程服务器、API 或数据库获取大量数据记录的 Web 应用程序。 例如,如果您正在构建一个支付系统,它可能会获取数千笔交易。 如果它是一个社交媒体应用程序,它可能会获取许多用户评论、个人资料或活动。 无论是哪种情况,有几种解决方案可以以不会压倒与应用程序交互的最终用户的方式呈现数据。
处理大型数据集的一种方法是使用 分页 。 当您预先知道数据集的大小(数据集中的记录总数)时,分页可以有效地工作。 其次,您仅根据最终用户与分页控件的交互从总数据集中加载所需的数据块。 这是用于在 Google 搜索中显示搜索结果的技术。
在本教程中,您将学习如何使用 React 构建自定义分页组件以对大型数据集进行分页。 您将构建世界各国的分页视图 - 一个已知大小的数据集。
这是您将在本教程中构建的内容的演示:
先决条件
要完成本教程,您需要:
- Node 安装在您的机器上。 步骤见如何安装Node.js并创建本地开发环境。
- create-react-app 命令行包为你的 React 应用程序创建样板代码。 如果您使用的是
npm < 5.2
,那么您可能需要安装create-react-app
作为全局依赖项。 - 最后,本教程假设您已经熟悉 React。 如果不是这样,您可以查看 如何在 React.js 系列中编码以了解有关 React 的更多信息。
本教程已使用 Node v14.2.0、npm
v6.14.4、react
v16.13.1 和 react-scripts
v3.4.1 进行了验证。
第 1 步 — 设置项目
使用 create-react-app
命令启动一个新的 React 应用程序。 您可以随意命名应用程序,但本教程将其命名为 react-pagination
:
npx create-react-app react-pagination
接下来,您将安装应用程序所需的依赖项。 首先,使用终端窗口导航到项目目录:
cd react-pagination
运行以下命令以安装所需的依赖项:
npm install bootstrap@4.1.0 prop-types@15.6.1 react-flags@0.1.13 countries-api@2.0.1 node-sass@4.14.1
这将安装 bootstrap
、prop-types
、react-flags
、countries-api
和 node-sass
。
您安装了 bootstrap
包作为应用程序的依赖项,因为您需要一些默认样式。 您还将使用 Bootstrap pagination
组件中的样式。
要在应用程序中包含 Bootstrap,请编辑 src/index.js
文件:
nano src/index.js
并在其他 import
语句之前添加以下行:
src/index.js
import "bootstrap/dist/css/bootstrap.min.css";
现在,Bootstrap 样式将在您的整个应用程序中可用。
您还安装了 react-flags
作为应用程序的依赖项。 为了从您的应用程序访问标志图标,您需要将图标图像复制到应用程序的 public
目录。
在 public
目录中创建一个 img
目录:
mkdir public/img
将flags
中的图片文件复制到img
:
cp -R node_modules/react-flags/vendor/flags public/img
这为您的应用程序提供了所有 react-flag
图像的副本。
现在您已经包含了一些依赖项,通过从 react-pagination
项目目录中使用 npm
运行以下命令来启动应用程序:
npm start
现在您已经启动了应用程序,可以开始开发了。 请注意,浏览器选项卡已经为您打开了 实时重新加载 功能,以便在您开发时与应用程序保持同步。
此时,应用程序视图应类似于以下屏幕截图:
您现在已准备好开始创建组件。
第 2 步 — 创建 CountryCard
组件
在此步骤中,您将创建 CountryCard
组件。 CountryCard
组件呈现给定国家的名称、地区和标志。
首先,我们在src
目录下创建一个components
目录:
mkdir src/components
然后,在src/components
目录下新建一个CountryCard.js
文件:
nano src/components/CountryCard.js
并向其中添加以下代码片段:
src/components/CountryCard.js
import React from 'react'; import PropTypes from 'prop-types'; import Flag from 'react-flags'; const CountryCard = props => { const { cca2: code2 = '', region = null, name = {} } = props.country || {}; return ( <div className="col-sm-6 col-md-4 country-card"> <div className="country-card-container border-gray rounded border mx-2 my-3 d-flex flex-row align-items-center p-0 bg-light"> <div className="h-100 position-relative border-gray border-right px-2 bg-white rounded-left"> <Flag country={code2} format="png" pngSize={64} basePath="./img/flags" className="d-block h-100" /> </div> <div className="px-3"> <span className="country-name text-dark d-block font-weight-bold">{ name.common }</span> <span className="country-region text-secondary text-uppercase">{ region }</span> </div> </div> </div> ) } CountryCard.propTypes = { country: PropTypes.shape({ cca2: PropTypes.string.isRequired, region: PropTypes.string.isRequired, name: PropTypes.shape({ common: PropTypes.string.isRequired }).isRequired }).isRequired }; export default CountryCard;
CountryCard
组件需要一个 country
属性,其中包含要渲染的国家/地区的数据。 如 CountryCard
组件的 propTypes
中所示,country
道具对象必须包含以下数据:
cca2
- 2 位国家代码region
- 国家地区(例如,“非洲”)name.common
- 国家的通用名称(例如,“尼日利亚”)
这是一个示例国家对象:
{ cca2: "NG", region: "Africa", name: { common: "Nigeria" } }
另外,请注意如何使用 react-flags
包渲染国家标志。 您可以查看 react-flags 文档 以了解有关所需道具以及如何使用包的更多信息。
您现在已经完成了一个单独的 CountryCard
组件。 最终,您将多次使用 CountryCard
s 在您的应用程序中显示不同的标志和国家信息。
第 3 步 — 创建 Pagination
组件
在此步骤中,您将创建 Pagination
组件。 Pagination
组件包含在分页控件上构建、渲染和切换页面的逻辑。
在src/components
目录下新建Pagination.js
文件:
nano src/components/Pagination.js
并向其中添加以下代码片段:
src/components/Pagination.js
import React, { Component, Fragment } from 'react'; import PropTypes from 'prop-types'; class Pagination extends Component { constructor(props) { super(props); const { totalRecords = null, pageLimit = 30, pageNeighbours = 0 } = props; this.pageLimit = typeof pageLimit === 'number' ? pageLimit : 30; this.totalRecords = typeof totalRecords === 'number' ? totalRecords : 0; // pageNeighbours can be: 0, 1 or 2 this.pageNeighbours = typeof pageNeighbours === 'number' ? Math.max(0, Math.min(pageNeighbours, 2)) : 0; this.totalPages = Math.ceil(this.totalRecords / this.pageLimit); this.state = { currentPage: 1 }; } } Pagination.propTypes = { totalRecords: PropTypes.number.isRequired, pageLimit: PropTypes.number, pageNeighbours: PropTypes.number, onPageChanged: PropTypes.func }; export default Pagination;
Pagination
组件可以采用 propTypes
对象中指定的四个特殊道具。
onPageChanged
是一个函数,仅在当前页面发生变化时才使用当前分页状态的数据调用。totalRecords
表示要分页的记录总数。 这是必需的。pageLimit
表示每页显示的记录数。 如果未指定,则默认为constructor()
中定义的30
。pageNeighbours
表示要在当前页面的每一侧显示的附加页码的数量。 最小值为0
,最大值为2
。 如果未指定,则默认为constructor()
中定义的0
。
下图说明了 pageNeighbours
属性不同值的效果:
在 constructor()
函数中,您计算总页数如下:
this.totalPages = Math.ceil(this.totalRecords / this.pageLimit);
请注意,您在此处使用 Math.ceil()
以确保您获得一个整数值来表示总页数。 这也确保在最后一页中捕获多余的记录,尤其是在多余记录的数量少于每页要显示的记录数量的情况下。
最后,您初始化了将 currentPage
属性设置为 1
的状态。 您需要此状态属性来在内部跟踪当前活动的页面。
接下来,您将创建生成页码的方法。
在 import
s 之后 Pagination
类之前,添加以下常量和 range
函数:
src/components/Pagination.js
// ... const LEFT_PAGE = 'LEFT'; const RIGHT_PAGE = 'RIGHT'; /** * Helper method for creating a range of numbers * range(1, 5) => [1, 2, 3, 4, 5] */ const range = (from, to, step = 1) => { let i = from; const range = []; while (i <= to) { range.push(i); i += step; } return range; }
在 Pagination
类中,在 constructor
之后,添加以下 fetchPageNumbers
方法:
src/components/Pagination.js
class Pagination extends Component { // ... /** * Let's say we have 10 pages and we set pageNeighbours to 2 * Given that the current page is 6 * The pagination control will look like the following: * * (1) < {4 5} [6] {7 8} > (10) * * (x) => terminal pages: first and last page(always visible) * [x] => represents current page * {...x} => represents page neighbours */ fetchPageNumbers = () => { const totalPages = this.totalPages; const currentPage = this.state.currentPage; const pageNeighbours = this.pageNeighbours; /** * totalNumbers: the total page numbers to show on the control * totalBlocks: totalNumbers + 2 to cover for the left(<) and right(>) controls */ const totalNumbers = (this.pageNeighbours * 2) + 3; const totalBlocks = totalNumbers + 2; if (totalPages > totalBlocks) { const startPage = Math.max(2, currentPage - pageNeighbours); const endPage = Math.min(totalPages - 1, currentPage + pageNeighbours); let pages = range(startPage, endPage); /** * hasLeftSpill: has hidden pages to the left * hasRightSpill: has hidden pages to the right * spillOffset: number of hidden pages either to the left or to the right */ const hasLeftSpill = startPage > 2; const hasRightSpill = (totalPages - endPage) > 1; const spillOffset = totalNumbers - (pages.length + 1); switch (true) { // handle: (1) < {5 6} [7] {8 9} (10) case (hasLeftSpill && !hasRightSpill): { const extraPages = range(startPage - spillOffset, startPage - 1); pages = [LEFT_PAGE, ...extraPages, ...pages]; break; } // handle: (1) {2 3} [4] {5 6} > (10) case (!hasLeftSpill && hasRightSpill): { const extraPages = range(endPage + 1, endPage + spillOffset); pages = [...pages, ...extraPages, RIGHT_PAGE]; break; } // handle: (1) < {4 5} [6] {7 8} > (10) case (hasLeftSpill && hasRightSpill): default: { pages = [LEFT_PAGE, ...pages, RIGHT_PAGE]; break; } } return [1, ...pages, totalPages]; } return range(1, totalPages); } }
在这里,您首先定义两个常量:LEFT_PAGE
和 RIGHT_PAGE
。 这些常量将用于指示您具有分别向左和向右移动的页面控件的点。
您还定义了一个帮助器 range()
函数,可以帮助您生成数字范围。
注意:如果你在项目中使用了Lodash这样的实用程序库,那么你可以使用Lodash提供的_.range()
函数代替。 以下代码片段显示了您刚刚定义的 range()
函数与 Lodash 中的函数之间的区别:
range(1, 5); // returns [1, 2, 3, 4, 5] _.range(1, 5); // returns [1, 2, 3, 4]
接下来,您在 Pagination
类中定义了 fetchPageNumbers()
方法。 此方法处理用于生成要在分页控件上显示的页码的核心逻辑。 您希望第一页和最后一页始终可见。
首先,您定义了几个变量。 totalNumbers
表示将在控件上显示的总页码。 totalBlocks
表示要显示的总页码加上左右页指示器的两个附加块。
如果 totalPages
不大于 totalBlocks
,则返回从 1
到 totalPages
的数字范围。 否则,您将返回页码数组,其中 LEFT_PAGE
和 RIGHT_PAGE
分别位于页面溢出到左侧和右侧的位置。
但是,请注意您的分页控件确保第一页和最后一页始终可见。 左右页面控件向内显示。
现在,您将添加 render()
方法以使您能够呈现分页控件。
在 Pagination
类中,在 constructor
和 fetchPageNumbers
方法之后,添加以下 render
方法:
src/components/Pagination.js
class Pagination extends Component { // ... render() { if (!this.totalRecords || this.totalPages === 1) return null; const { currentPage } = this.state; const pages = this.fetchPageNumbers(); return ( <Fragment> <nav aria-label="Countries Pagination"> <ul className="pagination"> { pages.map((page, index) => { if (page === LEFT_PAGE) return ( <li key={index} className="page-item"> <a className="page-link" href="#" aria-label="Previous" onClick={this.handleMoveLeft}> <span aria-hidden="true">«</span> <span className="sr-only">Previous</span> </a> </li> ); if (page === RIGHT_PAGE) return ( <li key={index} className="page-item"> <a className="page-link" href="#" aria-label="Next" onClick={this.handleMoveRight}> <span aria-hidden="true">»</span> <span className="sr-only">Next</span> </a> </li> ); return ( <li key={index} className={`page-item${ currentPage === page ? ' active' : ''}`}> <a className="page-link" href="#" onClick={ this.handleClick(page) }>{ page }</a> </li> ); }) } </ul> </nav> </Fragment> ); } }
在这里,您通过调用之前创建的 fetchPageNumbers()
方法生成页码 array
。 然后使用 Array.prototype.map()
渲染每个页码。 请注意,您在每个呈现的页码上注册点击事件处理程序来处理点击。
另外,请注意,如果 totalRecords
属性未正确传递给 Pagination
组件或只有 1
页面,则不会呈现分页控件。
最后,您将定义事件处理程序方法。
在 Pagination
类中,在 constructor
和 fetchPageNumbers
方法和 render
方法之后,添加以下内容:
src/components/Pagination.js
class Pagination extends Component { // ... componentDidMount() { this.gotoPage(1); } gotoPage = page => { const { onPageChanged = f => f } = this.props; const currentPage = Math.max(0, Math.min(page, this.totalPages)); const paginationData = { currentPage, totalPages: this.totalPages, pageLimit: this.pageLimit, totalRecords: this.totalRecords }; this.setState({ currentPage }, () => onPageChanged(paginationData)); } handleClick = page => evt => { evt.preventDefault(); this.gotoPage(page); } handleMoveLeft = evt => { evt.preventDefault(); this.gotoPage(this.state.currentPage - (this.pageNeighbours * 2) - 1); } handleMoveRight = evt => { evt.preventDefault(); this.gotoPage(this.state.currentPage + (this.pageNeighbours * 2) + 1); } }
您定义修改状态并将 currentPage
设置为指定页面的 gotoPage()
方法。 它确保 page
参数的最小值为 1
,最大值为总页数。 它最终调用了作为 prop 传入的 onPageChanged()
函数,其中的数据指示了新的分页状态。
当组件挂载时,通过调用 this.gotoPage(1)
进入第一页,如 componentDidMount()
生命周期方法所示。
请注意如何在 handleMoveLeft()
和 handleMoveRight()
中使用 (this.pageNeighbours * 2)
根据当前页码分别向左和向右滑动页码。
这是一个从左到右移动交互的演示。
您现在已经完成了 Pagination
组件。 用户将能够与此组件中的导航控件交互以显示不同页面的标志。
第 4 步 — 构建 App
组件
现在您有了 CountryCard
和 Pagination
组件,您可以在 App
组件中使用它们。
修改src
目录下的App.js
文件:
nano src/App.js
将 App.js
的内容替换为以下代码行:
src/App.js
import React, { Component } from 'react'; import Countries from 'countries-api'; import './App.css'; import Pagination from './components/Pagination'; import CountryCard from './components/CountryCard'; class App extends Component { state = { allCountries: [], currentCountries: [], currentPage: null, totalPages: null } componentDidMount() { const { data: allCountries = [] } = Countries.findAll(); this.setState({ allCountries }); } onPageChanged = data => { const { allCountries } = this.state; const { currentPage, totalPages, pageLimit } = data; const offset = (currentPage - 1) * pageLimit; const currentCountries = allCountries.slice(offset, offset + pageLimit); this.setState({ currentPage, currentCountries, totalPages }); } } export default App;
在这里,您使用以下属性初始化 App
组件的状态:
allCountries
- 这是您应用中所有国家/地区的数组。 初始化为空数组 ([]
)。currentCountries
- 这是当前活动页面上显示的所有国家/地区的数组。 初始化为空数组 ([]
)。currentPage
- 当前活动页面的页码。 初始化为null
。totalPages
- 所有国家记录的总页数。 初始化为null
。
接下来,在 componentDidMount()
生命周期方法中,您通过调用 Countries.findAll()
使用 countries-api
包获取所有世界国家。 然后更新应用程序状态,设置 allCountries
以包含所有世界国家。 您可以查看 countries-api 文档 以了解有关该软件包的更多信息。
最后,您定义了 onPageChanged()
方法,每次您从分页控件导航到新页面时都会调用该方法。 此方法将传递给 Pagination
组件的 onPageChanged
属性。
在这个方法中有两行值得关注。 首先是这一行:
const offset = (currentPage - 1) * pageLimit;
offset
值表示获取当前页面记录的起始索引。 使用 (currentPage - 1)
可确保偏移量从零开始。 例如,假设您正在每页显示 25
条记录,并且您当前正在查看页面 5
。 那么 offset
将是 ((5 - 1) * 25 = 100)
。
例如,如果您从数据库中按需获取记录,这是一个示例 SQL 查询,向您展示如何使用偏移量:
SELECT * FROM `countries` LIMIT 100, 25
由于您没有从数据库或任何外部来源获取记录,因此您需要一种方法来提取要为当前页面显示的所需记录块。
第二个是这一行:
const currentCountries = allCountries.slice(offset, offset + pageLimit);
在这里,您使用 Array.prototype.slice()
方法从 allCountries
中提取所需的记录块,方法是将 offset
作为切片的起始索引,将 (offset + pageLimit)
作为切片的起始索引结束切片的索引。
注意: 在本教程中,您没有从任何外部来源获取记录。 在实际应用程序中,您可能会从数据库或 API 中获取记录。 获取记录的逻辑可以进入 App
组件的 onPageChanged()
方法。
假设您有一个虚构的 API 端点 /api/countries?page={current_page}&limit={page_limit}
。 以下片段显示了如何使用 axios HTTP 包从 API 按需获取国家/地区:
onPageChanged = data => { const { currentPage, totalPages, pageLimit } = data; axios.get(`/api/countries?page=${currentPage}&limit=${pageLimit}`) .then(response => { const currentCountries = response.data.countries; this.setState({ currentPage, currentCountries, totalPages }); }); }
现在,您可以通过添加 render()
方法来完成 App
组件。
在 App
类中,但在 componentDidMount
和 onPageChanged
之后,添加以下 render
方法:
src/App.js
class App extends Component { // ... other methods here ... render() { const { allCountries, currentCountries, currentPage, totalPages } = this.state; const totalCountries = allCountries.length; if (totalCountries === 0) return null; const headerClass = ['text-dark py-2 pr-4 m-0', currentPage ? 'border-gray border-right' : ''].join(' ').trim(); return ( <div className="container mb-5"> <div className="row d-flex flex-row py-5"> <div className="w-100 px-4 py-5 d-flex flex-row flex-wrap align-items-center justify-content-between"> <div className="d-flex flex-row align-items-center"> <h2 className={headerClass}> <strong className="text-secondary">{totalCountries}</strong> Countries </h2> { currentPage && ( <span className="current-page d-inline-block h-100 pl-4 text-secondary"> Page <span className="font-weight-bold">{ currentPage }</span> / <span className="font-weight-bold">{ totalPages }</span> </span> ) } </div> <div className="d-flex flex-row py-4 align-items-center"> <Pagination totalRecords={totalCountries} pageLimit={18} pageNeighbours={1} onPageChanged={this.onPageChanged} /> </div> </div> { currentCountries.map(country => <CountryCard key={country.cca3} country={country} />) } </div> </div> ); } }
在 render()
方法中,您渲染国家总数、当前页面、总页面数、<Pagination>
控件,然后是每个国家的 <CountryCard>
当前页面。
请注意,您将之前定义的 onPageChanged()
方法传递给了 <Pagination>
控件的 onPageChanged
属性。 这对于从 Pagination
组件捕获页面更改非常重要。 您还在每页显示 18
个国家/地区。
此时,应用程序将如下图所示:
您现在有一个显示多个 CountryCard
组件的 App
组件和一个将内容分解为单独页面的 Pagination
组件。 接下来,您将探索应用程序的样式。
第 5 步 — 添加自定义样式
您可能已经注意到,您已经在之前创建的组件中添加了一些自定义类。 让我们在 src/App.scss
文件中为这些类定义一些样式规则。
nano src/App.scss
App.scss
文件将如下所示:
src/App.scss
/* Declare some variables */ $base-color: #ced4da; $light-background: lighten(desaturate($base-color, 50%), 12.5%); .current-page { font-size: 1.5rem; vertical-align: middle; } .country-card-container { height: 60px; cursor: pointer; position: relative; overflow: hidden; } .country-name { font-size: 0.9rem; } .country-region { font-size: 0.7rem; } .current-page, .country-name, .country-region { line-height: 1; } // Override some Bootstrap pagination styles ul.pagination { margin-top: 0; margin-bottom: 0; box-shadow: 0 0 5px rgba(0, 0, 0, 0.1); li.page-item.active { a.page-link { color: saturate(darken($base-color, 50%), 5%) !important; background-color: saturate(lighten($base-color, 7.5%), 2.5%) !important; border-color: $base-color !important; } } a.page-link { padding: 0.75rem 1rem; min-width: 3.5rem; text-align: center; box-shadow: none !important; border-color: $base-color !important; color: saturate(darken($base-color, 30%), 10%); font-weight: 900; font-size: 1rem; &:hover { background-color: $light-background; } } }
修改您的 App.js
文件以引用 App.scss
而不是 App.css
。
注意:有关此的更多信息,请参阅 Create React App 文档。
nano src/App.js
src/App.js
import React, { Component } from 'react'; import Countries from 'countries-api'; import './App.scss'; import Pagination from './components/Pagination'; import CountryCard from './components/CountryCard';
添加样式后,应用程序现在将如下图所示:
您现在拥有一个带有额外自定义样式的完整应用程序。 您可以使用自定义样式来修改和增强 Bootstrap 等库提供的任何默认样式。
结论
在本教程中,您在 React 应用程序中创建了一个自定义分页小部件。 尽管您在本教程中没有调用任何 API 或与任何数据库后端进行交互,但您的应用程序可能需要此类交互。 您绝不会受限于本教程中使用的方法——您可以根据您的应用程序的要求对其进行扩展。
有关本教程的完整源代码,请查看 GitHub 上的 build-react-pagination-demo 存储库。 您还可以在 Code Sandbox 上获得本教程的 现场演示。
如果您想了解有关 React 的更多信息,请查看我们的 如何在 React.js 中编码,或查看 我们的 React 主题页面 以了解练习和编程项目。