【Day23】导航元件 - Pagination

元件介绍

Pagination 是一个分页元件,当页面中一次要载入过多的资料时,载入及渲染将会花费更多的时间,因此,考虑分批载入资料的时候,需要分页元件来帮助我们在不同页面之间切换。

参考设计 & 属性分析

我们可以看到一个 Pagination 元件在 MUI 及 Antd 各有不同的 props 来帮助我们调整页面上的呈现,但是要决定一个 pagination 当下的状态有几个必定需要的参数,不过看了 MUI 以及 Antd 发现他们决定当下状态的参数略有不同

MUI

  • page: 当前页数
  • count: 总页数

Antd

  • current: 当前页数 (同 MUI 的 page)
  • pageSize: 每页有几笔资料
  • total: 数据总比数

透过观察这些 props 的设计,我觉得 MUI 在 props 与 UI 上面的对应会比较直觉,透过 props 可以知道页面会有几个分页,当前是第几页:

不过 MUI pagination 对於资料方面我觉得会需要前端再另外花功夫处理,因为其实我们比较常见的 API pagination 设计会是下面这样的形状:

GET /posts?page=2&limit=20

所以我觉得 Antd 的 props 设计,在我的经验当中,我觉得会跟 API 的设计比较一致,在资料串接上面可以少一些参数的转换,因为多一层参数的转换其实也容易增加我们出错的机率。

运算逻辑与样式分离

为了让开发者做到更进阶的客制化,MUI 推出了 usePagination() 这个 custom hook 将运算逻辑与渲染样式做分离,我觉得这个设计很值得令人学习。

我自己也是之前有遇过类似的情境,在 project 过去的 legacy code 当中,新的页面有一个元件跟过去的元件运算逻辑明明一模一样,但是因为样式上的差异导致共用过去的元件很不容易,结果同样的东西被硬生生刻了两次;所以如果把同样的逻辑抽出去做成 custom hook,这样在运算逻辑上就能够共用,而且页面的样式也能够比较弹性。

const { items } = usePagination({
  count: 20,
});

console.log('items: ', items);

我们用 console.log 把 usePagination() 回传的参数印出来看一下,并且对照一下画面:


基本上印出来的资料跟画面上看到的节点是一致的,item type 有几种可能

  • page:页数节点
  • previous:上一页按钮
  • next:下一页按钮
  • start-ellipsis:左侧被省略的节点
  • end-ellipsis:右侧被省略的节点

其他栏位如下:

栏位 说明 类型
type 节点种类 page, previous, next, start-ellipsis, end-ellipsis
selected 是否被选取 boolean
disabled 是否被禁用 boolean
onClick 点击事件 function
page 页数 number
aria-current 无障碍网站设计使用,表示当前项目的元素

透过这些栏位的设计,我们就能够描述一个节点的类型、外观、状态以及触发事件。

实作前构想

透过以上的观察,我们开始会对我们要实作的 Pagination 有一些想像,首先,如先前提到的一样,我的情境是,我还是比较喜欢 Antd 对於他参数的设计,因为比较符合我使用 pagination 的习惯,但是,其实我是还蛮喜欢 MUI 这种把 pagination 的逻辑往外抽出成独立的 custom hook 的想法,所以,到底要怎麽选择才好呢?根据「我全都要原则」(自己乱屁的原则 XD),不如我们来试试看把两种想法合一吧!我们一样用 Antd 参数的设计,同时也做一个符合这个参数的 usePagination。

介面设计

属性 说明 类型 默认值
className 客制化样式 string
themeColor 主题配色 primary, secondary, 色票 primary
defaultCurrent 预设的当前页面码 number 1
pageSize 每一页资料笔数 number 20
withEllipsis 页数过多是否省略 boolean false
onChange 页码以及 pageSize 改变时的 callback ({ current, pageSize }) => void

元件实作

我对 Pagination 的想像如下,最基本型我会有三个参数,当前页面(defaultCurrent)每页资料笔数(pageSize)资料总笔数(total),由於我们的 defaultCurrent 以及 pageSize 都能够给定预设值,所以我们只要像下面这样就能够展示出一个基本的 Pagination:

<Pagination
  total={100}
  onChange={handleOnChange}
/>

usePagination

要做 usePagination 之前,我们来想一下我们需要哪些东西。

首先,透过 pageSize 以及 total 的计算,我们能够得知总共有多少页

const totalPage = Math.ceil(total / pageSize);

我们用 Math.ceil 是让 total 跟 pageSize 相除之後我们要无条件进位,因为就算最後一页的资料笔数不满一页,还是要算一页。

再来我们把这每一页都存成一个节点资料,每一笔资料里面我们需要知道页码是否为当前页,以及点击这个节点的时候触发的 onClick 事件,因为 Pagination 不只需要上一页、下一页,我们还是需要点击那个页码的时候,可以直接跳到那一页。

