![[為你自己寫 Vue Component] AtomicPagination](https://ithelp.ithome.com.tw/upload/images/20240917/20120484i2jSodLRtU.png)
在 ERP 系統的列表頁面或部落格文章的總覽頁面,如果我們想要一次顯示大量資料,不僅會耗費大量的網路傳輸流量,也會使瀏覽器渲染畫面變得緩慢費時。為了解決這個問題,我們可能會選擇使用分頁的方式載入資料。這樣可以讓使用者不必一次取得所有資料,而是透過點擊指定分頁來查看特定區間的內容。
本篇的實作大量參考 React 的 Material-UI 的 Pagination 元件。

在開始實作前,我們先研究各個 UI Library 的 Pagination 元件是如何設計的。
Element Plus

<template>
  <ElPagination
    v-model:current-page="page"
    background
    layout="prev, pager, next"
    :total="1000"
  />
</template>
Element Plus 的 <ElPagination> 用法非常簡單,最特別的是其排版可依照需求調整,例如範例中的 layout 設定先顯示上一頁的按鈕,再來是頁碼,最後是下一頁的按鈕。
如果我們的 layout 設定為 prev, next, pager,排版就會變成「上一頁」、「下一頁」、「頁碼」。

Vuetify

<template>
  <VPagination v-model="page" :length="15" :total-visible="8" />
</template>
Vuetify 的 <VPagination> 也非常簡單使用。比較特別的設定是 totalVisible,可以設定頁碼按鈕顯示的總數量。不過目前看起來,這項設定只有在頁碼處於最前面或最後面的情況下,才會正確顯示。

另外 <VPagination> 的按鈕外觀設定繼承了 <VBtn> 的設定。如果需要定制按鈕樣式,可以透過 variant 這個 prop,接受的值與 <VBtn> 一致,開發人員能夠輕易地調整和統一整個網站的風格。
Nuxt UI

<template>
  <UPagination v-model="page" size="xl" :total="100" show-last show-first />
</template>
Nuxt UI 的 <UPagination> 也很簡單使用,特別的是 Nuxt UI 可以設定 showLast 與 showFirst,來控制是否顯示「第一頁」與「最後一頁」的按鈕。
在研究 <AtomicPagination> 的 props 應該如何命名時,我們需要一個 page 來表示當前頁面,並且要能夠雙向綁定。另外,關於總頁數的顯示,我們有兩個選擇,一個是用 count 來表示總頁數,另一個是用 perPage 和 total 來表示總資料數。
根據我的開發經驗,我們比較常接收到以下的資料格式:
const result = {
  page: 1,      // 當前頁碼
  perPage: 10,  // 每頁顯示資料數量
  total: 100,   // 總資料數
}
如果我們選擇使用 count,那麼我們就需要自己計算總頁數,在每個用到的地方都要進行計算。因此,我偏好使用 perPage 和 total 來計算總頁數,這樣我們可以在元件內部計算總頁數,而不需要在每個使用的地方自行計算。
綜合以上並結合自身經驗,我們統整出 <AtomicPagination> 的功能:
page 來控制當前頁碼。perPage 與 total 來計算總頁數。boundaryCount 來控制開頭與結尾固定顯示的頁碼數量。siblingCount 來控制當前頁面前後固定顯示的頁碼數量。hidePrevButton 與 hideNextButton 來控制是否隱藏「前一頁」、「下一頁」的按鈕。showFirstButton 與 showLastButton 來控制是否顯示「第一頁」、「最後一頁」的按鈕。disabled 來控制是否禁用整個元件。在這裡,我們希望「前一頁」、「下一頁」的預設值為
false,因此在 props 的命名上會選用hide開頭來表示是否隱藏;而「第一頁」、「最後一頁」的預設值為false,所以在 props 的命名上會選用show開頭來表示是否顯示。
使用結構如下:
<template>
  <AtomicPagination
    v-model:page="page"
    :perPage="10"
    :total="100"
    boundaryCount="1"
    siblingCount="1"
    hidePrevButton
    hideNextButton
    showFirstButton
    showLastButton
    disabled
  />
</template>
在實際元件中,
perPage也會設計為可以雙向綁定的屬性,不過這裡為了完全聚焦在核心功能上,所以省略了這部分。
首先,我們將需求中提到的功能整理成 props 的介面,我們會需要下列屬性:
| 屬性 | 型別 | 預設值 | 說明 | 
|---|---|---|---|
| page | number | - | 當前頁碼 | 
| perPage | number | - | 每頁顯示資料數量 | 
| total | number | - | 總資料數 | 
| boundaryCount | number | 1 | 開頭結尾各固定顯示幾頁面數量 | 
| siblingCount | number | 1 | 當前頁面前後固定顯示的頁碼數量 | 
| hidePrevButton | boolean | false | 是否隱藏「前一頁」按鈕 | 
| hideNextButton | boolean | false | 是否隱藏「下一頁」按鈕 | 
| showFirstButton | boolean | false | 是否顯示「第一頁」按鈕 | 
| showLastButton | boolean | false | 是否顯示「最後一頁」按鈕 | 
| disabled | boolean | false | 是否禁用 | 
接著我們就可以開始實作 <AtomicPagination> 元件。
<AtomicPagination>interface AtomicPaginationProps {
  page: number;
  perPage: number;
  total: number;
  boundaryCount?: number;
  siblingCount?: number;
  hideNextButton?: boolean;
  hidePrevButton?: boolean;
  showFirstButton?: boolean;
  showLastButton?: boolean;
  disabled?: boolean;
}
interface AtomicPaginationEmits {
  (event: 'update:page', value: number): void;
}
const props = withDefaults(defineProps<AtomicPaginationProps>(), {
  perPage: 10,
  boundaryCount: 1,
  siblingCount: 1,
});
const emit = defineEmits<AtomicPaginationEmits>();
接著我們需要計算總頁數:
const count = computed(() => Math.ceil(props.total / props.perPage));
在計算總頁數時,我們需要無條件進位,即使最後一頁資料不足 perPage 筆,我們仍需算作一頁。
接著我們來處理畫面的部分。頁碼按鈕部分,我們使用之前做的 <AtomicButton>。
<template>
  <nav
    aria-label="pagination navigation"
    class="atomic-pagination"
  >
    <ul class="atomic-pagination__container">
      <li
        v-for="(item, index) in count"
        :key="index"
      >
        <AtomicButton
          :aria-current="item === page ? 'true' : undefined"
          :aria-disabled="disabled"
          :aria-label="item === page ? undefined : `前往第 ${item} 頁`"
          class="atomic-pagination__button"
          :disabled="disabled"
          :variant="item === page ? 'contained' : 'outlined'"
          @click="emit('update:page', item)"
        >
          {{ item }}
        </AtomicButton>
      </li>
    </ul>
  </nav>
</template>
接著處理 SCSS 部分。因為使用了前面已經做好的 <AtomicButton>,所以在這裡我們需要處理的樣式並不複雜。
.pagination {
  &__container {
    display: flex;
    column-gap: 6px;
  }
  &__button {
    padding-right: 0;
    padding-left: 0;
    min-width: 36px;
    font-size: 0.75rem;
  }
}
接著依照 props 的設定來決定是否顯示「前一頁」、「下一頁」、「第一頁」、「最後一頁」的按鈕。
我們先處理「前一頁」、「下一頁」的按鈕。
<ul class="atomic-pagination__container">
  <li v-if="!hidePrevButton">
    <AtomicButton
      class="atomic-pagination__button atomic-pagination__button--previous"
      :disabled="disabled || page <= 1"
      variant="outlined"
      @click="emit('update:page', page - 1)"
    >
      <ArrowSvg
        fill="currentColor"
        height="16"
        width="16"
      />
    </AtomicButton>
  </li>
  <!-- 略 -->
  <li v-if="!hideNextButton">
    <AtomicButton
      class="atomic-pagination__button atomic-pagination__button--next"
      :disabled="disabled || page >= count"
      variant="outlined"
      @click="emit('update:page', page + 1)"
    >
      <ArrowSvg
        fill="currentColor"
        height="16"
        width="16"
      />
    </AtomicButton>
  </li>
</ul>
接著加上「第一頁」、「最後一頁」的按鈕。
<ul class="atomic-pagination__container">
  <li v-if="showFirstButton">
    <AtomicButton
      class="atomic-pagination__button atomic-pagination__button--first"
      :disabled="disabled || page <= 1"
      variant="outlined"
      @click="emit('update:page', 1)"
    >
      <DoubleArrowSvg
        fill="currentColor"
        height="16"
        width="16"
      />
    </AtomicButton>
  </li>
  <!-- 略 -->
  <li v-if="showLastButton">
    <AtomicButton
      class="atomic-pagination__button atomic-pagination__button--last"
      :disabled="disabled || page >= count"
      variant="outlined"
      @click="emit('update:page', count)"
    >
      <DoubleArrowSvg
        fill="currentColor"
        height="16"
        width="16"
      />
    </AtomicButton>
  </li>
</ul>
這樣我們就完成了最簡單的 <AtomicPagination> 元件。

以最簡單的 <AtomicPagination> 為例,除了原有的 10 頁按鈕外,我們還有「前一頁」、「下一頁」、「第一頁」、「最後一頁」的按鈕,共 14 個按鈕。如果資料量很大,按鈕數量就會變得非常多,導致頁面變得非常雜亂。
因此,我們需要對按鈕數量進行控制,當按鈕數量過多時,可以隱藏部分按鈕。

但要如何隱藏呢?在前面我們列出了 <AtomicPagination> 的 props,其中 boundaryCount 和 siblingCount 兩個 props,可以幫助我們控制按鈕數量。
如上圖所示,我們將 boundaryCount 設定為 1,siblingCount 設定為 1,這樣可以只顯示當前頁面前後各 1 頁,以及開頭和結尾各 1 頁,從而控制按鈕數量。
但如何計算呢?我們需要計算以下資訊:
在開始前,我們需要一個 util function 幫助我們生成指定範圍的數字陣列。
// https://dev.to/namirsab/comment/2050
const range = (start: number, end: number) => {
  const length = end - start + 1;
  return Array.from({ length }, (_, i) => start + i);
}
range 的功能是產生一個從 start 到 end 的數字陣列。
例如:
range(3, 5); // [3, 4, 5]
開頭固定顯示的頁碼
const startPages = computed(() => range(1, props.boundaryCount));
這樣假設 boundaryCount 為 3,startPages 就會是 [1, 2, 3]。
但在上面的假設下並且 count 為 2 時,算出的結果就不會是我們要的,因此當 boundaryCount 大於 count 時,我們需要將他的最大值限制為 count。
const startPages = computed(() => range(1, Math.min(props.boundaryCount, count.value)));
這樣當 count 為 2,boundaryCount 為 3 時,startPages 算出來就會是 [1, 2]。
結尾固定顯示的頁碼
const endPages = computed(() => range(count.value - props.boundaryCount + 1, count.value));
這樣當 count 為 20,boundaryCount 為 3 時,endPages 就會是 [18, 19, 20]。但如果 count 只有 2 時,endPages 就會是 [0, 1, 2]。
不僅出現了不合理的頁碼,還與 startPages 重複了。因此我們要將 endPages 的計算方式改為:
const endPages = computed(() => {
  return range(Math.max(count.value - props.boundaryCount + 1, props.boundaryCount + 1), count.value)
});
延續上面的範例,此時算出的 endPages 就會是空陣列 []。
當前頁面前與當前頁面前後顯示的頁碼
大多數情況下,這些頁碼可以很容易計算出來。例如當 siblingCount 為 1,page 為 5 時,siblingPages 就會是 [4, 5, 6]。
// 當前頁面前顯示的頁碼
const siblingsStart = computed(() => props.page - props.siblingCount);
// 當前頁面後顯示的頁碼
const siblingsEnd = computed(() => props.page + props.siblingCount);
我們很輕易就可以算出 siblingsStart 和 siblingsEnd 了對吧!對吧?
情境 1:
page 為 1。count 為 20。boundaryCount 為 1。siblingCount 為 1。此時算出的結果為:
startPages 為 [1]。endPages 為 [20]。siblingsStart 為 0。siblingsEnd 為 2。顯然這種算法不夠全面,我們需要避免當前頁面減去或加上 siblingCount 會超出頁碼範圍的問題。
const siblingsStart = computed(() => {
  return Math.max(props.page - props.siblingCount, props.boundaryCount + 1)
});
const siblingsEnd = computed(() => {
  return Math.max(
    props.page + props.siblingCount,
    // 原公式:props.boundaryCount + props.siblingCount + 1 + props.siblingCount
    props.boundaryCount + props.siblingCount * 2 + 1
  )
});
修正後的結果會是:
startPages 為 [1]。endPages 為 [20]。siblingsStart 為 2。siblingsEnd 為 4。這樣很好,我們來看看另一個情境。
情境 2:
page 為 20。count 為 20。boundaryCount 為 1。siblingCount 為 1。此時算出的結果為:
startPages 為 [1]。endPages 為 [20]。siblingsStart 為 19。siblingsEnd 為 21。siblingsEnd 超出了頁碼範圍!我們需要避免這個問題。
const siblingsEnd = computed(() => {
  return Math.min(
    Math.max(
      props.page + props.siblingCount,
      props.boundaryCount + props.siblingCount * 2 + 1
    ),
    count.value - props.boundaryCount
  )
});
const siblingsStart = computed(() => {
  return Math.max(
    Math.min(
      props.page - props.siblingCount,
      count.value - props.boundaryCount - props.siblingCount * 2
    ),
    props.boundaryCount + 1
  )
});
修正後得出的結果會是:
startPages 為 [1]。endPages 為 [20]。siblingsStart 為 17。siblingsEnd 為 19。到目前為止一切順利,我們已經可以推算出我們需要顯示的頁碼了,接下來只要判斷是否需要加上省略符號即可。
開頭固定顯示的頁碼到當前頁面前顯示的頁碼之間是否需要省略?
const needStartEllipsis = computed(() => {
  return siblingsStart.value > props.boundaryCount + 1;
});
const needEndEllipsis = computed(() => {
  return siblingsEnd.value < count.value - endPages.value.length;
});
這樣我們就可以知道是否需要加上省略符號了。
重新整合
看起來很棒,我們已經算出了所有需要的資訊,接下來可以重新整合我們的按鈕了。
<ul class="pagination__container">
  <!-- 上略 -->
  <li
    v-for="item in startPages"
    :key="item"
  >
    <!-- 按鈕 -->
  </li>
  <li v-if="needStartEllipsis">
    <span>
      … 
    </span>
  </li>
  <li
    v-for="item in range(siblingsStart, siblingsEnd)"
    :key="item"
  >
    <!-- 按鈕 -->
  </li>
  <li v-if="needEndEllipsis">
    <span>
      … 
    </span>
  </li>
  <li
    v-for="item in endPages"
    :key="item"
  >
    <!-- 按鈕 -->
  </li>
  <!-- 下略 -->
</ul>
加上 SCSS 即可完成:
.atomic-pagination {
  &__button {
    &--ellipsis {
      display: flex;
      height: 100%;
      width: 100%;
      align-items: center;
      justify-content: center;
    }
  }
}
現在的 <AtomicPagination> 已經可以應付大部分的情況了,但有沒有什麼部分可以做得更好的呢?
最顯而易見的是,「<template> 裡面重複的地方太多了」。另一個問題是當頁面不斷向下切換時,元素(按鈕與省略符號)的數量不總是固定的。如果能固定元素的數量,效果會更好。
這個問題容易解決,我們可以將所有要顯示的按鈕與省略符號整合成一個陣列,這樣就可以使用一個迴圈渲染出我們需要的按鈕。
首先,我們把所有計算屬性統整到一個 items 計算屬性裡面。
const items = computed(() => {
  const { 
    boundaryCount,
    siblingCount,
    showFirstButton,
    showLastButton,
    hidePrevButton,
    hideNextButton,
  } = props;
  const count = Math.ceil(props.total / props.perPage);
  const startPages = range(1, Math.min(boundaryCount, count))
  const endPages = range(
    Math.max(count - boundaryCount + 1, boundaryCount + 1),
    count
  )
  const siblingsEnd = Math.min(
    Math.max(
      page + siblingCount,
      boundaryCount + siblingCount * 2 + 1
    ),
    count - boundaryCount
  )
  const siblingsStart = Math.max(
    Math.min(
      page - siblingCount,
      count - boundaryCount - siblingCount * 2
    ),
    boundaryCount + 1
  )
  // 先省略 `needStartEllipsis` 與 `needEndEllipsis`
})
接著,我們組合出 _items,用來標示每個按鈕的意義。
const items = computed(() => {
  // 上略
  const _items = [
    ...(showFirstButton ? ['first'] : []),
    ...(hidePrevButton ? [] : ['previous']),
    ...startPages,
    // Start ellipsis
    ...(siblingsStart > boundaryCount + 1
      ? ['start-ellipsis']
      : []),
    // Sibling pages
    ...range(siblingsStart, siblingsEnd),
    // End ellipsis
    ...(siblingsEnd < count - boundaryCount
      ? ['end-ellipsis']
      : []),
    ...endPages,
    ...(hideNextButton ? [] : ['next']),
    ...(showLastButton ? ['last'] : []),
  ]
})
到這一步 _items 會像是這樣:
['first', 'previous', 1, 'start-ellipsis', 4, 5, 6, 'end-ellipsis', 10, 'next', 'last']
最後我們把 _items 轉換成按鈕上的各種屬性與事件。
const items = computed(() => {
  // 上略
  const buttonPage = (type: string) => {
    switch (type) {
      case 'first':
        return 1;
      case 'previous':
        return page - 1;
      case 'next':
        return page + 1;
      case 'last':
        return count;
      default:
        return null;
    }
  };
  return _items.map(item => {
    return isNumber(item)
      ? {
          onClick(): void {
            onChange?.(item);
          },
          type: 'page',
          page: item,
          selected: item === page,
          disabled,
          ariaCurrent: item === page ? 'true' : undefined,
        }
      : {
          onClick(): void {
            const page = buttonPage(item);
            page && onChange?.(page);
          },
          type: item,
          page: buttonPage(item),
          selected: false,
          disabled: 
            disabled ||
            (!item.includes('ellipsis') &&
              (item === 'next' || item === 'last'
                ? page >= count
                : page <= 1)),
        };
  })
})
我們有了 items 這個計算屬性,就可以在 <template> 裡使用 v-for 和 v-if 來渲染按鈕了。
<ul class="atomic-pagination__container">
  <li
    v-for="(item, index) in pagination"
    :key="index"
  >
    <template v-if="item.type === 'start-ellipsis' || item.type === 'end-ellipsis'">
      <span class="atomic-pagination__button atomic-pagination__button--ellipsis">
        …
      </span>
    </template>
    <template v-else>
      <AtomicButton
        :aria-current="item.ariaCurrent"
        :aria-disabled="item.disabled"
        class="atomic-pagination__button"
        :class="{
          'atomic-pagination__button--selected': item.selected,
          [`atomic-pagination__button--${item.type}`]: true,
        }"
        :disabled="item.disabled || undefined"
        :variant="item.selected ? 'contained' : 'outlined'"
        @click="item.onClick"
      >
        <template v-if="item.type === 'page'">
          {{ item.page }}
        </template>
        <template v-else>
          <component
            :is="ARROW_MAP[item.type]" 
            fill="currentColor"
            height="16"
            width="16"
          />
        </template>
      </AtomicButton>
    </template>
  </li>
