背景
笔者最近的工作是实现移动端页面,期间开发了不少带有滚动分页的页面。滚动分页,即页面滚动到底部时才能加载更多数据,以免一次加载太多数据造成性能问题。
笔者项目有多处页面需要做滚动分页,且有多Tab分页等复杂场景,因此需要将滚动分页做成可复用易维护的模块,以提高效率。
功能分析
首先,滚动分页根据不同的操作,需要渲染不同的状态的页面视图:
滚动分页视图对应了多个状态,有状态自然会有状态机。状态机的变化请看如下流程图:
上图的其中一个关键点是,需要根据分页的后台接口请求的请求前、请求中和请求后三个阶段的状态渲染不同的视图。
另外一个关键点是,涉及到的用户操作有两个:
- 初始化时,触发第一次远程数据加载
- 滚动到底部时,触发远程数据加载
分层
笔者一开始的抽象方法是将上述流程图逻辑和视图的代码都抽象到一个React组件上,后来更多的页面使用这个分页组件时,遇到了一些的问题:
- 分页组件可能需要被销毁,由于数据存放在组件中,分页数据也跟着销毁。
- 多Tab分页的场景下可能需要不同的加载策略,React组件的抽象方式并不适合设置这种策略。
为了让调用方更灵活地调用分页模块。笔者采取了MVC框架的概念将滚动分页模块分为三层:操作层、视图层和数据层。下图是他们三者的关系:
操作层位于整个模型的上层。该层主要逻辑是处理一些用户操作,触发远程数据加载。从上文可知,用户操作主要包括:初始化、滚动到底部。还有一些特殊的用户操作,比如切换tab。
数据层对操作层主要暴露一个远程加载数据的方法。对视图层暴露分页数据,如是否加载中、是否没有更多数据。
视图层是一些无状态的组件,仅仅根据数据渲染视图。
实现
笔者的项目的技术栈是react + mobx。数据层放在Mobx上,实际上使用其他状态管理框架,如Redux也是可以的,重点是数据层放在状态管理框架的store上可以共享数据。视图层放在组件上。操作层放在hook上。
可以在github地址上看到下面代码的源码
数据层代码:
import { makeAutoObservable } from 'mobx';
const defaultPageSize = 20;
const beginPageNum = 1;
// 分页store
export class PagingStore<Item, Res> {
public pageNum = beginPageNum; // 当前请求下标
public pageSize = defaultPageSize; // 每个分页的个数
public list: Item[] = []; // 数据列表
public total = 0; // 数据总值
public loading = false; // 是否正在请求数据
public end = false; // 是否到底了
public onePage = false; // 是否只有一页数据
public fail = false; // 是否加载失败
public fetchList: (pageNum: number, pageSize: number) => Promise<Res>;
public constructor({pageSize, fetchList} : {
pageSize?: number;
fetchList: (pageNum: number, pageSize: number) => Promise<Res>;
}) {
makeAutoObservable(this);
this.fetchList = fetchList;
if (pageSize) this.pageSize = pageSize;
}
public async load(): Promise<Res | null> {
// 如果处于loading状态或者到底了,则不load数据
if (this.loading || this.end) return null;
this.loading = true;
const rawRes = await this.fetchList(this.pageNum, this.pageSize);
const res = rawRes as {
code: number;
data: {
total: number;
list: Item[];
};
};
if (res.code === 200) {
this.list = this.list.concat(res.data?.list || []);
const total = res.data?.total || 0;
this.total = total;
if (this.list.length >= total) {
this.end = true;
// 如果到底了且分页只有一页,则onePage为true
if (this.pageNum === beginPageNum) {
this.onePage = true;
}
}
this.pageNum = this.pageNum + 1;
} else {
this.fail = true;
}
this.loading = false;
return rawRes;
}
// 从第一页开始重新加载数据
public async reload(): Promise<Res | null> {
// 具体实现请看源码
}
}
数据层中的关键代码是 load() 函数,该函数发起了远程数据请求,并在请求前后对改变滚动分页的状态数据。
视图层代码:
// 滚动分页组件
const PagingState = ({
children, count = 0, loading, end, onePage, fail,
}: {
children?: string | JSX.Element | JSX.Element[];
count?: number;
loading?: boolean;
end?: boolean;
onePage?: boolean;
fail?: boolean;
}): JSX.Element => {
// 状态:重试
if (fail) {
return (
<Retry />
);
}
// 状态:第一页加载中
if (count === 0 && loading) {
return (
<MiddleLoading />
);
}
// 状态:空数据
if (count === 0 && !loading) {
return (
<Empty />
);
}
return (
<>
{children}
{(loading && count > 0) && <BottomLoading />}
{(end && !onePage) && <End />}
</>
)
};
export default PagingState
视图层是一个无状态组件,该组件在获取数据层的状态数据后渲染不同的视图。
操作层代码:
import { useEffect, useRef } from 'react';
export const useLoadData = ({
load,
}: {
load: () => void,
}) => {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 初始化时加载第一页的数据
useEffect(() => {
load();
}, []);
// 滚动到底部是加载下一页数据
useEffect(() => {
const scrollFetch = ((): void => {
if (timerRef.current) return;
// 节流
timerRef.current = setTimeout(() => {
if (window.scrollY > document.body.clientHeight - document.documentElement.clientHeight - 200) {
load();
}
timerRef.current = null;
}, 200);
});
document.body.addEventListener('touchmove', scrollFetch);
return () => {
document.body.removeEventListener('touchmove', scrollFetch);
};
}, []);
};
操作层是变化较大的层,因此一般由业务自己定义逻辑。通用的逻辑有初始化加载和滚动到底部加载。实际上还可以扩展更多操作,如多Tab时切换的加载操作。
调用代码:
import { observer } from 'mobx-react-lite';
import { useLoadData } from './hooks/loadData';
import { useStore } from './hooks/store';
import PagingState from './components/PagingState';
import List from './components/List';
const App = observer(() => {
const { pagingStore } = useStore();
// 数据层:暴露内部状态给视图层渲染
const { loading, end, onePage, fail, list } = pagingStore;
// 操作层:设置远程加载数据的触发点
useLoadData({
load: pagingStore.load.bind(pagingStore),
});
return (
<div className="App">
{/* 视图层:渲染滚动分页的各个状态,该组件是一个无状态组件 */}
<PagingState
loading={loading}
end={end}
onePage={onePage}
fail={fail}
count={list.length}
>
<List list={list} />
</PagingState>
</div>
);
});
export default App;
结语
笔者从自身需求出发,使用MVC框架的方式将滚动分页功能拆分成多个层次。分层可以让代码结构更加清晰和可维护,而且能让调用方更加灵活地组装自己的业务需求。