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;
        });
      })
    );
  }
});
相关推荐
用户69371750013843 小时前
Google 正在“收紧侧加载”:陌生 APK 安装或需等待 24 小时
android·前端
蓝帆傲亦3 小时前
Web 前端搜索文字高亮实现方法汇总
前端
用户69371750013843 小时前
Room 3.0:这次不是升级,是重来
android·前端·google
漫随流水4 小时前
旅游推荐系统(view.py)
前端·数据库·python·旅游
enjoy嚣士5 小时前
springboot之Exel工具类
java·spring boot·后端·easyexcel·excel工具类
踩着两条虫5 小时前
VTJ.PRO 核心架构全公开!从设计稿到代码,揭秘AI智能体如何“听懂人话”
前端·vue.js·ai编程
jzlhll1236 小时前
kotlin Flow first() last()总结
开发语言·前端·kotlin
小涛不学习6 小时前
Spring Boot 详解(从入门到原理)
java·spring boot·后端
蓝冰凌7 小时前
Vue 3 中 defineExpose 的行为【defineExpose暴露ref变量】详解:自动解包、响应性与实际使用
前端·javascript·vue.js
奔跑的呱呱牛7 小时前
generate-route-vue基于文件系统的 Vue Router 动态路由生成工具
前端·javascript·vue.js