Bknd分页数据检索Skill bknd-pagination

这个技能用于在Bknd平台中实现分页数据检索,涵盖限制/偏移分页、页面计算、分页元数据处理、分页辅助函数、无限滚动和React集成模式,适用于构建分页列表、表格和无限滚动功能。关键词:分页,Bknd,数据检索,React,无限滚动。

前端开发 0 次安装 0 次浏览 更新于 3/8/2026

名称:bknd-分页 描述:在Bknd中实现分页数据检索时使用。涵盖限制/偏移分页、页面计算、分页元数据(总数、是否有下一页、是否有上一页)、分页辅助函数、无限滚动和React集成模式。

分页

使用Bknd的限制/偏移分页为列表、表格和无限滚动实现分页数据检索。

先决条件

  • Bknd项目运行中(本地或部署)
  • 实体存在数据(使用bknd-create-entitybknd-seed-data
  • SDK配置或API端点已知

何时使用UI模式

  • 在管理面板中浏览数据
  • 快速数据探索
  • 手动测试分页

UI步骤: 管理面板 > 数据 > 选择实体 > 使用底部分页控件

何时使用代码模式

  • 构建分页列表/表格
  • 实现“加载更多”按钮
  • 创建无限滚动
  • 服务器端分页API

分页基础

Bknd使用基于偏移的分页,有两个参数:

参数 类型 默认值 描述
limit 数字 10 每页记录数
offset 数字 0 跳过的记录数

页面公式

// 第N页(1起始索引),每页pageSize条记录:
{
  limit: pageSize,
  offset: (page - 1) * pageSize
}

// 示例:
// 第1页:{ limit: 20, offset: 0 }   -> 记录0-19
// 第2页:{ limit: 20, offset: 20 }  -> 记录20-39
// 第3页:{ limit: 20, offset: 40 }  -> 记录40-59

代码方法

步骤1:基本分页查询

import { Api } from "bknd";

const api = new Api({ host: "http://localhost:7654" });

const page = 1;
const pageSize = 20;

const { ok, data, meta } = await api.data.readMany("posts", {
  where: { status: { $eq: "published" } },
  sort: { created_at: "desc" },
  limit: pageSize,
  offset: (page - 1) * pageSize,
});

console.log(`第${page}页:${data.length}条记录`);
console.log(`总数:${meta.total}`);

步骤2:处理响应元数据

meta对象包含分页信息:

type PaginationMeta = {
  total: number;   // 总匹配记录数
  limit: number;   // 当前页面大小
  offset: number;  // 当前偏移量
};

const { data, meta } = await api.data.readMany("posts", {
  limit: 20,
  offset: 0,
});

const totalPages = Math.ceil(meta.total / meta.limit);
const currentPage = Math.floor(meta.offset / meta.limit) + 1;
const hasNextPage = meta.offset + meta.limit < meta.total;
const hasPrevPage = meta.offset > 0;

console.log(`第${currentPage}页,共${totalPages}页`);
console.log(`是否有下一页:${hasNextPage},是否有上一页:${hasPrevPage}`);

步骤3:创建分页辅助函数

type PaginationResult<T> = {
  data: T[];
  page: number;
  pageSize: number;
  total: number;
  totalPages: number;
  hasNext: boolean;
  hasPrev: boolean;
};

async function paginate<T>(
  entity: string,
  page: number,
  pageSize: number,
  query: object = {}
): Promise<PaginationResult<T>> {
  const { data, meta } = await api.data.readMany(entity, {
    ...query,
    limit: pageSize,
    offset: (page - 1) * pageSize,
  });

  return {
    data: data as T[],
    page,
    pageSize,
    total: meta.total,
    totalPages: Math.ceil(meta.total / pageSize),
    hasNext: page * pageSize < meta.total,
    hasPrev: page > 1,
  };
}

// 用法
const result = await paginate("posts", 1, 20, {
  where: { status: { $eq: "published" } },
  sort: { created_at: "desc" },
});

console.log(result.data);         // 帖子数组
console.log(result.totalPages);   // 总页数
console.log(result.hasNext);      // true/false

步骤4:带过滤器的分页

结合分页和where子句:

async function paginatedSearch(
  entity: string,
  page: number,
  pageSize: number,
  filters: object,
  sort: object = {}
) {
  const { data, meta } = await api.data.readMany(entity, {
    where: filters,
    sort,
    limit: pageSize,
    offset: (page - 1) * pageSize,
  });

  return {
    data,
    pagination: {
      page,
      pageSize,
      total: meta.total,
      totalPages: Math.ceil(meta.total / pageSize),
      hasNext: page * pageSize < meta.total,
      hasPrev: page > 1,
    },
  };
}

// 带分页的搜索
const result = await paginatedSearch(
  "posts",
  2,        // 第2页
  10,       // 每页10条
  {
    status: { $eq: "published" },
    title: { $ilike: "%react%" },
  },
  { created_at: "desc" }
);

REST API方法

查询参数

# 第1页,每页20条
curl "http://localhost:7654/api/data/posts?limit=20&offset=0"

# 第2页
curl "http://localhost:7654/api/data/posts?limit=20&offset=20"

# 带排序
curl "http://localhost:7654/api/data/posts?limit=20&offset=0&sort=-created_at"

# 带过滤器(URL编码JSON)
curl "http://localhost:7654/api/data/posts?limit=20&offset=0&where=%7B%22status%22%3A%22published%22%7D"

POST用于复杂查询

curl -X POST http://localhost:7654/api/data/posts/query \
  -H "Content-Type: application/json" \
  -d '{
    "where": {"status": {"$eq": "published"}},
    "sort": {"created_at": "desc"},
    "limit": 20,
    "offset": 0
  }'

