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 吐槽?评论区见!




本文作者: 杜赛
发布时间: 2021年08月17日 - 11:51
最后更新: 2023年10月17日 - 11:51
转载请保留原文链接及作者