名称:bknd-分页 描述:在Bknd中实现分页数据检索时使用。涵盖限制/偏移分页、页面计算、分页元数据(总数、是否有下一页、是否有上一页)、分页辅助函数、无限滚动和React集成模式。
分页
使用Bknd的限制/偏移分页为列表、表格和无限滚动实现分页数据检索。
先决条件
- Bknd项目运行中(本地或部署)
- 实体存在数据(使用
bknd-create-entity、bknd-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;
验证
-
检查第一页返回正确计数:
const { data, meta } = await api.data.readMany("posts", { limit: 10 }); console.log(data.length, "of", meta.total); -
验证最后一页无错误:
const lastPage = Math.ceil(meta.total / pageSize); const { data } = await api.data.readMany("posts", { limit: pageSize, offset: (lastPage - 1) * pageSize, }); -
测试空结果:
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默认分页