响应格式

{
  "ok": true,
  "data": [...],
  "meta": {
    "total": 150,
    "limit": 20,
    "offset": 0
  }
}

React集成

基本分页列表

import { useApp } from "bknd/react";
import { useState, useEffect } from "react";

function PaginatedPosts() {
  const { api } = useApp();
  const [posts, setPosts] = useState([]);
  const [page, setPage] = useState(1);
  const [totalPages, setTotalPages] = useState(0);
  const [loading, setLoading] = useState(true);
  const pageSize = 10;

  useEffect(() => {
    setLoading(true);
    api.data.readMany("posts", {
      sort: { created_at: "desc" },
      limit: pageSize,
      offset: (page - 1) * pageSize,
    }).then(({ data, meta }) => {
      setPosts(data);
      setTotalPages(Math.ceil(meta.total / pageSize));
      setLoading(false);
    });
  }, [page]);

  return (
    <div>
      {loading ? (
        <p>加载中...</p>
      ) : (
        <ul>
          {posts.map((post) => (
            <li key={post.id}>{post.title}</li>
          ))}
        </ul>
      )}

      <div className="pagination">
        <button
          disabled={page === 1}
          onClick={() => setPage(page - 1)}
        >
          上一页
        </button>
        <span>第{page}页,共{totalPages}页</span>
        <button
          disabled={page >= totalPages}
          onClick={() => setPage(page + 1)}
        >
          下一页
        </button>
      </div>
    </div>
  );
}

使用SWR(推荐)

import { useApp } from "bknd/react";
import { useState } from "react";
import useSWR from "swr";

function PaginatedPosts() {
  const { api } = useApp();
  const [page, setPage] = useState(1);
  const pageSize = 10;

  const { data: result, isLoading } = useSWR(
    ["posts", page, pageSize],
    () => api.data.readMany("posts", {
      sort: { created_at: "desc" },
      limit: pageSize,
      offset: (page - 1) * pageSize,
    })
  );

  const posts = result?.data ?? [];
  const total = result?.meta?.total ?? 0;
  const totalPages = Math.ceil(total / pageSize);

  return (
    <div>
      {isLoading ? <p>加载中...</p> : (
        <ul>
          {posts.map((post) => (
            <li key={post.id}>{post.title}</li>
          ))}
        </ul>
      )}

      <Pagination
        page={page}
        totalPages={totalPages}
        onPageChange={setPage}
      />
    </div>
  );
}