</ul>
在這裡我們使用了 ARROW_MAP 這個物件,這個物件是用來對應按鈕的類型與對應的 SVG 圖示。
import ArrowSvg from '~/assets/svg/arrow.svg?component';
import DoubleArrowSvg from '~/assets/svg/double-arrow.svg?component';
const ARROW_MAP = {
  next: ArrowSvg,
  previous: ArrowSvg,
  first: DoubleArrowSvg,
  last: DoubleArrowSvg,
};
這裡使用了極端方式減少
<template>重複的部分,不過這樣的寫法可能會使<template>變得較難閱讀,使用時需要注意。
我們先來看看元素數量如何變化。


我們發現在第 4 頁(總頁數 10)時,不含頭尾元素的數量為 7;在第 1 頁時,元素數量為 6。另外我們發現一個問題,在第 4 頁時頁碼為:
[previous, 1, start-ellipsis, 3, 4, 5, end-ellipsis, 10, next]
其中 start-ellipsis 裡面只有第二頁而已,因此可以將 start-ellipsis 裡面的按鈕直接顯示出來。
數量的部分以上圖為例,我們知道最多的按鈕數量為 7,因此可以將按鈕數量固定為 7。當遇到按鈕數量不足的情況時,我們可以補上一個按鈕。
但如果多補上一個按鈕,表示 siblingsStart 和 siblingsEnd 需要進行相應調整。
const siblingsStart = Math.max(
  Math.min(
    page - siblingCount,
    count - boundaryCount - siblingCount * 2 - 1
  ),
  boundaryCount + 2
)
const siblingsEnd = Math.min(
  Math.max(
    page + siblingCount,
    boundaryCount + siblingCount * 2 + 2
  ),
  count - boundaryCount - 1
)
最後 ellipsis 部分也需要做相應調整。
const items = [
  // 上略
  // Start ellipsis
  ...(siblingsStart > boundaryCount + 2
    ? ['start-ellipsis']
    : boundaryCount + 1 < count - boundaryCount
    ? [boundaryCount + 1]
    : []),
  // Sibling pages
  ...range(siblingsStart, siblingsEnd),
  // End ellipsis
  ...(siblingsEnd < count - boundaryCount - 1
    ? ['end-ellipsis']
    : count - boundaryCount > boundaryCount
    ? [count - boundaryCount]
    : []),
  // 下略
]
看起來不錯,我們已經可以固定按鈕數量了!

