一、后端实现(Spring Boot)
1. 实体类
// Article.java
@Data
@Document(collection = "articles")
@CompoundIndex(def = "{'categoryId': 1, 'createdAt': -1}") // 复合索引
public class Article {
@Id
private String id;
private String title;
private String content;
private String categoryId;
@Indexed(direction = IndexDirection.DESCENDING)
private LocalDateTime createdAt;
private Integer viewCount;
}
// Category.java
@Data
@Document(collection = "categories")
public class Category {
@Id
private String id;
private String name;
}
2. Repository
@Repository
public interface ArticleRepository extends MongoRepository<Article, String> {
/**
* 游标分页核心查询
* 查询 createdAt < cursor 的数据,按 createdAt 倒序
*/
@Query(sort = "{ 'createdAt': -1 }")
List<Article> findByCategoryIdAndCreatedAtLessThan(
String categoryId,
LocalDateTime cursor,
Pageable pageable
);
/**
* 第一页查询(无需游标)
*/
List<Article> findByCategoryIdOrderByCreatedAtDesc(
String categoryId,
Pageable pageable
);
/**
* 统计总数(用于显示)
*/
long countByCategoryId(String categoryId);
}
3. Service
@Service
public class ArticleService {
@Autowired private ArticleRepository articleRepository;
/**
* 游标分页 + 模糊总数
* @param cursor 上一页最后一条的 createdAt
* @param limit 每页数量
* @param categoryId 分类筛选
*/
public CursorPage<Article> getArticles(
LocalDateTime cursor,
int limit,
String categoryId
) {
// 查询 limit+1 条,用于判断是否有下一页
Pageable pageable = PageRequest.of(0, limit + 1);
List<Article> articles;
if (cursor == null) {
// 第一页
articles = articleRepository.findByCategoryIdOrderByCreatedAtDesc(
categoryId, pageable
);
} else {
// 下一页
articles = articleRepository.findByCategoryIdAndCreatedAtLessThan(
categoryId, cursor, pageable
);
}
// 判断是否有下一页
boolean hasNext = articles.size() > limit;
if (hasNext) {
articles = articles.subList(0, limit);
}
// 计算下一页游标(最后一条的 createdAt)
LocalDateTime nextCursor = articles.isEmpty() ?
null : articles.get(articles.size() - 1).getCreatedAt();
// 获取总数(带缓存优化)
long total = getTotalCountWithCache(categoryId);
return CursorPage.<Article>builder()
.content(articles)
.total(total)
.nextCursor(nextCursor)
.hasNext(hasNext)
.pageSize(limit)
.build();
}
/**
* 总数缓存优化(10分钟有效)
*/
@Autowired private RedisTemplate<String, Long> redisTemplate;
private long getTotalCountWithCache(String categoryId) {
String cacheKey = "article:count:" + categoryId;
Long cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return cached;
}
long count = articleRepository.countByCategoryId(categoryId);
// 模糊处理:超过10000显示为-1
long result = count > 10000 ? -1 : count;
redisTemplate.opsForValue().set(cacheKey, result, 10, TimeUnit.MINUTES);
return result;
}
}
// CursorPage.java
@Data
@Builder
public class CursorPage<T> {
private List<T> content; // 当前页数据
private Long total; // 总数(-1表示"10000+")
private LocalDateTime nextCursor; // 下一页游标
private Boolean hasNext; // 是否有下一页
private Integer pageSize; // 当前页大小
}
4. Controller
@RestController
@RequestMapping("/api/articles")
public class ArticleController {
@GetMapping
public CursorPage<Article> getArticles(
@RequestParam(required = false) String cursor,
@RequestParam(defaultValue = "20") Integer limit,
@RequestParam(required = false) String categoryId
) {
// 解析游标字符串
LocalDateTime cursorTime = StringUtils.hasText(cursor) ?
LocalDateTime.parse(cursor) : null;
// 限制最大分页大小
limit = Math.min(limit, 100);
return articleService.getArticles(cursorTime, limit, categoryId);
}
}
二、前端实现(TypeScript + React)
1. 游标历史管理器(核心)
1.1 TypeScript实现
TypeScript
// cursorHistory.ts - 游标历史管理器
export class CursorHistory<T> {
private cursorStack: (string | null)[] = [null]; // 初始游标为null
private currentIndex = 0; // 当前页索引
private hasNextCache = true; // 缓存是否有下一页
constructor(
private fetchFn: (cursor: string | null) => Promise<CursorPage<T>>,
private onDataLoaded: (data: CursorPage<T>, direction: 'next' | 'prev') => void
) {}
// 加载下一页
async next(): Promise<void> {
const currentCursor = this.cursorStack[this.currentIndex];
const data = await this.fetchFn(currentCursor);
// 如果是第一页且已经有数据,不重复压栈
if (this.currentIndex === 0 && this.cursorStack.length > 1) {
this.cursorStack[this.currentIndex + 1] = data.nextCursor;
} else {
this.cursorStack.push(data.nextCursor); // 压入新游标
}
this.currentIndex++;
this.hasNextCache = data.hasNext;
this.onDataLoaded(data, 'next');
}
// 加载上一页
async prev(): Promise<void> {
if (this.currentIndex <= 0) {
throw new Error('已经是第一页');
}
this.currentIndex--;
const prevCursor = this.cursorStack[this.currentIndex - 1]; // 上一页的游标
const data = await this.fetchFn(prevCursor);
this.onDataLoaded(data, 'prev');
this.hasNextCache = true; // 返回后一定有下一页
}
// 跳转到指定页(仅支持前N页)
async goToPage(page: number): Promise<void> {
if (page > this.cursorStack.length) {
throw new Error('页码超出范围');
}
this.currentIndex = page;
const cursor = this.cursorStack[this.currentIndex];
const data = await this.fetchFn(cursor);
this.onDataLoaded(data, 'next');
}
// 重置到第一页
reset(): void {
this.cursorStack = [null];
this.currentIndex = 0;
this.hasNextCache = true;
}
// 获取当前状态
getState() {
return {
hasPrev: this.currentIndex > 0,
hasNext: this.hasNextCache,
currentPage: this.currentIndex,
totalPages: this.cursorStack.length,
};
}
}
// 分页数据接口
export interface CursorPage<T> {
content: T[];
total: number; // -1 表示"10000+"
nextCursor: string | null;
hasNext: boolean;
pageSize: number;
}
1.2. API 调用封装
TypeScript
// articleApi.ts
import axios from 'axios';
export const articleApi = {
// 获取文章列表
async getArticles(params: {
cursor?: string;
limit?: number;
categoryId?: string;
}): Promise<CursorPage<Article>> {
const response = await axios.get('/api/articles', { params });
return response.data;
},
};
// 文章类型
export interface Article {
id: string;
title: string;
content: string;
categoryId: string;
createdAt: string;
}
2.1 React实现 (React Hooks 封装)
TypeScript
// useCursorPagination.ts
import { useState, useCallback } from 'react';
import { CursorHistory, CursorPage } from './cursorHistory';
export function useCursorPagination<T>(
fetchFn: (cursor: string | null) => Promise<CursorPage<T>>
) {
const [data, setData] = useState<T[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 创建游标历史管理器
const [history] = useState(() =>
new CursorHistory<T>(fetchFn, (pageData, direction) => {
setData(pageData.content);
setLoading(false);
})
);
// 下一页
const loadNext = useCallback(async () => {
setLoading(true);
setError(null);
try {
await history.next();
} catch (e) {
setError(e.message);
setLoading(false);
}
}, [history]);
// 上一页
const loadPrev = useCallback(async () => {
setLoading(true);
setError(null);
try {
await history.prev();
} catch (e) {
setError(e.message);
setLoading(false);
}
}, [history]);
// 重置到第一页
const reset = useCallback(() => {
history.reset();
loadNext();
}, [history, loadNext]);
const state = history.getState();
return {
data,
loading,
error,
loadNext,
loadPrev,
reset,
hasPrev: state.hasPrev,
hasNext: state.hasNext,
currentPage: state.currentPage,
};
}
2.2React 分页组件
TypeScript
// ArticleList.tsx
import React, { useEffect } from 'react';
import { useCursorPagination } from './useCursorPagination';
import { articleApi } from './articleApi';
export function ArticleList({ categoryId }: { categoryId?: string }) {
// 创建分页Hook
const {
data: articles,
loading,
error,
loadNext,
loadPrev,
reset,
hasPrev,
hasNext,
currentPage,
} = useCursorPagination((cursor) =>
articleApi.getArticles({ cursor, limit: 20, categoryId })
);
// 分类变化时重置
useEffect(() => {
reset();
}, [categoryId, reset]);
// 渲染分页按钮
const renderPagination = () => {
if (loading && articles.length === 0) return null;
return (
<div className="pagination">
<button
onClick={loadPrev}
disabled={!hasPrev || loading}
className="btn btn-primary"
>
← 上一页
</button>
<span className="page-info">
第 {currentPage + 1} 页
</span>
<button
onClick={loadNext}
disabled={!hasNext || loading}
className="btn btn-primary"
>
下一页 →
</button>
</div>
);
};
return (
<div className="article-list">
{error && <div className="alert alert-danger">{error}</div>}
{loading && articles.length === 0 ? (
<div>加载中...</div>
) : (
<>
<ul>
{articles.map((article) => (
<li key={article.id}>
<h3>{article.title}</h3>
<p>{article.createdAt}</p>
</li>
))}
</ul>
{renderPagination()}
{loading && articles.length > 0 && <div>加载更多...</div>}
</>
)}
</div>
);
}
三、高级功能
1. URL 同步(刷新保持位置)
TypeScript
// syncWithUrl.ts
export function syncPaginationWithUrl(history: CursorHistory<any>) {
// 从URL读取游标
const params = new URLSearchParams(window.location.search);
const cursor = params.get('cursor');
if (cursor) {
// 恢复游标栈
history['cursorStack'] = [null, cursor];
history['currentIndex'] = 1;
}
// 监听游标变化,更新URL
const originalNext = history.next.bind(history);
const originalPrev = history.prev.bind(history);
history.next = async () => {
await originalNext();
updateUrl(history);
};
history.prev = async () => {
await originalPrev();
updateUrl(history);
};
}
function updateUrl(history: CursorHistory<any>) {
const cursor = history['cursorStack'][history['currentIndex']];
const params = new URLSearchParams();
if (cursor) {
params.set('cursor', cursor);
}
window.history.replaceState(
{},
'',
`${window.location.pathname}?${params.toString()}`
);
}
2. 多标签页支持(localStorage同步)
TypeScript
// multiTabSupport.ts
export class MultiTabCursorHistory<T> extends CursorHistory<T> {
private storageKey: string;
constructor(
fetchFn: (cursor: string | null) => Promise<CursorPage<T>>,
onDataLoaded: (data: CursorPage<T>, direction: 'next' | 'prev') => void,
key: string = 'cursor_history'
) {
super(fetchFn, onDataLoaded);
this.storageKey = key;
this.loadFromStorage();
// 监听其他标签页变化
window.addEventListener('storage', (e) => {
if (e.key === this.storageKey) {
this.loadFromStorage();
}
});
}
private loadFromStorage() {
const stored = localStorage.getItem(this.storageKey);
if (stored) {
const data = JSON.parse(stored);
this.cursorStack = data.cursorStack;
this.currentIndex = data.currentIndex;
}
}
private saveToStorage() {
localStorage.setItem(this.storageKey, JSON.stringify({
cursorStack: this.cursorStack,
currentIndex: this.currentIndex,
}));
}
async next(): Promise<void> {
await super.next();
this.saveToStorage();
}
async prev(): Promise<void> {
await super.prev();
this.saveToStorage();
}
}
四、性能优化
1. 防抖加载
TypeScript
// 避免快速点击导致重复请求
import { debounce } from 'lodash';
const loadNextDebounced = debounce(loadNext, 300);
<button onClick={loadNextDebounced} disabled={loading}>
下一页
</button>
2. 虚拟滚动(大数据量列表)
TypeScript
import { FixedSizeList as List } from 'react-window';
<List
height={600}
itemCount={articles.length}
itemSize={120}
itemData={articles}
>
{({ index, style, data }) => (
<div style={style}>
<ArticleItem article={data[index]} />
</div>
)}
</List>
3. Service Worker 缓存
TypeScript
// service-worker.js
self.addEventListener('fetch', (event) => {
if (event.request.url.includes('/api/articles')) {
event.respondWith(
caches.match(event.request).then((response) => {
return response || fetch(event.request).then((resp) => {
// 缓存最近5页
caches.open('articles-cache').then((cache) => {
cache.put(event.request, resp.clone());
});
return resp;
});
})
);
}
});