iT邦幫忙

2021 iThome 鐵人賽

DAY 23
0

元件介紹

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


上一篇
【Day22】導航元件 - Tabs
下一篇
【Day24】反饋元件 - Spin
系列文
30 天擁有一套自己手刻的 React UI 元件庫30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言