當初看到這種設計方式時真的是驚為天人。很多時候我們無法好好使用一套 UI Library,主要原因之一就是其樣式幾乎被寫死了!當然,我們可以自己用 CSS 壓過原本的設定,但這樣對長期的維護來說真的是一個災難(回顧那些被 UI Library 卡死、完全無法升級的專案)。
如果我們可以把元件中的邏輯與樣式結構分開,那麼我們就可以更靈活地使用這些「邏輯」。
在剛剛的實作過程中,我們已經把所有的邏輯、所有需要的資料整合到一個 items 的計算屬性裡面,這部分可以抽取成獨立的 Composition Function。
const range = (start: number, end: number) => {
  const length = end - start + 1;
  return Array.from({ length }, (_, i) => start + i);
};
function usePagination (props: MaybeRefOrGetter<UsePaginationProps>) {
  return computed(() => {
    const {
      boundaryCount = 1,
      count = 1,
      disabled = false,
      hideNextButton = false,
      hidePrevButton = false,
      page,
      showFirstButton = false,
      showLastButton = false,
      siblingCount = 1,
      onChange = noop,
    } = toValue(props);
  })
  // 這裡放剛剛所有的計算邏輯
  const items = _items.map(() => {
    // 略
  })
  return items
}
這樣我們的元件就會更加簡潔了!
今天我們一起從最簡單的 <AtomicPagination> 開始,逐步推進到更完善的元件。在增進使用者體驗的過程中,我們花了大量篇幅推導最合理的算法。如果一開始沒看懂,沒關係,我強烈建議從進階需求部分開始,隨機帶入幾種組合進行計算,這對理解會有幫助。
在樣式方面,我們使用了之前寫的 <AtomicButton> 並做了一些調整。如果可以的話,<AtomicPagination> 也可以接收類似 variant 這樣的 props 並傳給 <AtomicButton> 來調整樣式,這部分就留給大家去嘗試了。
最後,受到 Material-UI 的啟發,我們將元件的邏輯與樣式分開,這樣我們就可以更靈活地使用這個元件了。這是一個很酷的概念,在不同的專案中,我通常只需要調整樣式和修改預設值就可以使用了。必須佩服想到這種設計的人,真的是太厲害了!
<AtomicPagination> 原始碼:AtomicPagination.vue
usePagination 原始碼:usePagination.ts