前言
「数组分页」这件事本身很简单:给一个数组和一个页码,切出对应的那一段。核心就是一个函数------array_slice()。但写完之后总会有几个地方让人反复调试:页码超出范围时返回空数组还是返回最后一页?总页数怎么算才不会多出一页?切完之后数组的键为什么变成了 0、1、2 而不是原来的?
还有一个必须先说清的前提:对已经拿到内存里的数组做分页,和对数据库做分页,是完全不同的两件事 。数据存在数据库里时,正确做法是把分页下推到 SQL 的 LIMIT ... OFFSET ...(或者按主键游标分页),让服务端只返回当前页的几十行。如果你先 SELECT * 把几十万行读进 PHP 数组,再用 array_slice() 切出一页,那么每一页请求都会把整表的数据从数据库搬到 PHP 进程里,内存和网络开销都不受页码控制。数组分页只适合数据本来就在内存里的场景:配置文件、静态列表、缓存好的小数据集、第三方 API 一次性返回的完整结果。
本文讲清 array_slice() 的参数与键的处理,给出一份可以直接用 php pager.php 跑起来的分页实现,并指出分页计算和页码校验上最容易出错的几个点。示例以 PHP 8.0 及以上为基准。
一、切片的核心:array_slice
签名如下:
php
array_slice(array $array, int $offset, ?int $length = null, bool $preserve_keys = false): array
四个参数的含义:
| 参数 | 说明 |
|---|
|----------|----------------|
| $array | 源数组,按值传入,不会被修改 |
|-----------|-------------------------------------|
| $offset | 起始位置。非负表示从第几个元素开始(从 0 数);为负表示从末尾往前数 |
|-----------|-----------------------------|
| $length | 取多少个。省略表示取到末尾;为负表示「排掉末尾若干个」 |
|------------------|--------------------------------|
| $preserve_keys | 是否保留原键。默认 false,即整数键重新从 0 编号 |
关于键,有一条规则要单独记住:字符串键永远保留,$preserve_keys 只影响整数键。
php
<?php // 适用于 PHP 8.0+
$list = ['a', 'b', 'c', 'd', 'e'];
print_r(array_slice($list, 1, 2));
// [0 => 'b', 1 => 'c'] ------ 整数键从 0 重新编号
print_r(array_slice($list, 1, 2, true));
// [1 => 'b', 2 => 'c'] ------ 保留原键
$assoc = ['x' => 1, 'y' => 2, 'z' => 3];
print_r(array_slice($assoc, 1, 1));
// ['y' => 2] ------ 字符串键自动保留,第四个参数对它没有影响
// 负偏移:从倒数第二个开始,取两个
print_r(array_slice($list, -2, 2)); // [0 => 'd', 1 => 'e']
分页场景里 $preserve_keys 基本都保持默认的 false,因为前端要的是「这一页的 N 条记录」,键从 0 开始才方便遍历和 json_encode()。反过来,如果这一页要显示原数组里的「序号」,就要用 true 保留原键。
二、分页的计算:总页数、页码边界与偏移量
给定总条数 $total 和每页条数 $perPage,需要算出三个数:
- 总页数:
(int) ceil($total / $perPage) - 当前页的起始偏移:
($page - 1) * $perPage - 当前页的有效页码范围:1 到总页数
这里有几个边界必须显式处理,否则就会出现「第 0 页」和「页码超出时返回空列表」这类问题:
$perPage必须大于 0,否则除零,而且切片长度也没意义。$total为 0 时,ceil(0 / 10)得 0,此时「总页数」应该是 0 还是 1?这取决于接口约定。返回 0 表示「没有数据」,返回 1 表示「有一页但内容为空」。前后端要统一,推荐明确返回 0。- 页码默认值:没有传页码时用 1,不要用 0。
- 页码超出上界时,是返回空数组还是自动钳到最后一页?多数后台列表的体验是钳到最后一页,但这会让「用户手动输入超大页码」得到最后一页的内容而不是空页,需要和前端约定清楚。
页码来自 $_GET 时,必须做类型校验,不能直接参与运算:
php
<?php // 适用于 PHP 8.0+
// ✅ 强制转成整数并夹到合法范围,非法输入一律回落到第 1 页
$page = filter_input(INPUT_GET, 'page', FILTER_VALIDATE_INT, [
'options' => ['default' => 1, 'min_range' => 1],
]);
filter_input() 在输入不是合法整数时给出默认值 1,这一步就把 ?page=abc、?page=-5 这类输入挡掉了。不这么做的话,(int) $_GET['page'] 虽然也能防止注入,但 ?page=0 会得到负数偏移量,array_slice() 会把它当作「从末尾倒数」,于是第 0 页变成了最后一段数据------这是个很容易被忽略的行为。
三、实战:一个可直接运行的分页函数
下面这段代码用 PHP CLI 直接运行,不需要 Web 服务器:
php
<?php // 适用于 PHP 8.0+,用 php pager.php 运行
/**
* 对内存中的数组分页
*
* @param array $rows 完整数据集
* @param int $page 当前页码,从 1 开始
* @param int $perPage 每页条数
* @return array{items: array, page: int, per_page: int, total: int, last_page: int, has_prev: bool, has_next: bool}
*/
function paginate(array $rows, int $page = 1, int $perPage = 10): array
{
// 1. 参数兜底:每页条数必须为正
if ($perPage < 1) {
$perPage = 10;
}
$total = count($rows); // count() 对数组是 O(1)
// 2. 总页数:向上取整;没有数据时为 0
$lastPage = $total === 0 ? 0 : (int) ceil($total / $perPage);
// 3. 页码钳制到 [1, lastPage];数据为空时固定在 1 便于显示
if ($lastPage === 0) {
$page = 1;
} else {
$page = max(1, min($page, $lastPage));
}
// 4. 计算偏移并切片;preserve_keys 保持默认 false,让前端拿到干净的列表
$offset = ($page - 1) * $perPage;
$items = array_slice($rows, $offset, $perPage);
return [
'items' => $items,
'page' => $page,
'per_page' => $perPage,
'total' => $total,
'last_page' => $lastPage,
'has_prev' => $page > 1,
'has_next' => $lastPage > 0 && $page < $lastPage,
];
}
// ---- 自测 ----
$rows = array_map(fn(int $i): array => ['id' => $i, 'name' => "user{$i}"], range(1, 23));
foreach ([1, 3, 9, 0, -4] as $p) {
$r = paginate($rows, $p, 10);
printf(
"请求页码 %3d -> 实际第 %d 页,本页 %2d 条,共 %d 页,首条 id=%d\n",
$p,
$r['page'],
count($r['items']),
$r['last_page'],
$r['items'][0]['id'] ?? 0
);
}
// 空数据集的表现
$empty = paginate([], 5, 10);
printf("空数据集:page=%d, total=%d, last_page=%d, items=%d\n",
$empty['page'], $empty['total'], $empty['last_page'], count($empty['items']));
预期输出(可以自己跑一遍核对):
text
请求页码 1 -> 实际第 1 页,本页 10 条,共 3 页,首条 id=1
请求页码 3 -> 实际第 3 页,本页 3 条,共 3 页,首条 id=21
请求页码 9 -> 实际第 3 页,本页 3 条,共 3 页,首条 id=21
请求页码 0 -> 实际第 1 页,本页 10 条,共 3 页,首条 id=1
请求页码 -4 -> 实际第 1 页,本页 10 条,共 3 页,首条 id=1
空数据集:page=1, total=0, last_page=0, items=0
这段代码里页码的钳制发生在切片之前 ,这是关键。如果先算偏移再钳制页码,负数或超界页码就会把 array_slice() 的负偏移语义带进来,切出末尾那一段。
如果分页不是为了「取一段」,而是要把整个数组切成固定大小的块(例如批量提交、批量插入),用 array_chunk() 更直接:
php
<?php // 适用于 PHP 8.0+
$all = range(1, 23);
// 切成每块 10 个,最后一块不足 10 个也照样是一块
$chunks = array_chunk($all, 10);
echo count($chunks), PHP_EOL; // 3
print_r($chunks[2]); // [21, 22, 23]
// 第三个参数为 true 时保留原键(对关联数组更直观)
$chunked = array_chunk(['a' => 1, 'b' => 2, 'c' => 3], 2, true);
print_r($chunked);
// [['a' => 1, 'b' => 2], ['c' => 3]]
三个「切数组」的函数容易混,对照一下:
| 函数 | 是否修改原数组 | 返回什么 | 键的处理 |
|---|
|-----------------|---|-------|-------------------|
| array_slice() | 否 | 切出的一段 | 整数键重排(可保留),字符串键保留 |
|-----------------|---|--------------|---------------|
| array_chunk() | 否 | 二维数组,每块是一个数组 | 默认重排,第三个参数可保留 |
|------------------|------------|---------|-----------|
| array_splice() | 是(按引用) | 被删掉的那一段 | 原数组整数键会重排 |
常见坑点
- 页码不校验就直接算偏移
❌ $offset = ($_GET['page'] - 1) * 10; ------ ?page=0 得到 -10,array_slice() 把它当负偏移,切出的是末尾 10 条 ✅ filter_input(INPUT_GET, 'page', FILTER_VALIDATE_INT, ['options' => ['default' => 1, 'min_range' => 1]]),再钳到 [1, lastPage]
- 总页数用整除算
❌ $lastPage = intdiv($total, $perPage); ------ 23 条每页 10 条得 2 页,第 3 页的 3 条永远看不到 ✅ (int) ceil($total / $perPage),并单独处理 $total === 0
- 忘了处理空数据集
❌ 空数组时 ceil(0 / 10) 得 0,紧接着把页码钳成 0,页面显示「第 0 页 / 共 0 页」 ✅ $total === 0 时把页码固定为 1、总页数为 0,单独分支处理
- 用
count()之外的方式判断有没有数据
❌ if (empty($items)) 在数据是 ['0'] 这种全假值数组时------其实非空数组 empty() 永远是 false,这里不会错;但写成 if (!$items && $page > 1) 那种混合判断就容易乱 ✅ 判断有没有本页数据统一用 count($items) === 0 或 $items === []
- 切片后整数键重排了却还在按原键取值
❌ $page1 = array_slice($rows, 0, 10); echo $page2[10]; ------ 第 2 页的键是 0 到 9,不是 10 到 19 ✅ 需要原键就传第四个参数 true;否则一律按 0 开始的下标访问
- 大表先全查出来再切片
❌ $all = $pdo->query('SELECT * FROM big_table')->fetchAll(); $page = array_slice($all, $offset, 20); ✅ 分页下推到 SQL:LIMIT 20 OFFSET 40,只让数据库返回当前页,PHP 侧不保存全量数据
array_chunk()当成分页用
❌ 为了取第 3 页而 array_chunk($rows, 10)[2] ------ 先把整个数组切成了所有页,白做了大量工作 ✅ 取一页用 array_slice();需要全部页时才用 array_chunk()
- 忽略
$perPage的用户输入
❌ $perPage = (int) ($_GET['size'] ?? 10); ------ 用户传 size=1000000 就会一次切出百万条并序列化返回 ✅ 给 $perPage 设白名单或上限:$perPage = min(100, max(1, (int) ($_GET['size'] ?? 10)));
总结
| 需求 | 做法 | 要点 |
|---|
|------|----------------------------------------------------|-----------|
| 取某一页 | array_slice($rows, ($page-1)*$perPage, $perPage) | 先钳页码,再算偏移 |
|-----|---------------------------------|--------------------------|
| 总页数 | (int) ceil($total / $perPage) | 必须向上取整;$total 为 0 单独处理 |
|------|-----------------------------------|-------------------------------|
| 页码来源 | filter_input() + min/max 钳制 | 不校验会触发 array_slice() 的负偏移语义 |
|------|------------------------|----------|
| 键的处理 | 默认重排,需要原键传第四个参数 true | 字符串键始终保留 |
|-------|-----------------|-----------------|
| 切成等长块 | array_chunk() | 会一次性切完全部,别拿它取单页 |
|--------|------------------------|-----------------|
| 数据在数据库 | LIMIT ... OFFSET ... | 数组分页只适合内存中的小数据集 |
数组分页的实现核心就三行:算总页数、钳页码、array_slice()。而实际项目里出问题的地方几乎都不在这三行里,而在「页码校验」和「该不该在 PHP 侧分页」这两个判断上。把分页尽量下推到数据源,把参数校验做在前面,剩下的代码自然就短了。