springboot +mongodb游标分页,性能好。前端存储游标历史

一、后端实现(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;
        });
      })
    );
  }
});
相关推荐
却话巴山夜雨时i1 小时前
295. 数据流的中位数【困难】
java·服务器·前端
云技纵横1 小时前
Vue无限滚动实战——从原理到企业级优化方案
前端
细心细心再细心1 小时前
响应式记录
前端·vue.js
s***P9821 小时前
Spring Boot实时推送技术详解:三个经典案例
spring boot·后端·状态模式
java干货1 小时前
优雅停机!Spring Boot 应用如何使用 Hook 线程完成“身后事”?
java·spring boot·后端
干就完了11 小时前
关于git的操作命令(一篇盖全),可不用,但不可不知!
前端·javascript
之恒君1 小时前
JavaScript 垃圾回收机制详解
前端·javascript
是你的小橘呀1 小时前
像前任一样捉摸不定的异步逻辑,一文让你彻底看透——JS 事件循环
前端·javascript·面试
Cache技术分享1 小时前
260. Java 集合 - 深入了解 HashSet 的内部结构
前端·后端