function Pagination({ page, totalPages, onPageChange }) {
  return (
    <div className="flex gap-2">
      <button
        disabled={page === 1}
        onClick={() => onPageChange(page - 1)}
      >
        上一页
      </button>

      {/* 页码 */}
      {Array.from({ length: totalPages }, (_, i) => i + 1)
        .filter(p => Math.abs(p - page) <= 2 || p === 1 || p === totalPages)
        .map((p, i, arr) => (
          <>
            {i > 0 && arr[i - 1] !== p - 1 && <span>...</span>}
            <button
              key={p}
              onClick={() => onPageChange(p)}
              className={p === page ? "active" : ""}
            >
              {p}
            </button>
          </>
        ))}

      <button
        disabled={page >= totalPages}
        onClick={() => onPageChange(page + 1)}
      >
        下一页
      </button>
    </div>
  );
}

加载更多/无限滚动

import { useApp } from "bknd/react";
import { useState, useCallback } from "react";

function InfinitePostsList() {
  const { api } = useApp();
  const [posts, setPosts] = useState([]);
  const [hasMore, setHasMore] = useState(true);
  const [loading, setLoading] = useState(false);
  const pageSize = 20;

  const loadMore = useCallback(async () => {
    if (loading || !hasMore) return;

    setLoading(true);
    const { data, meta } = await api.data.readMany("posts", {
      sort: { created_at: "desc" },
      limit: pageSize,
      offset: posts.length,  // 使用当前长度作为偏移量
    });

    setPosts((prev) => [...prev, ...data]);
    setHasMore(posts.length + data.length < meta.total);
    setLoading(false);
  }, [posts.length, loading, hasMore]);

  // 初始加载
  useEffect(() => {
    loadMore();
  }, []);

  return (
    <div>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>

      {hasMore && (
        <button onClick={loadMore} disabled={loading}>
          {loading ? "加载中..." : "加载更多"}
        </button>
      )}

      {!hasMore && <p>没有更多帖子了</p>}
    </div>
  );
}

使用Intersection Observer的无限滚动

import { useApp } from "bknd/react";
import { useState, useEffect, useRef, useCallback } from "react";

function InfiniteScrollPosts() {
  const { api } = useApp();
  const [posts, setPosts] = useState([]);
  const [hasMore, setHasMore] = useState(true);
  const [loading, setLoading] = useState(false);
  const loaderRef = useRef(null);
  const pageSize = 20;

  const loadMore = useCallback(async () => {
    if (loading || !hasMore) return;

    setLoading(true);
    const { data, meta } = await api.data.readMany("posts", {
      sort: { created_at: "desc" },
      limit: pageSize,
      offset: posts.length,
    });

    setPosts((prev) => [...prev, ...data]);
    setHasMore(posts.length + data.length < meta.total);
    setLoading(false);
  }, [posts.length, loading, hasMore]);

  // 交叉观察器用于自动加载
  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          loadMore();
        }
      },
      { threshold: 0.1 }
    );

    if (loaderRef.current) {
      observer.observe(loaderRef.current);
    }

    return () => observer.disconnect();
  }, [loadMore]);

  // 初始加载
  useEffect(() => {
    loadMore();
  }, []);

  return (
    <div>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>

      <div ref={loaderRef} style={{ height: 20 }}>
        {loading && <p>加载中...</p>}
        {!hasMore && <p>列表结束</p>}
      </div>
    </div>
  );
}

URL同步分页

import { useApp } from "bknd/react";
import { useSearchParams } from "react-router-dom";
import useSWR from "swr";

function URLPaginatedPosts() {
  const { api } = useApp();
  const [searchParams, setSearchParams] = useSearchParams();
  const page = parseInt(searchParams.get("page") || "1", 10);
  const pageSize = 20;

  const { data: result, isLoading } = useSWR(
    ["posts", page],
    () => api.data.readMany("posts", {
      limit: pageSize,
      offset: (page - 1) * pageSize,
    })
  );

  const setPage = (newPage: number) => {
    setSearchParams({ page: String(newPage) });
  };

  const totalPages = result
    ? Math.ceil(result.meta.total / pageSize)
    : 0;

  return (
    <div>
      {/* ... 渲染帖子 ... */}
      <Pagination
        page={page}
        totalPages={totalPages}
        onPageChange={setPage}
      />
    </div>
  );
}

