分页hooks的封装
Star Sea

项目中使用到分页的场景很多,例如:后管系统中的表格分页、移动端列表页上拉加载更多。这些场景使用一些分页加载的组件可以实现,但是还是有痛点,每次都得写一套大差不差的逻辑。因此,在此文中我们使用hooks来封装一下通用的逻辑,简化使用。

Vue中composition API

自定义composition API的封装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import { reactive } from '@vue/composition-api';
import { debounce } from 'lodash';

const usePager = (fetch, {
pageSize = 20,
} = {}) => {
const data = reactive({
pageIndex: 1,
pageSize: pageSize,
total: 0,
});

// 刷新函数,增加防抖处理,debounce可以自己实现也可以引入对应库
const refresh = debounce(async () => {
const { pageIndex, pageSize } = data;
const { total, pageIndex: newPageIndex } = await fetch({ pageIndex, pageSize });

// 通常fetch函数只需要返回total即可
data.total = total;
newPageIndex && (data.pageIndex = newPageIndex);
}, 50);

// pageSize改变触发
const handleSizeChange = (pageSize) => {
data.pageIndex = 1;
data.pageSize = pageSize;
refresh();
};

// 页码变化触发
const handleCurrentChange = (pageIndex) => {
data.pageIndex = pageIndex;
refresh();
};

// 此处重置直接重写了pageSize,可根据项目需求修改
const reset = () => {
handleSizeChange(pageSize);
};

refresh();

// 返回分页信息和相关函数
return [data, { handleSizeChange, handleCurrentChange, reset, }];
};

export default usePager;

分页组件的封装,这里基于element。此处没有将usePager和组件写在一起,因为如果换用了UI组件,我们希望分页的逻辑还是可以复用的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<template>
<el-pagination
:current-page="pageInfo.pageIndex"
:page-sizes="pageSizes"
:page-size="pageInfo.pageSize"
:total="pageInfo.total"
:layout="layout"
v-bind="$attrs"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</template>

<script>
import usePager from './usePager';

export default {
props: {
pageSizes: {
type: Array,
default: () => [20, 30, 40, 50]
},
layout: {
type: String,
default: 'total, sizes, prev, pager, next, jumper'
},
fetch: {
type: Function,
required: true,
}
},
setup(props) {
const { fetch } = props;

// 此处未使用到reset
const [ pageInfo, { handleSizeChange, handleCurrentChange }] = usePager(fetch);

return {
pageInfo,
handleSizeChange,
handleCurrentChange
};
},
};
</script>

组件的使用,这里仅展示分页相关的内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<template>
<div>
<custom-pager :fetch="fetch" />
</div>
</template>

<script>
export default {
setup() {
const tableData = ref([]);

// 自己实现fetch,返回total即可
const fetch = async ({ pageSize, pageIndex }) => {
const { data } = await getData({ pageSize, pageIndex });
tableData.value = data.list;
return { total: tableData.total };
};
return {
tableData,
fetch
};
},
};
</script>

React中hooks

分页自定义hooks的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
import { useState, useEffect, useRef } from 'react'
import { useImmer } from 'use-immer'

const initialState = {
loading: false, // 标识是否正在加载中
hasMore: true, // 标识是否已到达最后一页
pageIndex: 1, // 当前页
pageSize: 10 // 每次请求页数
}

export default (props) => {
const { fetch, auto = true, pageSize = 10 } = props

// 重写pageSize,使用useImmer方便对象属性修改
const [page, setPage] = useImmer({ ...initialState, pageSize })
const totalRef = useRef(0)

const [hasNext, setHasNext] = useState(true)

// 监听pageIndex变化刷新数据
useEffect(() => {
if (auto) {
handleFetch()
}
}, [page.pageIndex])

const handleFetch = async () => {
// 修改loading状态
setPage((v) => {
v.loading = true
})
const { total } = await fetch(page)
totalRef.current = total
setPage((v) => {
// 判断是否还可以加载更多
if (!total || total <= page.pageSize * page.pageIndex) {
v.hasMore = false
} else {
// 防止重置过程中nextPage执行,导致hasMore为false
v.hasMore = true
}
v.loading = false
})
}

const nextPage = () => {
const curPage = page.pageIndex + 1
// 异常处理
if (!totalRef.current || curPage > Math.ceil(+totalRef.current / page.pageSize)) {
setPage((v) => {
v.hasMore = false
})
return
} else {
setPage((v) => {
v.pageIndex = curPage
})
}
}

const getTotal = () => {
return totalRef.current
}

const reset = async () => {
totalRef.current = 0
await setPage((v) => {
v.pageIndex = 1
v.hasMore = true
})

// 此处如果是auto状态的话,会自动刷新
if (!auto || page.pageIndex == 1) {
handleFetch()
}
}

return {
page,
getTotal,
nextPage,
reset
}
}

