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;
        });
      })
    );
  }
});
相关推荐
研☆香16 小时前
html页面如何精准布局
前端·html
零下32摄氏度16 小时前
【前端干货】接口在 Postman 测试很快,页面加载咋就慢?
前端·程序人生·postman
全栈陈序员16 小时前
说说你对 Vue 的理解
前端·javascript·vue.js·学习·前端框架
多则惑少则明16 小时前
SpringBoot3整合knife4j(swagger3)
java·spring boot·swagger
星月昭铭16 小时前
Spring Boot写一个/v1/chat/completions接口给Cherry Studio流式调用
java·spring boot·后端·ai
Coder_Boy_17 小时前
基于DDD+Spring Boot 3.2+LangChain4j构建企业级智能客服系统 版本升级
java·人工智能·spring boot·后端·langchain
全栈技术负责人17 小时前
Ling框架:针对AIGC工作流中JSON数据流式处理的解决方案
前端·ai
武昌库里写JAVA17 小时前
vue+iview+node+express实现文件上传,显示上传进度条,实时计算上传速度
java·vue.js·spring boot·后端·sql
自由与自然17 小时前
实现类似van-dialog自定义弹框
前端·javascript·html
KLW7517 小时前
vue3中操作样式的变化
前端·javascript·vue.js