在 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