ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Django分页实战:从后端Paginator到前端页码组件的完整实现

Django分页实战:从后端Paginator到前端页码组件的完整实现 前端做分页说简单也简单说坑也真不少。尤其是Django项目后端框架自带了一套很完整的分页机制但很多新手第一次接触时容易被模板语法、请求参数、页码越界这些细枝末节卡住最后甚至干脆用前端把数据一把梭全渲染出来再靠JavaScript做个“假分页”。这种做法在小项目里能凑合数据量一旦上来页面加载直接卡成幻灯片。这篇文章就基于我最近在一个后台管理系统里给列表页加分页的真实经验把Django配合前端实现分页的完整链路拆开讲清楚包括后端分页器的正确用法、API返回结构怎么设计、前端渲染和交互怎么写、以及几个我踩过之后才明白的坑。适合刚学完Django基础、准备做完整项目的新手也适合那些做了一半发现列表太长、想补分页的同学参考。1. 先想清楚到底做前端分页还是后端分页拿到“前端分页”这个需求先别急着写代码。第一步是跟产品或者跟你自己确认一个事这个分页的数据量到底有多大1.1 两种分页方案的本质区别所谓前端分页通常有两种理解方式。一种是数据一次性从后端取回来存在前端的内存里翻页的时候纯靠JavaScript在本地做切片这不涉及重新请求接口。另一种是页面上的分页按钮、页码组件是前端的但每次点击页码都重新向后端发起请求由后端根据页码把对应的一小段数据返回来前端只负责渲染。第一种做法本质上叫作“假分页”。第二种才是真正生产环境里天天在用的“真分页”只是分页器是前端渲染的。很多新手把这两者搞混上来就在前端切数据导致接口返回几千条记录页面DOM直接爆炸。1.2 为什么推荐用后端分页器Django自带一个名叫Paginator的分页器它解决的问题非常纯粹给你一个对象列表告诉你每页显示几条它会自动算出一共有多少页、当前页有哪些数据、上一页下一页页码是多少。这背后使用的LIMIT和OFFSET机制在数据量大时能显著减少网络传输和渲染压力。我做项目时给自己定了一条规矩凡是列表超过50条一律后端分页。原因很简单前端假分页有两个硬伤你很可能会遇到首次加载慢。一次性拿几千条数据接口响应时间和带宽消耗都上去了。数据不一致。如果有人在其他端修改了数据你前端内存里的旧数据翻到下一页还是老样子用户会以为更新失败了。所以这篇文章讲的做法是后端负责数据切片前端负责页码交互双方通过URL参数通信。这也是绝大多数企业项目的标准做法。2. 数据准备搭建可复现的演示环境和模型为了让后面的代码跑得通我先给你一个可以直接照抄的最小Demo环境。假设我们要对一个文章列表做分页这是最常见的场景。2.1 项目初始化和模型定义django-admin startproject myproject cd myproject python manage.py startapp blog接着在blog/models.py里定义一个简单的文章模型from django.db import models class Article(models.Model): title models.CharField(max_length100, verbose_name标题) content models.TextField(verbose_name正文) created_at models.DateTimeField(auto_now_addTrue, verbose_name创建时间) class Meta: ordering [-created_at] # 按创建时间倒序 verbose_name 文章 verbose_name_plural verbose_name def __str__(self): return self.title这里有个细节ordering字段非常关键。分页背后是数据库切片如果排序不稳定分页结果就可能出现重复或者遗漏。你想想第一页和第二页的边界数据如果因为排序一致而漂移用户翻页时会明显觉得数据怪怪的。所以我会建议你在模型里就固定好默认排序如果业务上确实需要不同排序也要在查询集上明确调用order_by。2.2 造数据一条命令刷出100条测试记录写一个临时的脚本直接在shell里跑也行用test命令里的setup_test_data也行。我图省事直接开了Django shellfrom blog.models import Article from datetime import timedelta from django.utils import timezone for i in range(1, 101): Article.objects.create( titlef测试文章第{i}篇, contentf这是文章内容序号{i}, created_attimezone.now() - timedelta(daysi) )完成后确认一下数据量Article.objects.count() # 输出100有了数据下面就可以开始写后端分页逻辑了。我建议你多插几条把总数控制在100左右正好方便我们观察页码边界。3. Django后端分页核心实现从Paginator到page_obj的完整解读Django的分页器写起来非常简单但很多人只会抄Paginator的官方示例不知道每个返回对象究竟提供了哪些东西。这里我把关键知识一次性讲透。3.1 Paginator的构造参数和工作原理先看官方标准用法from django.core.paginator import Paginator from blog.models import Article article_list Article.objects.all() paginator Paginator(article_list, 10)第二个参数10代表每页显示10条。这个Paginator内部会做以下几件事调用list(queryset)或者使用数据库的COUNT语句统计总记录数。根据总记录数和每页条数计算总页数遇到余数自动向上取整。提供page(number)方法传入页码返回一个Page对象。Page对象才是你真正打交道的东西。它的常用属性和方法包括属性/方法作用page_obj.object_list当前页的数据列表page_obj.has_previous()是否有上一页page_obj.has_next()是否有下一页page_obj.previous_page_number()上一页页码page_obj.next_page_number()下一页页码page_obj.number当前页码paginator.num_pages总页数paginator.page_range页码范围比如range(1, 11)3.2 视图里的标准写法在blog/views.py里写一个最简单的分页视图from django.shortcuts import render from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger from blog.models import Article def article_list(request): article_list Article.objects.all() paginator Paginator(article_list, 10) page_number request.GET.get(page, 1) try: page_obj paginator.page(page_number) except PageNotAnInteger: # 如果page参数不是一个整数回到第一页 page_obj paginator.page(1) except EmptyPage: # 如果页码超出范围给最后一页 page_obj paginator.page(paginator.num_pages) context { page_obj: page_obj, } return render(request, blog/article_list.html, context)这个写法里有两个异常处理值得你细品。PageNotAnInteger是用户直接在URL里输入?pageabc时触发的。这种情况下你强行去转换页码只会抛异常不如直接默认回第一页。EmptyPage更常见可能是手动改了URL也可能翻页翻到超出范围。我见过两种处理风格一种是像上面这样给最后一页一种是返回到空页面。我更推荐回最后一页因为用户翻到越界页时他大概率是想看更多新内容不是想看一片空白。3.3 Django模板里怎么渲染分页控件模板里我们可以直接调用page_obj中的方法。下面是一个很典型的模板片段{% if page_obj.has_previous %} a href?page{{ page_obj.previous_page_number }}上一页/a {% else %} span上一页/span {% endif %} span第 {{ page_obj.number }} / {{ page_obj.paginator.num_pages }} 页/span {% if page_obj.has_next %} a href?page{{ page_obj.next_page_number }}下一页/a {% else %} span下一页/span {% endif %}这是最原始的分页能跑但页面一多就不好用了。用户想到第7页去就得一页页点。接下去我们要做的是前端交互更友好的版本同时把数据通过JSON接口返回让前端自由决定怎么渲染项目的扩展性一下子就上来了。我自己的经验是如果你的项目是服务端渲染为主直接在模板里用for page in page_obj.paginator.page_range生成所有页码再用CSS高亮当前页倒是完全够用。但如果项目后期有小程序、APP或者前后端分离的可能那就一定走JSON接口一步到位。4. 进阶实战JSON接口 前端页码组件的真分页实现这部分是我这篇文章的重头戏。Django模板渲染分页虽然快但绝大多数前后端分离的项目、纯前端页面都需要后端返回JSON数据由前端负责分页UI的渲染和交互。我们照着这个思路把前面那个article_list视图改造成一个API接口再配合前端实现完整的分页组件。4.1 后端返回JSON结构的设计思路接口设计的好坏直接决定了前端写起来顺不顺手。一段合理的分页JSON应当同时包含两部分信息当前页的数据列表以及分页的元数据。import json from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger from django.http import JsonResponse from blog.models import Article def article_list_api(request): page_size int(request.GET.get(page_size, 10)) page_number request.GET.get(page, 1) article_list Article.objects.all() paginator Paginator(article_list, page_size) try: page_obj paginator.page(page_number) except PageNotAnInteger: page_obj paginator.page(1) except EmptyPage: page_obj paginator.page(paginator.num_pages) articles [ { id: article.id, title: article.title, created_at: article.created_at.strftime(%Y-%m-%d %H:%M:%S) } for article in page_obj.object_list ] data { code: 0, data: { articles: articles, page: { current: page_obj.number, total: paginator.num_pages, page_size: page_size, total_count: paginator.count, has_prev: page_obj.has_previous(), has_next: page_obj.has_next(), } } } return JsonResponse(data)注意这个设计里的几个巧思。第一我把page_size也作为请求参数暴露出来了。前端可以自由选择每页显示5条、10条还是20条只需重新请求一次接口后端自动重新切片。第二返回的page对象里我把has_prev和has_next单独挑了出来。前端在渲染时可以直接根据这两个布尔值判断按钮是否可点击。这个字段叫法上我现在更习惯用驼峰hasPrevJavaScript那边更顺手不过各团队约定不同你自己保持一致就行。第三日期时间字段我用strftime做了格式化。否则前端拿到的可能是2025-05-01T10:30:00Z这种ISO格式需要再手动转一遍才能正常显示多一步就多一个出错的可能。4.2 前端页面骨架和CSS基础前端部分我直接用原生JavaScript写不引入框架。这样你能看清分页交互的核心逻辑后面迁到Vue、React也只差一个数据绑定的距离。HTML部分div idarticle-app table thead tr thID/th th标题/th th创建时间/th /tr /thead tbody idarticle-tbody !-- 动态渲染 -- /tbody /table div classpagination idpagination !-- 分页按钮 -- /div /divCSS部分我给分页按钮做一个最基础的样式让当前页高亮禁用的按钮降低透明度.pagination { display: flex; gap: 6px; margin-top: 20px; } .pagination button { padding: 6px 12px; border: 1px solid #ddd; background: #fff; cursor: pointer; border-radius: 4px; } .pagination button.active { border-color: #409eff; color: #409eff; background: #ecf5ff; } .pagination button:disabled { opacity: 0.5; cursor: not-allowed; }4.3 核心页码计算与渲染逻辑终于到关键环节了。很多初级前端在这里卡住不知道怎么把“总共30页”转化成“页面上最多显示10个页码按钮”。这里有一个经典的页码窗口算法我直接给你封装好。function getPageItems(current, total, maxButtons 9) { if (total maxButtons) { // 总页数太少全部显示 return Array.from({ length: total }, (_, i) i 1); } const half Math.floor(maxButtons / 2); let start current - half; let end current half; if (start 1) { start 1; end start maxButtons - 1; } if (end total) { end total; start end - maxButtons 1; } const items []; for (let i start; i end; i) { items.push(i); } return items; }这个算法的核心思路是让当前页尽量保持在按钮序列的中间但如果当前页靠近第一页或最后一页就自动把窗口挪到开头或结尾。你可以把分页想象成一个可以在长条上左右滑动的窗口窗口永远看得见一部分页码但永远不超出边界。有了页码数组接下来就是渲染按钮了let currentPage 1; const params new URLSearchParams(window.location.search); async function fetchArticles(page) { // URLSearchParams可以兼容当前页面可能存在的其他查询参数 params.set(page, page); const resp await fetch(/blog/api/articles/?${params.toString()}); const json await resp.json(); if (json.code ! 0) return; renderTable(json.data.articles); renderPagination(json.data.page); } function renderTable(articles) { const tbody document.getElementById(article-tbody); tbody.innerHTML articles.map(article tr td${article.id}/td td${article.title}/td td${article.created_at}/td /tr ).join(); } function renderPagination(page) { const pagination document.getElementById(pagination); pagination.innerHTML ; const prevBtn document.createElement(button); prevBtn.textContent 上一页; prevBtn.disabled !page.has_prev; prevBtn.addEventListener(click, () { if (page.has_prev) fetchArticles(page.current - 1); }); pagination.appendChild(prevBtn); const pages getPageItems(page.current, page.total, 9); pages.forEach(p { const btn document.createElement(button); btn.textContent p; if (p page.current) { btn.classList.add(active); } btn.addEventListener(click, () fetchArticles(p)); }); pagination.appendChild(pagesBtn); const nextBtn document.createElement(button); nextBtn.textContent 下一页; nextBtn.disabled !page.has_next; nextBtn.addEventListener(click, () { if (page.has_next) fetchArticles(page.current 1); }); pagination.appendChild(nextBtn); } // 首次加载 fetchArticles(currentPage);这里有几个容易忽略但是特别重要的点我用URLSearchParams而不是字符串拼接来维护查询参数因为它能正确处理那些包含特殊字符的参数值未来要加筛选条件、搜索关键字的时候直接params.set(keyword, value)就行不会把URL搞坏。每次点击页码fetchArticles都会重新请求后端。这看起来比前端切数据要多几次网络请求但实际上每次请求的数据量小得多页面渲染快服务器的内存压力也小这是真分页该有的样子。每个页码按钮都用了addEventListener而不是onclick因为这样不会互相覆盖。如果你在循环里直接btn.onclick () fetchArticles(p)由于闭包特性所有按钮点击时取的p都可能是循环结束后的最后一个值。用addEventListener配合const声明每次循环的p被正确固定问题自然消失。4.4 尊重浏览器原生能力从URL读取初始页码另外一个对用户体验影响很大的细节是当用户点击浏览器的后退按钮、或者手动刷新页面时我们应该尽量保持当前的页码状态。做法是把当前页码同步到URL的query参数里。上面的实现里我用params.set(page, page)之后并没有直接修改window.history这意味着刷新会丢失当前页。更好的做法是每次请求成功后把页码写回地址栏function updateUrl(page) { const newUrl ${window.location.pathname}?page${page}; window.history.replaceState(null, , newUrl); }然后在页面加载时从URL里取page参数作为初始页码const initialPage parseInt(new URLSearchParams(window.location.search).get(page)) || 1; fetchArticles(initialPage);这样用户不管是从收藏夹进来还是刷新页面都能回到之前浏览的位置。我上次给别人做后台管理系统时这个细节直接被产品经理单独表扬了说“没想到你连这个都想到了”。其实不是聪明是我之前翻页时刷新一下回到第一页翻了半天才找到那条数据这种体验实在太糟心了。5. 联调现场参数对齐与边界值测试清单前后端联调是分页功能最容易出错的地方80%的Bug都出在参数约定和边界处理上。这里我建议你养成一个习惯写一个联调checklist把每个边界情况都过一遍。下面就是从我项目里直接拿过来的版本。5.1 参数对齐最容易踩的三个坑第一个坑前端传的页码是字符串后端忘了转整数。request.GET.get(page)拿到的永远是字符串如果直接拿去跟整数比较、做运算会在意想不到的地方报错。上面我在视图里没有显式转换page_number而是交给了Paginator.page()它内部会自动处理整数化。但如果你需要拿页码做判断记得先int()。第二个坑page_size不设上限。这个值得重点强调。如果前端传一个page_size999999你就相当于把整个表全量返回了分页形同虚设。在Django后端我给接口加了一个简单的保护page_size int(request.GET.get(page_size, 10)) page_size min(page_size, 100)这个最大值根据你业务数据大小来定但无论如何得有。否则就是一条SQL把数据库打垮的经典事故现场。第三个坑当paginator.num_pages 0时即数据库里没有数据paginator.page(1)会抛出EmptyPage。你的异常处理逻辑一定要能覆盖这种情况。我在上面的视图代码里paginator.page(1)在PageNotAnInteger分支出现但如果没有任何文章且page_number被恶意传成abc就会先走PageNotAnInteger分支再抛EmptyPage。这就是为什么我在视图里把两个异常处理分开逻辑上其实覆盖了这种边缘情况。如果你心里没底可以在接口返回里加一个isEmpty字段前端根据它来渲染“暂无数据”体验更友好。5.2 我实测过的一组合法用例下面这组测试用例你在本地把服务跑起来照着过一遍基本上能把分页功能保到及格线以上。场景请求预期行为正常第一页/api/articles/?page1返回前10条has_nexttruecurrent1正常中间页/api/articles/?page5返回第41-50条has_prevtruehas_nexttrue最后一页/api/articles/?page10返回第91-100条has_nextfalse超出范围/api/articles/?page999返回最后一页current10非数字/api/articles/?pageabc返回第一页current1无page参数/api/articles/返回第一页current1自定义页大小/api/articles/?page3page_size20返回第41-60条total5这套用例在每次后端修改分页逻辑后都要重新跑一遍。不要嫌烦真出Bug的时候这一遍能帮你节省至少一个小时的排查时间。6. 常见问题实录我踩过的分页坑和排查技巧写技术文章不分享踩坑等于白写。下面这几个问题是我在Django分页开发中真实遇到过、并且花了不少时间才解决的你提前知道能省下大把头发。6.1 重复数据或数据缺失第一时间怀疑排序现象是第一页末尾出现了一条数据第二页开头又出现一次或者某条数据永远消失不见。这是分页场景下非常典型的“边界漂移”问题。根源几乎都是查询集没有稳定的排序。你可以做个实验Article.objects.all()在PostgreSQL里如果表结构没有变默认按主键升序可能保持稳定但一旦发生数据变更、数据库优化器抽风返回顺序就可能变得不可预测。建议在所有分页查询集上明确调用Article.objects.all().order_by(id)按主键排序是最稳定的方案。如果有业务排序也要保证排序字段不重复。比如你想按创建时间倒序但同一秒内插入了两条数据排序时就会出现并列这时最好追加次要排序order_by(-created_at, -id)。6.2 页码越界时页面白屏这个坑的现场是这样的用户把URL改成了?page999后端返回了空列表前端模板里又只用{% for article in page_obj.object_list %}渲染结果页面变成一片空白用户一脸懵。正确做法就是我在视图里写的那样捕获EmptyPage异常后返回最后一页。前端同时也要做好空数据的兜底展示“暂无数据”。6.3 快速翻页时页面短时间内连续请求性能急剧下降有次我做活动列表用户疯狂点击下一页后端接口每秒收到几十个请求每个请求都要执行一次COUNT加一次SELECT数据库压力瞬间拉满。解决方案也不复杂服务端给接口加缓存把高频请求的页内容存起来。前端做节流比如在fetch进行中时忽略其他的点击事件等响应回来再开放。前端最简单粗暴的做法就是加一个isLoading标志位let isLoading false; async function fetchArticles(page) { if (isLoading) return; isLoading true; try { // 原有逻辑 } finally { isLoading false; } }6.4 大数据量下统计总数也逐渐变慢分页必须执行count()这个操作在大表上很慢。Django的Paginator默认会对查询集执行SELECT COUNT(*)如果表里有了几十万条数据每次分页请求都多一次慢查询这是不能忽略的。我的建议是如果数据量真的到这个级别优先保证表上有适当的索引特别是排序字段和筛选字段的组合索引。同时可以给总数加一层缓存比如用cache.set(article_total_count, paginator.count, 60 * 5)过期时间为5分钟。数据变化不那么频繁的场景这个方案性价比极高。另外还有一种更精细的做法是不管总数直接判断“有没有下一页”比如取每页page_size 1条如果取到了第page_size 1条说明还有下一页然后只把前page_size条返回给前端。这个技巧能省掉COUNT查询但会让分页器里的总页数、总条数这几个信息失真适合只需要上一页下一页、不需要精确页码的“轻分页”场景。7. 提升体验的进阶玩法Django模板分页迭代器最后分享一个使用频率很高、但很多教程都不讲的小技巧在Django自带的模板渲染方案里用page_range配合自定义过滤器只显示部分页码而不是把所有页码都列出来。假设我们的文章有100页把100个页码全部渲染在页面上不仅难看也难用。常规做法是只显示当前页附近的几个页码再加一个“省略号”表示中间隐藏部分。这本质上跟前面前端getPageItems算法是一个思路只是换到了模板语言里。写一个模板过滤器from django import template register template.Library() register.filter def page_window(page_obj, window_size5): 返回当前页附近的部分页码列表 current page_obj.number total page_obj.paginator.num_pages if total window_size: return range(1, total 1) half window_size // 2 start max(1, current - half) end min(total, start window_size - 1) start max(1, end - window_size 1) return range(start, end 1)然后在模板里这样使用{% load pagination_tags %} div classpagination {% if page_obj.has_previous %} a href?page{{ page_obj.previous_page_number }}上一页/a {% endif %} {% for page_num in page_obj|page_window:5 %} {% if page_num page_obj.number %} span classcurrent{{ page_num }}/span {% else %} a href?page{{ page_num }}{{ page_num }}/a {% endif %} {% endfor %} {% if page_obj.has_next %} a href?page{{ page_obj.next_page_number }}下一页/a {% endif %} /div如果你想要更细的交互比如第一页、最后一页固定显示中间用省略号连接那就在过滤器里多返回几个特殊标记模板里针对None渲染省略号。具体实现不复杂核心就是你想明白页码不是数据只是UI元素你完全有权力按需渲染它们。写在最后分页这件事做到什么程度算合格回头看分页功能本身并不难难点全在细节。参数要正确解析边界要兜住排序要稳定UI交互要符合直觉还要兼顾性能。这一整套组合拳打下来才算一个合格的分页功能。我见过太多项目上线半年后分页出现重复数据、白屏、慢查询的问题最后排查一圈发现都是最基础的几个点没做好。如果你刚好在看这篇文章打算给自己的Django项目加分页我建议你按这个顺序动手先确认数据量决定后端真分页还是前端假分页然后把后端接口写稳把异常处理补全再写前端渲染页码窗口算法直接用我给出的代码最后把测试用例清单过一遍。整个过程半小时到一小时就能跑通。等你把分页做得滚瓜烂熟再去接触搜索筛选、排序联动、异步加载这些高级功能你会发现它们共同的基础逻辑其实就是你这次理解透了的“切片、传参、渲染、兜底”这四板斧。
返回列表