Django搭建网络相册:无限滚动
4546 views, 2023/10/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 吐槽?评论区见!