Pagination
是一个分页元件,当页面中一次要载入过多的资料时,载入及渲染将会花费更多的时间,因此,考虑分批载入资料的时候,需要分页元件来帮助我们在不同页面之间切换。
我们可以看到一个 Pagination 元件在 MUI 及 Antd 各有不同的 props 来帮助我们调整页面上的呈现,但是要决定一个 pagination 当下的状态有几个必定需要的参数,不过看了 MUI 以及 Antd 发现他们决定当下状态的参数略有不同
MUI
Antd
透过观察这些 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 有几种可能
其他栏位如下:
栏位 | 说明 | 类型 |
---|---|---|
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
>>: Day22:22 - 结帐服务(6) - 前端 - 结帐 X PayPal付款
找到股价站上 20 周线只是第一步,不是一站上就会开始飙升,我还会搭配价位突破「箱型区间」,这个突破...
前言 铁人赛进入第十八天,今天原本是要开始讲网页前端的部分 没想到碰到自己挖的坑...所以这篇变成踩...
之前我们有说过,再调用函式的时候,浏览器会传递隐藏的参数给我们函式 一个是「this」,除了 th...
前言 每个程序语言都有属於它独一无二的诞生故事,毕竟以 JavaScript 的历史背景来讲是还满有...
https://tw40210.github.io/Real-time-pop-music-acc...