常见模式

可配置默认限制

在SDK中配置默认页面大小:

const api = new Api({
  host: "http://localhost:7654",
  data: {
    defaultQuery: {
      limit: 25,  // 未指定时的默认值
    },
  },
});

服务器端分页(Next.js)

// app/posts/page.tsx
export default async function PostsPage({
  searchParams,
}: {
  searchParams: { page?: string };
}) {
  const page = parseInt(searchParams.page || "1", 10);
  const pageSize = 20;

  const response = await fetch(
    `${process.env.BKND_URL}/api/data/posts?` +
    `limit=${pageSize}&offset=${(page - 1) * pageSize}&sort=-created_at`
  );
  const { data, meta } = await response.json();

  const totalPages = Math.ceil(meta.total / pageSize);

  return (
    <>
      <PostsList posts={data} />
      <PaginationLinks
        page={page}
        totalPages={totalPages}
        basePath="/posts"
      />
    </>
  );
}

带关系分页

const result = await paginate("posts", page, pageSize, {
  where: { status: { $eq: "published" } },
  sort: { created_at: "desc" },
  with: {
    author: { select: ["id", "name", "avatar"] },
  },
});

计数总数无数据

如果只需要计数(例如,显示总数):

const { data } = await api.data.count("posts", {
  status: { $eq: "published" },
});
console.log(`${data.count} 总帖子数`);

常见陷阱

忘记分页

问题: 加载所有记录导致性能问题。

修复: 始终对大数据集进行分页:

// 错误 - 加载所有内容
const { data } = await api.data.readMany("posts");

// 正确 - 分页
const { data } = await api.data.readMany("posts", {
  limit: 20,
  offset: 0,
});

页面计算差一错误

问题: 使用0起始索引的页面。

修复: 使用1起始索引的页面和正确的偏移公式:

// 错误(如果页面是1起始索引)
offset: page * pageSize  // 跳过第一页!

// 正确
offset: (page - 1) * pageSize

未跟踪总数

问题: 没有总数无法显示“第X页,共Y页”。

修复: 始终使用响应中的meta.total

const { data, meta } = await api.data.readMany("posts", query);
const totalPages = Math.ceil(meta.total / pageSize);

无限滚动中的重复项

问题: 当数据变化时,相同项多次出现。

修复: 使用唯一键并去重:

setPosts((prev) => {
  const ids = new Set(prev.map(p => p.id));
  const newPosts = data.filter(p => !ids.has(p.id));
  return [...prev, ...newPosts];
});

页面超出总数

问题: 导航到不存在的页面。

修复: 限制页面编号:

const safePage = Math.min(page, totalPages);
const safeOffset = (safePage - 1) * pageSize;

验证

  1. 检查第一页返回正确计数:

    const { data, meta } = await api.data.readMany("posts", { limit: 10 });
    console.log(data.length, "of", meta.total);
    
  2. 验证最后一页无错误:

    const lastPage = Math.ceil(meta.total / pageSize);
    const { data } = await api.data.readMany("posts", {
      limit: pageSize,
      offset: (lastPage - 1) * pageSize,
    });
    
  3. 测试空结果:

    const { data } = await api.data.readMany("posts", {
      where: { title: { $eq: "nonexistent" } },
      limit: 10,
    });
    console.log("Empty:", data.length === 0);
    

做与不做

做:

  • 始终对大数据集进行分页
  • 使用meta.total进行页面计算
  • 处理边缘情况(空、最后一页)
  • 使用SWR/React Query进行缓存
  • 在适当时同步分页与URL
  • 预取下一页以改善用户体验

不做:

  • 一次加载所有记录
  • 使用0起始索引的页面(约定是1起始索引)
  • 忘记处理加载状态
  • 忽略空状态UI
  • 在管理/调试视图中跳过分页(仍要分页!)
  • 假设页面间数据不会变化

相关技能

  • bknd-crud-read - 基本读取操作
  • bknd-query-filter - 结合分页与过滤
  • bknd-bulk-operations - 分页批量操作
  • bknd-client-setup - 配置SDK默认分页