Django搭建网络相册:无限滚动
4296 views, 2021/08/17 updated Go to Comments
有读者私信提问:点击页码的分页形式在移动端体验不佳,能否修改成无限滚动的分页形式?
那么本文就作为附加章节,聊聊无限滚动分页的实现方式,给大家参考。
如果你有其他感兴趣的内容,请在评论区告诉我。
无限滚动
无限滚动是指每当页面滑动到底部时,下一页的数据将自动被获取、并填充到页面底部。这样做的好处是省去了用户手动点击页码翻页的动作,这在移动端的体验提升是比较明显的。
无限滚动的重点在于不重载整个页面的情况下,对网页进行部分更新,这种技术被称为 AJAX(Asynchronous JavaScript and XML)。 AJAX 技术提供强大的灵活度,也让开发方式变得非常的不同。让我们在接下来的实践中感受吧。
后端部分
回顾前面章节中 Django 的开发模式:视图将数据作为上下文,传递到模板中,模板经过渲染(将标签替换为数据),显示到浏览器中。但问题是模板渲染通常是一个整体,要更新所有内容一起更新。而无限滚动仅仅只需要更新一小部分页面。
因此开发的思路需要变成这样:
- 后端提供两个 url 路径。
- 路径1作为浏览器访问的入口,提供页面基础的骨架。
- 路径2专门用于给路径1提供数据。
按照上述思路,先写好路径2的视图函数:
# /photo/views.py ... from django.http import JsonResponse # 获取数据的视图 def fetch_photos(request): photos = Photo.objects.values() paginator = Paginator(photos, 4) page_number = int(request.GET.get('page')) data = {} # 页码正确才返回数据 if page_number <= paginator.num_pages: paged_photos = paginator.get_page(page_number) data.update({'photos': list(paged_photos)}) return JsonResponse(data)
视图函数 fetch_photos()
最显著的特点是不再按照 MTV 模式,返回了一个带有数据的模板,而是仅仅只返回了数据。这个数据通过 JsonResponse(data)
被转换为 JSON 格式,方便前端读取。
if
语句保证只有正确的页码才会有数据。否则返回空值。
接着给路径1和路径2配置路由:
# /photo/urls.py from django.urls import path from photo.views import ( home, upload, oss_home, fetch_photos, ) from django.views.generic import TemplateView app_name = 'photo' urlpatterns = [ ... path( 'endless-home/', TemplateView.as_view(template_name='photo/endless_list.html'), name='endless_home' ), path('fetch/', fetch_photos, name='fetch'), ]
由于提供基础骨架的路径 endless_home
已经不需要提供自定义的 context
了,因此直接由 TemplateView
转发到模板即可。数据由专门的 fetch
路径提供,也就是对应前面的 fetch_photos()
视图。
这已经比较接近前后端分离开发的思想了。
后端这样就搞定了!
真正的难点在前端代码,让我们继续。
前端部分
开胃菜
无限滚动核心的需求是代码要监听页面的滚动行为,一但到达底部就触发获取新数据的事件。
具备此类功能的插件很多,笔者找了一个在 Github 上小巧的插件 Bounds.js 。
进入插件的 Github 仓库,直接将 bounds.js
这个文件复制或者下载,放到相册项目的 /static/
路径中,取名叫 bounds.js
。
即路径为
/static/bounds.js
。
注意:下载完毕后,必须将文件尾部的 export default bound
这行代码注释或者删除掉,否则会报错。
这行代码是插件从 NPM 安装时才需要的。
接下来的问题是:既然抛弃了 Django 的上下文,那获取的数据如何渲染到页面中?
jQuery 能够充当这个角色,但用起来更方便的是当下几个流行的“胖前端”框架,比如 Vue 。很多老铁总认为 Vue 这种框架是和前后端分离绑定在一起的,实则不然,称作“关系密切”会更贴切。你可以把 Vue 当成大号的 jQuery 使用,或者和模板混用(就像本文这样),一点问题都没有。
另外,由于前端要自行向后端索取数据,因此还得有发送请求的插件,比如 axios.js
。
有了以上认识后,最后让我们把提到的这三个小玩意儿引用到 base.html
中:
<!-- /templates/base.html --> ... <script src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.js"></script> <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script> <!-- 只有 bounds.js 是本地引用的 --> <script src="{% static 'bounds.js' %}"></script> ...
主菜
接下来完成图片列表部分。它有点复杂是因为在脚本(Javascript)中独立承担了监听事件、获取数据、渲染数据、状态管理等逻辑。如果你看着非常头疼,那可能需要先浏览下 Vue 的入门文档了。
新建 /templates/photo/endless_list.html
文件。这里先把所有代码全贴出来:
<!-- /templates/photo/endless_list.html --> {% extends "base.html" %} {% block title %}首页{% endblock title %} {% block content %} <div class="container py-2" id="app"> <div class="row" id="cards"> <div v-for="photo in photos" class="col-6 py-2 grid-item"> <div class="card hvr-float-shadow"> <a href="#" data-bs-toggle="modal" :data-bs-target="'#photo-' + photo.id" > <img v-if="photo !== 0" :src="'/media/' + photo.image" alt="" class="card-img" > </a> </div> </div> </div> <!-- Modal --> <div v-for="photo in photos" class="modal fade" :id="'photo-' + photo.id"> <div class="modal-dialog modal-dialog-centered modal-lg"> <div class="modal-content"> <div class="modal-body"> <img :src="'/media/' + photo.image" alt="" class="card-img" > </div> </div> </div> </div> </div> <div class="box"></div> {% endblock content %} {% block scripts %} <script type='text/javascript'> // 休眠函数用于测试 let sleep = (time) => { return new Promise((resolve) => setTimeout(resolve, time)); } // 监听滚动到底部的事件 let setBounds = () => { const box = document.querySelector('.box'); // 进入底部后将 Vue 的页码状态 +1 const onEnter = () => { app._instance.data.pageNum += 1; console.log('onEnter'); }; // 离开底部事件 const onLeave = () => { console.log('onLeave'); }; const boundary = bound({ margins: {bottom: 10} }) boundary.watch(box, onEnter, onLeave); } // Vue 实例 const app = Vue.createApp({ el: '#app', // 替换Vue的模板标签 // 防止与Django冲突 delimiters: ['[[', ']]'], data() { return { // 图片列表数据 photos: [], // 当前页码 pageNum: 1, } }, // Vue实例创建完毕后,立即获取第一页的数据 created() { axios.get('/photo/fetch', { params: { page: this.pageNum } }) .then((response) => { this.photos = response.data.photos; }) }, watch: { // 监听页码变化的事件 // 请求下一页的数据 pageNum(newValue, oldValue) { if (newValue > 1) { axios .get('/photo/fetch', { params: { page: this.pageNum } }) .then((response) => { sleep(500).then(() => { if (Object.keys(response.data).length !== 0) { this.photos = [...this.photos, ...response.data.photos]; } }) }) } } }, }); // 挂载 Vue 实例 app.mount('#app'); // 页面初始化完毕后,开始监听滚动事件 $(window).on('load', function() { setBounds(); }) </script> {% endblock scripts %}
篇幅很长,让我们逐个探讨。
代码拆解
从 html 部分开始拆解。
你可以将它与普通的 Django 模板逐行对比,研究其区别。
<div class="container py-2" id="app"> <div class="row" id="cards"> <div v-for="photo in photos" class="col-6 py-2 grid-item"> <div class="card hvr-float-shadow"> <a href="#" data-bs-toggle="modal" :data-bs-target="'#photo-' + photo.id" > <img v-if="photo !== 0" :src="'/media/' + photo.image" alt="" class="card-img" > </a> </div> </div> </div> <!-- Modal --> <div v-for="photo in photos" class="modal fade" :id="'photo-' + photo.id"> <div class="modal-dialog modal-dialog-centered modal-lg"> <div class="modal-content"> <div class="modal-body"> <img :src="'/media/' + photo.image" alt="" class="card-img" > </div> </div> </div> </div> </div> <div class="box"></div>
抛弃了 Django 模板语法,我们又用上了 Vue 的模板语法,比如:
- 根元素要有
id="app"
以方便 Vue 的挂载。 v-for
遍历图片数据。:data-bs-target
和:src
分别绑定了不同的 Vue 单行语句,用于动态获取模态窗id
和图片的路径。
额外需要注意的是,代码中取消了 Bootstrap 的瀑布流样式,原因是它与 bounds.js
互相冲突。没办法只能忍痛割爱了。
没有了瀑布流就是排列平整的正常卡片结构了,但在实际开发中这不是什么大问题。因为既然都用 Vue 了,那么你可能都不会用 Bootstrap,而是用基于 Vue 的专门的 UI 组件。
此外,底部的 <div class="box"></div>
是一个标志物,bounds.js
根据它是否出现在浏览器视窗中,从而判断页面是否已经到了底部。
// 监听滚动到底部的事件 let setBounds = () => { const box = document.querySelector('.box'); // 进入底部后将 Vue 的页码状态 +1 const onEnter = () => { app._instance.data.pageNum += 1; console.log('onEnter'); }; // 离开底部事件 const onLeave = () => { console.log('onLeave'); }; const boundary = bound({ margins: {bottom: 10} }) boundary.watch(box, onEnter, onLeave); }
setBounds()
函数就是 bounds.js
插件文档提供的标准写法。它的原理是根据 class="box"
元素进入或离开视窗,从而调用 onEnter()
或 onLeave()
函数。到达底部时,onEnter()
函数将访问 Vue 实例,将页码的状态 +1 ,也就是“翻页”。
接下来看 Vue 实例内部。
data() { return { // 图片列表数据 photos: [], // 当前页码 pageNum: 1, } },
Vue 管理了两个状态:
photos
是当前所有的图片集的数据。pageNum
是当前的页码。
// Vue实例创建完毕后,立即获取第一页的数据 created() { axios.get('/photo/fetch', { params: { page: this.pageNum } }) .then((response) => { this.photos = response.data.photos; this.pageNum = 1; }) },
生命周期钩子 created()
在 Vue 实例初始化完成后立即调用,获取第一页的数据。
watch: { // 监听页码变化的事件 // 请求下一页的数据 pageNum(newValue, oldValue) { if (newValue > 1) { axios.get('/photo/fetch', { params: { page: this.pageNum } }) .then((response) => { sleep(500).then(() => { if (Object.keys(response.data).length !== 0) { this.photos = [...this.photos, ...response.data.photos]; } }) }) } } },
- 当页面到达底部时,
onEnter()
事件将更新 Vue 实例的pageNum
的值。 - 而
pageNum
被 Vue 监听,一但更新就会触发请求下一页数据的代码。 sleep(500)
是为了方便开发时观察,线上时可去掉或改得非常小。- 取得数据后,将其解包拼接到
photos
列表中。
最后还有些零零碎碎的内容了,比如要记得 app.mount('#app');
挂载 Vue 实例,还要 $(window).on('load', ...)
开启滚动事件。
欧了,接下来试试效果。
测试效果
启动开发服务器,浏览器进入 http://127.0.0.1:8000/photo/endless-home/
路径。
效果如下:
首页数据显示正常。
把页面滚动到底部:
第二页的数据自动追加到页面底部。效果还是不错的。
总结
前后端分离的开发模式在当下非常流行。它的好处就是把前后端的工作彻底分割开了,后端只负责提供数据,前端负责数据的渲染。因此相比传统的 Django MTV 模式而言,前端逻辑的复杂度增加了,相信你也感受到了。
虽然本章的内容虽然并不是纯正的前后端分离,但是也非常接近了。如果对这方面感兴趣的读者,建议先阅读 Vue文档,再根据情况阅读我的Django-Vue搭建博客教程。
另外,能够实现无限滚动的方式、插件、框架很多,本文仅提供了其中一种思路。在实际开发中,最好根据项目情况选择合适的方法、插件和框架,不要拘泥于形式。
本章代码是在本地开发测试的。与前面章节一样,部署到线上也要对图片加载延迟进行对应的处理。
点赞 or 吐槽?评论区见!