PHP 实现数组分页

前言

「数组分页」这件事本身很简单:给一个数组和一个页码,切出对应的那一段。核心就是一个函数------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 页」和「页码超出时返回空列表」这类问题:

  1. $perPage 必须大于 0,否则除零,而且切片长度也没意义。
  2. $total 为 0 时,ceil(0 / 10) 得 0,此时「总页数」应该是 0 还是 1?这取决于接口约定。返回 0 表示「没有数据」,返回 1 表示「有一页但内容为空」。前后端要统一,推荐明确返回 0。
  3. 页码默认值:没有传页码时用 1,不要用 0。
  4. 页码超出上界时,是返回空数组还是自动钳到最后一页?多数后台列表的体验是钳到最后一页,但这会让「用户手动输入超大页码」得到最后一页的内容而不是空页,需要和前端约定清楚。

页码来自 $_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() | 是(按引用) | 被删掉的那一段 | 原数组整数键会重排 |

常见坑点

  1. 页码不校验就直接算偏移

❌ $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]

  1. 总页数用整除算

❌ $lastPage = intdiv($total, $perPage); ------ 23 条每页 10 条得 2 页,第 3 页的 3 条永远看不到 ✅ (int) ceil($total / $perPage),并单独处理 $total === 0

  1. 忘了处理空数据集

❌ 空数组时 ceil(0 / 10) 得 0,紧接着把页码钳成 0,页面显示「第 0 页 / 共 0 页」 ✅ $total === 0 时把页码固定为 1、总页数为 0,单独分支处理

  1. 用 count() 之外的方式判断有没有数据

❌ if (empty($items)) 在数据是 ['0'] 这种全假值数组时------其实非空数组 empty() 永远是 false,这里不会错;但写成 if (!$items && $page > 1) 那种混合判断就容易乱 ✅ 判断有没有本页数据统一用 count($items) === 0 或 $items === []

  1. 切片后整数键重排了却还在按原键取值

❌ $page1 = array_slice($rows, 0, 10); echo $page2[10]; ------ 第 2 页的键是 0 到 9,不是 10 到 19 ✅ 需要原键就传第四个参数 true;否则一律按 0 开始的下标访问

  1. 大表先全查出来再切片

❌ $all = $pdo->query('SELECT * FROM big_table')->fetchAll(); $page = array_slice($all, $offset, 20); ✅ 分页下推到 SQL:LIMIT 20 OFFSET 40,只让数据库返回当前页,PHP 侧不保存全量数据

  1. array_chunk() 当成分页用

❌ 为了取第 3 页而 array_chunk($rows, 10)[2] ------ 先把整个数组切成了所有页,白做了大量工作 ✅ 取一页用 array_slice();需要全部页时才用 array_chunk()

  1. 忽略 $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 侧分页」这两个判断上。把分页尽量下推到数据源,把参数校验做在前面,剩下的代码自然就短了。

相关推荐
weishuangyun11 小时前
小程序制作平台怎么选?三大平台对比!
大数据·开发语言·javascript
我滴老baby1 小时前
Navidrome:飞牛OS安装、曲库导入与远程收听全流程
开发语言·数据库·php
code2cat1 小时前
Java进阶篇之AtomicLongArray:大数累加、上限检查与溢出边界
java·开发语言
小比特-combat1 小时前
LVGL_1(使用示例讲解父子关系)
开发语言·前端·javascript
开开心心_Every1 小时前
电脑OCR识别软件支持表格识别图片转Excel
java·开发语言·微信·计算机外设·ocr·intellij-idea·excel
徐小黑ACG1 小时前
Golang 基础03 函数
开发语言·后端·golang
Do_It_Today1 小时前
PHP xpath()函数讲解
开发语言·php
YYYing.1 小时前
【Python系列 (一) 】Python基础疑难杂症
开发语言·python
rannn_1111 小时前
JVM 垃圾回收面试题全解析:从基础到进阶
java·开发语言·jvm·后端·面试