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