bknd-pagination
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChinesePagination
分页
Implement paginated data retrieval for lists, tables, and infinite scroll using Bknd's limit/offset pagination.
使用Bknd的limit/offset分页功能,为列表、表格和无限滚动实现分页数据检索。
Prerequisites
前提条件
- Bknd project running (local or deployed)
- Entity exists with data (use ,
bknd-create-entity)bknd-seed-data - SDK configured or API endpoint known
- 运行中的Bknd项目(本地或已部署)
- 存在包含数据的实体(使用、
bknd-create-entity)bknd-seed-data - 已配置SDK或已知API端点
When to Use UI Mode
何时使用UI模式
- Browsing data in admin panel
- Quick data exploration
- Testing pagination manually
UI steps: Admin Panel > Data > Select Entity > Use pagination controls at bottom
- 在管理面板中浏览数据
- 快速探索数据
- 手动测试分页功能
UI操作步骤: 管理面板 > 数据 > 选择实体 > 使用底部的分页控件
When to Use Code Mode
何时使用代码模式
- Building paginated lists/tables
- Implementing "Load More" buttons
- Creating infinite scroll
- Server-side pagination APIs
- 构建分页列表/表格
- 实现“加载更多”按钮
- 创建无限滚动功能
- 服务端分页API开发
Pagination Basics
分页基础
Bknd uses offset-based pagination with two parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
| number | 10 | Records per page |
| number | 0 | Records to skip |
Bknd使用基于偏移量的分页,包含两个参数:
| 参数 | 类型 | 默认值 | 描述 |
|---|---|---|---|
| 数字 | 10 | 每页记录数 |
| 数字 | 0 | 要跳过的记录数 |
Page Formula
页码计算公式
typescript
// Page N (1-indexed) with pageSize records:
{
limit: pageSize,
offset: (page - 1) * pageSize
}
// Examples:
// Page 1: { limit: 20, offset: 0 } -> records 0-19
// Page 2: { limit: 20, offset: 20 } -> records 20-39
// Page 3: { limit: 20, offset: 40 } -> records 40-59typescript
// 第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-59Code Approach
代码实现方式
Step 1: Basic Paginated Query
步骤1:基础分页查询
typescript
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 ${page}: ${data.length} records`);
console.log(`Total: ${meta.total}`);typescript
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}`);Step 2: Handle Response Metadata
步骤2:处理响应元数据
The object contains pagination info:
metatypescript
type PaginationMeta = {
total: number; // Total matching records
limit: number; // Current page size
offset: number; // Current offset
};
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(`Page ${currentPage} of ${totalPages}`);
console.log(`Has next: ${hasNextPage}, Has prev: ${hasPrevPage}`);metatypescript
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}`);Step 3: Create Pagination Helper
步骤3:创建分页辅助函数
typescript
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,
};
}
// Usage
const result = await paginate("posts", 1, 20, {
where: { status: { $eq: "published" } },
sort: { created_at: "desc" },
});
console.log(result.data); // Posts array
console.log(result.totalPages); // Total pages
console.log(result.hasNext); // true/falsetypescript
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/falseStep 4: Paginate with Filters
步骤4:结合筛选条件的分页
Combine pagination with where clause:
typescript
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,
},
};
}
// Search with pagination
const result = await paginatedSearch(
"posts",
2, // page 2
10, // 10 per page
{
status: { $eq: "published" },
title: { $ilike: "%react%" },
},
{ created_at: "desc" }
);将分页与查询条件结合:
typescript
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 Approach
REST API实现方式
Query Parameters
查询参数
bash
undefinedbash
undefinedPage 1, 20 per page
第1页,每页20条
Page 2
第2页
With sorting
带排序
With filters (URL-encoded JSON)
带筛选条件(URL编码的JSON)
POST for Complex Queries
复杂查询使用POST请求
bash
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
}'bash
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
}'Response Format
响应格式
json
{
"ok": true,
"data": [...],
"meta": {
"total": 150,
"limit": 20,
"offset": 0
}
}json
{
"ok": true,
"data": [...],
"meta": {
"total": 150,
"limit": 20,
"offset": 0
}
}React Integration
React集成
Basic Paginated List
基础分页列表
tsx
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>Loading...</p>
) : (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)}
<div className="pagination">
<button
disabled={page === 1}
onClick={() => setPage(page - 1)}
>
Previous
</button>
<span>Page {page} of {totalPages}</span>
<button
disabled={page >= totalPages}
onClick={() => setPage(page + 1)}
>
Next
</button>
</div>
</div>
);
}tsx
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>
);
}With SWR (Recommended)
结合SWR使用(推荐)
tsx
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>Loading...</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)}
>
Prev
</button>
{/* Page numbers */}
{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)}
>
Next
</button>
</div>
);
}tsx
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>
);
}Load More / Infinite Scroll
加载更多 / 无限滚动
tsx
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, // Use current length as offset
});
setPosts((prev) => [...prev, ...data]);
setHasMore(posts.length + data.length < meta.total);
setLoading(false);
}, [posts.length, loading, hasMore]);
// Initial load
useEffect(() => {
loadMore();
}, []);
return (
<div>
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
{hasMore && (
<button onClick={loadMore} disabled={loading}>
{loading ? "Loading..." : "Load More"}
</button>
)}
{!hasMore && <p>No more posts</p>}
</div>
);
}tsx
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>
);
}Infinite Scroll with Intersection Observer
结合Intersection Observer的无限滚动
tsx
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]);
// Intersection Observer for auto-load
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]);
// Initial load
useEffect(() => {
loadMore();
}, []);
return (
<div>
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
<div ref={loaderRef} style={{ height: 20 }}>
{loading && <p>Loading...</p>}
{!hasMore && <p>End of list</p>}
</div>
</div>
);
}tsx
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]);
// 使用Intersection Observer实现自动加载
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-Synced Pagination
与URL同步的分页
tsx
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>
{/* ... render posts ... */}
<Pagination
page={page}
totalPages={totalPages}
onPageChange={setPage}
/>
</div>
);
}tsx
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>
);
}Common Patterns
常见模式
Configurable Default Limit
配置默认每页记录数
Configure default page size in SDK:
typescript
const api = new Api({
host: "http://localhost:7654",
data: {
defaultQuery: {
limit: 25, // Default if not specified
},
},
});在SDK中配置默认的每页大小:
typescript
const api = new Api({
host: "http://localhost:7654",
data: {
defaultQuery: {
limit: 25, // 未指定时的默认值
},
},
});Server-Side Pagination (Next.js)
服务端分页(Next.js)
typescript
// 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"
/>
</>
);
}typescript
// 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"
/>
</>
);
}Paginate with Relations
关联数据的分页
typescript
const result = await paginate("posts", page, pageSize, {
where: { status: { $eq: "published" } },
sort: { created_at: "desc" },
with: {
author: { select: ["id", "name", "avatar"] },
},
});typescript
const result = await paginate("posts", page, pageSize, {
where: { status: { $eq: "published" } },
sort: { created_at: "desc" },
with: {
author: { select: ["id", "name", "avatar"] },
},
});Count Total Without Data
仅统计总数不获取数据
If you only need count (e.g., for showing total):
typescript
const { data } = await api.data.count("posts", {
status: { $eq: "published" },
});
console.log(`${data.count} total posts`);如果只需要总数(例如用于显示总数量):
typescript
const { data } = await api.data.count("posts", {
status: { $eq: "published" },
});
console.log(`总共有${data.count}篇已发布文章`);Common Pitfalls
常见陷阱
Forgetting Pagination
忘记使用分页
Problem: Loading all records causes performance issues.
Fix: Always paginate large datasets:
typescript
// Wrong - loads everything
const { data } = await api.data.readMany("posts");
// Correct - paginate
const { data } = await api.data.readMany("posts", {
limit: 20,
offset: 0,
});问题: 加载全部记录会导致性能问题。
解决方法: 对于大型数据集,始终使用分页:
typescript
// 错误示例 - 加载全部记录
const { data } = await api.data.readMany("posts");
// 正确示例 - 使用分页
const { data } = await api.data.readMany("posts", {
limit: 20,
offset: 0,
});Off-by-One Page Calculation
页码计算错误(差一问题)
Problem: Using 0-indexed pages.
Fix: Use 1-indexed pages with correct offset formula:
typescript
// Wrong (if page is 1-indexed)
offset: page * pageSize // Skips first page!
// Correct
offset: (page - 1) * pageSize问题: 使用从0开始计数的页码。
解决方法: 使用从1开始计数的页码,并使用正确的偏移量公式:
typescript
// 错误示例(如果页码从1开始计数)
offset: page * pageSize // 会跳过第一页的数据!
// 正确示例
offset: (page - 1) * pageSizeNot Tracking Total
未跟踪总记录数
Problem: Can't show "Page X of Y" without total.
Fix: Always use from response:
meta.totaltypescript
const { data, meta } = await api.data.readMany("posts", query);
const totalPages = Math.ceil(meta.total / pageSize);问题: 无法显示“第X页,共Y页”的信息。
解决方法: 始终使用响应中的字段:
meta.totaltypescript
const { data, meta } = await api.data.readMany("posts", query);
const totalPages = Math.ceil(meta.total / pageSize);Duplicate Items in Infinite Scroll
无限滚动中出现重复数据
Problem: Same items appear multiple times when data changes.
Fix: Use unique keys and deduplicate:
typescript
setPosts((prev) => {
const ids = new Set(prev.map(p => p.id));
const newPosts = data.filter(p => !ids.has(p.id));
return [...prev, ...newPosts];
});问题: 数据更新时,相同的条目多次出现。
解决方法: 使用唯一键并去重:
typescript
setPosts((prev) => {
const ids = new Set(prev.map(p => p.id));
const newPosts = data.filter(p => !ids.has(p.id));
return [...prev, ...newPosts];
});Page Exceeds Total
访问不存在的页码
Problem: Navigating to non-existent page.
Fix: Clamp page number:
typescript
const safePage = Math.min(page, totalPages);
const safeOffset = (safePage - 1) * pageSize;问题: 导航到超出总页数的页码。
解决方法: 限制页码的范围:
typescript
const safePage = Math.min(page, totalPages);
const safeOffset = (safePage - 1) * pageSize;Verification
验证方法
-
Check first page returns correct count:typescript
const { data, meta } = await api.data.readMany("posts", { limit: 10 }); console.log(data.length, "of", meta.total); -
Verify last page doesn't error:typescript
const lastPage = Math.ceil(meta.total / pageSize); const { data } = await api.data.readMany("posts", { limit: pageSize, offset: (lastPage - 1) * pageSize, }); -
Test empty results:typescript
const { data } = await api.data.readMany("posts", { where: { title: { $eq: "nonexistent" } }, limit: 10, }); console.log("Empty:", data.length === 0);
-
检查第一页返回的记录数是否正确:typescript
const { data, meta } = await api.data.readMany("posts", { limit: 10 }); console.log(data.length, "条,总共有", meta.total, "条"); -
验证最后一页不会报错:typescript
const lastPage = Math.ceil(meta.total / pageSize); const { data } = await api.data.readMany("posts", { limit: pageSize, offset: (lastPage - 1) * pageSize, }); -
测试空结果的情况:typescript
const { data } = await api.data.readMany("posts", { where: { title: { $eq: "不存在的标题" } }, limit: 10, }); console.log("是否为空:", data.length === 0);
DOs and DON'Ts
注意事项
DO:
- Always paginate large datasets
- Use for page calculations
meta.total - Handle edge cases (empty, last page)
- Use SWR/React Query for caching
- Sync pagination with URL when appropriate
- Pre-fetch next page for smoother UX
DON'T:
- Load all records at once
- Use 0-indexed pages (convention is 1-indexed)
- Forget to handle loading states
- Ignore empty state UI
- Skip pagination in admin/debug views (still paginate!)
- Assume data won't change between pages
建议:
- 对于大型数据集,始终使用分页
- 使用进行页码计算
meta.total - 处理边界情况(空数据、最后一页)
- 使用SWR/React Query进行缓存
- 适当情况下将分页状态与URL同步
- 预加载下一页以提升用户体验
禁止:
- 一次性加载全部记录
- 使用从0开始计数的页码(常规约定是从1开始)
- 忽略加载状态的处理
- 忽略空状态的UI展示
- 在管理/调试视图中跳过分页(仍需使用分页!)
- 假设页面之间数据不会发生变化
Related Skills
相关技能
- bknd-crud-read - Basic read operations
- bknd-query-filter - Combine pagination with filtering
- bknd-bulk-operations - Paginate bulk operations
- bknd-client-setup - Configure SDK with default pagination
- bknd-crud-read - 基础读取操作
- bknd-query-filter - 结合分页与筛选
- bknd-bulk-operations - 批量操作的分页
- bknd-client-setup - 配置带有默认分页的SDK