ScrollView的封装,这里举例在Taro小程序中开发的场景实现自定义的滚动加载UI组件,也可以结合对应的UI组件实现二次封装。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
function CustomScrollView (props, ref) {
const { children, fetch, auto = true } = props

// 使用usePage
const { page, getTotal, nextPage, reset } = usePage({
fetch,
auto
})

const wrapRef = useRef(null)

useEffect(() => {
// 这里可以参考createIntersectionObserver文档
let observer = Taro.createIntersectionObserver(Taro.getCurrentInstance().page, {
observeAll: true
})
setTimeout(() => {
// 监听.scrollview-bottom是否到达了可视区,达到之后调用nextPage
observer.relativeToViewport({ bottom: 0 }).observe('.scrollview-bottom', (res) => {
if (res.intersectionRatio > 0) {
if (page.hasMore && !page.loading) {
nextPage()
}
}
})
}, 0)

return () => {
observer.disconnect()
}
}, [page])

// 将reset暴露给父组件
useImperativeHandle(ref, () => ({
reset: () => {
reset()
}
}))

return (
<View ref={wrapRef}>
<View className='custom-scrollview-body'>{children}</View>
{page.loading && <div>正在加载...</div>}
{!page.loading && !page.hasMore && getTotal() > 0 && (
<div className='no-more'>没有更多数据了</div>
)}
<View className='scrollview-bottom'></View>
</View>
)
}

export default React.forwardRef(CustomScrollView)

ScrollView的使用,此处只保留分页相关的部分,其他内容省略

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
function GoodsList () {
// 列表dom
const goodsRef = useRef(null)
// 存储列表数据
const [goodsList, setGoodsList] = useState([])

// 滚动加载调用函数
async function refreshGoods ({ pageIndex, pageSize }) {
const data = await getGoodsList({ pageIndex, pageSize })
setGoodsList([
...goodsList,
...data.list
])
return { total: data.total };
}

// 此处举例说明reset的使用,调用goodsRef暴露出的reset方法
function resetData (){
setGoodsList([])
goodsRef.current.reset()
}

return (
<View className='goods-list'>
{/* 其他逻辑省略... */}
<View className='content'>
<CustomScrollView className='list-scroll' ref={goodsRef} fetch={refreshGoods}>
{
goodsList.map(goodsInfo => <GoodsItem {...goodsInfo} key={goodsInfo.itemId} />)
}
</CustomScrollView>
</View>
</View>
)
}
export default GoodsList

React中使用装饰器

在class方式的开发中,可以使用装饰器来单独封装分页的逻辑(Vue中的话用mixin也可以,但是封装到分页组件内部逻辑中可能更为方便)。以下是装饰器的写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
export default function withPager(Component) {
return class CustomPagerComponent extends Component {
constructor(props) {
super(props)

// 接收装饰器传入的参数
const { pageSize = 10, pageIndex = 0, pageTotal = 0 } = props || {}

const page = {
hasMore: true,
isLoading: false,
total: pageTotal,
pageIndex: pageIndex,
pageSize: pageSize
}

this.state.page = page
}

nextPage = async () => {
const { page } = this.state
if (!page.hasMore || page.isLoading) return

page.isLoading = true
this.setState({
page
})

const { pageIndex, pageSize } = page
const curPage = pageIndex + 1

// 调用fetch方法,获取返回值
const { total } = await this.fetch({
pageIndex: curPage,
pageSize
})

// 判断是否已经加载完成
if (!total || curPage >= Math.ceil(+total / pageSize)) {
page.hasMore = false
}

this.setState({
page: {
...page,
total,
pageIndex: curPage,
isLoading: false,
}
})
}

// 重置函数
reset(cb = () => { }) {
const page = {
...(this.state.page || {}),
pageIndex: 0,
total: 0,
isLoading: false,
hasMore: true
}
this.setState({ page }, cb)
}
}
}

直接在对应页面中使用,这里结合小程序的ScrollView使用,同样只展示分页相关的逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
@withPager
export default class GoodsList extends Component {
constructor(props) {
super(props);

// 注意此处需要先使用展开运算符将装饰器中写入的state.page扩展进来
this.state = {
...this.state,
goodsList: []
}
}

componentDidMount() {
// 页面加载完成先调用一次分页加载
this.nextPage();
}

// 此处fetch命名固定,withPager中会按名称读取
async function fetch ({ pageIndex, pageSize }) {
const data = await getGoodsList({ pageIndex, pageSize })
this.setState({
goodsList: [
...this.state.goodsList,
...data.list
]
})
return { total: data.total_count };
}

// 此处举例说明reset的使用
function resetData (){
// 调用withPager中加入的reset函数
this.reset();
this.setState({
goodsList: []
})
}

render() {
const { goodsList } = this.state;

return (
<View className='goods-list'>
{/* 其他逻辑省略... */}
<View className='content'>
<ScrollView
className='list-scroll'
onScrollToLower={this.nextPage}
scrollY
>
{
goodsList.map(goodsInfo => <GoodsItem {...goodsInfo} key={goodsInfo.itemId} />)
}
</CustomScrollView>
</View>
</View>
)
}