那我们预期产生出来的资料会如下:

const items = [
  { page: 1, isCurrent: true, onClick: () => {...} },
  { page: 2, isCurrent: false, onClick: () => {...} },
  { page: 3, isCurrent: false, onClick: () => {...} },
  { page: 4, isCurrent: false, onClick: () => {...} },
  { page: 5, isCurrent: false, onClick: () => {...} },
  ...
];

我们得到总页数之後,透过简单的迭代,就能够产生上面的资料,如下:

const [current, setCurrent] = useState(defaultCurrent);

const items = [...Array(totalPage).keys()]
  .map((key) => key + 1) // 页数从 1 开始
  .map((page) => ({
    isCurrent: current === page,
    page,
    onClick: () => setCurrent(page),
  }));

因为我们当前页码是用一个 state 在 usePagination 里面控制,所以我们点击上一页、下一页的 function 也可以写在 usePagination 里面,这样如果要上下一页切换的话,只要呼叫这两个 function 就可以了。

上一页和下一页的 function 也很简单,下一页就是 current 一直加一,直到最後一页为止,上一页也是一样,就是 current 一直减一,直到第一页为止:

const handleClickNext = () => {
  const nextCurrent = current + 1 > totalPage ? totalPage : current + 1;
  setCurrent(nextCurrent);
};

const handleClickPrev = () => {
  const prevCurrent = current - 1 < 1 ? 1 : current - 1;
  setCurrent(prevCurrent);
};

到目前为止我们阳春的 usePagination 就已经搞定,完整程序码如下:

export const usePagination = ({
  defaultCurrent = 1,
  pageSize = 20,
  total,
}) => {
  const [current, setCurrent] = useState(defaultCurrent);
  const totalPage = Math.ceil(total / pageSize);
  const items = [...Array(totalPage).keys()]
    .map((key) => key + 1)
    .map((page) => ({
      isCurrent: current === page,
      page,
      onClick: () => setCurrent(page),
    }));

  const handleClickNext = () => {
    const nextCurrent = current + 1 > totalPage ? totalPage : current + 1;
    setCurrent(nextCurrent);
  };

  const handleClickPrev = () => {
    const prevCurrent = current - 1 < 1 ? 1 : current - 1;
    setCurrent(prevCurrent);
  };

  return {
    items,
    current,
    totalPage,
    handleClickNext,
    handleClickPrev,
  };
};

Pagination

搞定 usePagination 之後,我们就能够来实作 Pagination 的本体了,由於 usePagination 已经帮我们搞定大部分的逻辑,所以其实 Pagination 里面就只需要做一些简单的排版布局、样式调整就可以了,大致上的架构会如下,主要就是三个部分,上一页按钮每一页的 page 节点下一页按钮

const {
  items,
  handleClickNext,
  handleClickPrev,
} = usePagination({ defaultCurrent, pageSize, total });


<StyledPagination>
  <PreviousButton onClick={handleClickPrev} />
  {
    items.map((item) => (
      <StyledItem
        key={item.page}
        $isCurrent={item.isCurrent}
        onClick={item.onClick}
      >
        <span>{item.page}</span>
      </StyledItem>
    ))
  }
  <NextButton onClick={handleClickPrev} />
</StyledPagination>

当然我们上述的参数都是由 usePagination 取得,所以 Pagination 内部其实就会变得很简洁。

到目前为止我们的阳春 Pagination 就已经搞定啦!会一直说他阳春是因为我没有做什麽样式的修饰,也没有考虑一些加值功能,例如可能页数太多的时候怎麽处理、可以改变 pageSize ...等等的功能。

下面展示一下成果:

Pagination 简单实测

为了简化,我们先假设情境是资料一次全部载入前端之後,在前端做分页。当然实务上因为我们资料笔数很多,所以应该是分页载入前端是比较好的做法,但我为了展示用,先不要做这麽复杂。

首先我来产生一些假资料,假定一页是 20 笔资料,那我希望总页数是 6 页,最後一页只有少数不满一页的资料,所以我给他 total 是 102,我们来产生 102 笔的资料:

const defaultCurrent = 1;
const pageSize = 20;

const fakeData = [...Array(102).keys()].map((key) => ({
  id: key,
  title: `Index: ${key}`,
}));

再来我希望在当前页码改变的时候,我能够拿出在这 102 笔当中,当前那一页的 20 笔。
所以首先我需要在 onChange 的时候拿到当前页码,因此在 Pagination 内部会有一个这样的 useEffect 来处理,意思就是当 current page 改变的时候我要执行一次 onChange 来让外面使用 Pagination 的地方拿到更新的 current page:

useEffect(() => {
  onChange({
    current,
  });
}, [current]);

接着,在 onChange 被呼叫的时候,因为当前页码改变了,所以我们要筛选出在当前那一页的资料,我的作法是先算出最小索引以及最大索引,然後对这个 fakeData 做筛选。

