如何使用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

这将安装 bootstrapprop-typesreact-flagscountries-apinode-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 组件。 最终,您将多次使用 CountryCards 在您的应用程序中显示不同的标志和国家信息。

第 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 的状态。 您需要此状态属性来在内部跟踪当前活动的页面。

接下来,您将创建生成页码的方法。

imports 之后 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_PAGERIGHT_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,则返回从 1totalPages 的数字范围。 否则,您将返回页码数组,其中 LEFT_PAGERIGHT_PAGE 分别位于页面溢出到左侧和右侧的位置。

但是,请注意您的分页控件确保第一页和最后一页始终可见。 左右页面控件向内显示。

现在,您将添加 render() 方法以使您能够呈现分页控件。

Pagination 类中,在 constructorfetchPageNumbers 方法之后,添加以下 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">&laquo;</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">&raquo;</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 类中,在 constructorfetchPageNumbers 方法和 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 组件

现在您有了 CountryCardPagination 组件,您可以在 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 类中,但在 componentDidMountonPageChanged 之后,添加以下 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 主题页面 以了解练习和编程项目。