以第一页来举例,最小索引就是 0,最大索引就是 19;
第二页来说,最小索引是 20,最大索引是 39,
依此类推,我们就能够找出计算索引的公式:

const [dataSource, setDataSource] = useState([]);

const handleOnChange = ({ current }) => {
  const max = current * pageSize;
  const min = max - pageSize + 1;
  setDataSource(fakeData.filter((data, index) => index + 1 >= min && index + 1 <= max));
};

搞定完资料之後,画面就是拿到什麽就渲染什麽,为了方便展示跟观察,我就直接把索引当作资料内容:

<StyledWrapper>
  <div style={{ height: 650 }}>
    {dataSource.map((data) => (
      <DataItem key={data.id}>
        <div>{data.title}</div>
      </DataItem>
    ))}
  </div>
  <Pagination
    defaultCurrent={defaultCurrent}
    pageSize={pageSize}
    total={fakeData.length}
    onChange={handleOnChange}
  />
</StyledWrapper>

这边就是我们展示的成果了,看起来虽然简易,但也是有模有样的:

Custom style

如果就只是阳春的来结尾,感觉会留下一些遗憾,所以这边我们也简单做一些样式的美化,我拿 MUI 的样式做范本,稍微调整一下 CSS,然後跟前一些篇章一样,我们可以用 themeColor 这个 props 来客制化他的颜色,详细的做法我一样会附上程序码,因为只有稍微调整一下 CSS,和换一下 Icon,就不做详细说明,直接来展示结果,证明我们的 Pagination 是可以让你轻易随意调整样式的:

页数太多要省略节点

最後我们再来处理一个情境,因为我们会用到 Pagination,通常就是我们资料太多,不想要在一页里面全部呈现出来才会使用,所以很可能遇到页数爆棚的情况,特别在行动装置流行的当代,如果页数爆棚真的是有点困扰,窄窄的手机萤幕可能塞不下,如下示意:

当然做法会很多种,这边我跟大家分享我的作法,
我希望缩短的方式,是留头尾,然後留 current - 1, current, current + 1 这几个 page,其他的都省略。

首先我们在资料面上先做一些标记,我跑一个回圈,把想要留下来的标示为 type: 'page',其他的,若在 current 之後,则标示 end-ellipsis,反之,则标示 start-ellipsis

const ellipsisItems = items.map((item) => {
  const { page } = item;
  if (
    page === totalPage
    || page === 1
    || page === current
    || page === current + 1
    || page === current - 1
  ) {
    return item;
  }
  return {
    ...item,
    type: item.page > current ? 'end-ellipsis' : 'start-ellipsis',
  };
});


再来,因为我不需要那麽多重复的 start-ellipsis 以及 end-ellipsis,所以我们来把重复的过滤掉

const ellipsisItems = markedItems
  .filter((item, index) => {
    if (item.type === 'start-ellipsis' && markedItems[index + 1].type === 'start-ellipsis') {
      return false;
    }
    if (item.type === 'end-ellipsis' && markedItems[index + 1].type === 'end-ellipsis') {
      return false;
    }
    return true;
  });

资料处理完之後我们来处理画面:
如果他是 type: 'page' 的节点,我们就让他跟之前一样显示,如果是 ellipsis 的节点,我们就把他换成省略符号:

items.map((item) => {
  if (item.type === 'page') {
    return (
      <StyledItem
        key={item.page}
        $isCurrent={item.isCurrent}
        $color={color}
        onClick={item.onClick}
      >
        <span>{item.page}</span>
      </StyledItem>
    );
  }
  return (
    <div key={item.page}>
      ...
    </div>
  );
})

下面就是我们本篇的最终成品啦!


usePagination 元件原始码:
Source code

Pagination 元件原始码:
Source code

Storybook:
Pagination


<<:  DAY 24 优化检视团购讯息

>>:  Day22:22 - 结帐服务(6) - 前端 - 结帐 X PayPal付款

[Day27] 监视股价 - Watcher

找到股价站上 20 周线只是第一步,不是一站上就会开始飙升,我还会搭配价位突破「箱型区间」,这个突破...

DAY18 - 踩坑纪录 : 填了坑又有新的坑

前言 铁人赛进入第十八天,今天原本是要开始讲网页前端的部分 没想到碰到自己挖的坑...所以这篇变成踩...

Day.22 「让我们在更深入函式~」 —— JavaScript call & apply & arguments

之前我们有说过,再调用函式的时候,浏览器会传递隐藏的参数给我们函式 一个是「this」,除了 th...

从 JavaScript 角度学 Python(2) - 历史

前言 每个程序语言都有属於它独一无二的诞生故事,毕竟以 JavaScript 的历史背景来讲是还满有...

从零开始用github架设静态网站入门(1) - 介绍&环境搭建

https://tw40210.github.io/Real-time-pop-music-acc...