实现伪瀑布流和滚动加载
前言
这是我在我的 个人站点 部署时遇到的一个业务需求,最后的实现方式和目前很多博主都不太一样,分享一下。
伪瀑布流,滚动加载以及背后的故事
先说瀑布流吧。
瀑布流已经出现了很多年了,我第一次知道还是初中的时候看极客之家推送的一篇微博,里面举例了一个 成人网站 使用瀑布流部署 成人内容 ,需要承认的一点是,效果 十分惊艳。
我当时没有对瀑布流的实现感什么兴趣,现在看一看,瀑布流的实现还是比较麻烦的。
传统的瀑布流一般适应两种情况:
- 子元素高度和宽度都恒定,类似栅格布局
- 子元素高度不固定,但是内容可控,如图库,图片+文字
这两者实现的方式基本统一。首先需要对子元素的高度进行计算和排序,例如我需要让1x和2x尽可能在同一行,并且还要让一个最接近1x的元素与1x在同一列,实际上还是让其尽可能符合栅格布局,否则最后的效果会变得参差不齐,难以阅读。
但是,这种算法要求你必须对子元素内容完全可控,例如你使用的图片比例和高度,文字长度,行间距以及最后的卡片/子元素高度。或者,你也需要进行先行排序。
目前已经有很多的插件可以实现这个功能了,但是都和我的业务逻辑不符,我需要一个完全可以渲染不定内容的解决方案,也就是不对数据做任何排序和筛选,直接渲染。
既然如此,我就必须要舍弃列宽或者列高。 在这里,我舍弃了列宽,使列宽固定,从而实现伪瀑布流布局。
而对于滚动加载,常见的解决方案有通过listener监听scroll操作,或者监听touchmove操作,计算是否已经滚动到底。这个思路是可以的,但是实在太远古了,而且在Android端兼容性很差。 我选择了Intersection Observer实现这个功能。
实现
这里的实现基于Vue 2 和Vuetify。
首先,放弃了列宽,就意味着我需要把列宽设定为固定宽度,在这里我选择了栅格布局+断点自适应。
如果你没有使用Vuetify,也可以选择栅格布局,手写就可以了。
在这里,我把整体页面分为75vw的卡片居中,卡片内渲染一行两列,这个时候宽度是比较合适的。 对于移动端,则是90vw渲染一列,也就是放弃了瀑布流布局,便于用户操作。如果你觉得过宽,可以渲染三列,把下面的数据对应修改就可以了。
<v-card :max-width="$vuetify.breakpoint.mdAndUp ? '75vw' : '90vw'">
<v-row>
<!-- 第一列 -->
<v-col cols="6"></v-col>
<!-- 第一列 -->
<v-col cols="6"></v-col>
</v-row>
</v-card>
子元素内容,我选择了卡片来实现,而这里的渲染使用v-for来实现。
<v-card
v-for="item in items1C"
:key="item.headline"
elevation="12"
class="mb-5"
<v-img v-if="item.img !== null" :src="item.img"></v-img>
<v-card-title>
{{ item.name }}
</v-card-title>
<v-card-subtitle>
{{ getDate(item.dateC) }}
</v-card-subtitle>
<v-card-text>
{{ item.text }}
</v-card-text>
<v-card-actions>
<v-chip class="ma-2" color="primary" pill>
<v-avatar>
<v-icon left>mdi-star</v-icon>
</v-avatar>
{{ item.upvoteCount }} 赞同
</v-chip>
<v-chip class="ma-2" color="info" pill>
<v-avatar>
<v-icon left>mdi-menu</v-icon>
</v-avatar>
{{ item.commentCount }} 评论
</v-chip>
<v-spacer></v-spacer>
<v-btn
color="primary"
style="font-size:1rem"
class="d-flex justify-center align-center"
<a target="_blank" :href="item.url" class="text-decoration-none"
>阅读更多 〉</a
</v-btn>
</v-card-actions>
</v-card>
可能你已经注意到了,因为我分为两列渲染,我必须要对数据进行处理:
- 在v-for渲染时对数组进行限制,例如通过一个方法返回一个 1/2数组
- 在获取数据时提前对数组进行拆分
我们来获取数据,并且对数据进行初始化和滚动加载:
export default {
data: () => ({
// IOS
ioStatus1: false,
ioStatus2: false,
ioStatusI: false,
// 主索引,用于记录用户已经浏览到的索引位置
mainIndex: null,
// PLS
plStatus: true,
// axios数据获取状态
status: false,
// 单列时的数组
items: [],
// 第一列的数组
items1C: [],
// 第二列的数组
items2C: [],
// 主数组,用于存储axios获取到的数据
mainArray: []
mounted() {
axios
.get(
.then(response => {
// 默认不对mainArray进行修改,所以整个生命周期不涉及到深拷贝问题
this.mainArray = response.data;
// 初始化数组数据
// 判断mainArray的长度
if (this.mainArray.length >= 20) {
// 使用Vuetify的断点检测用户状态
// md以上
// 当mainArray的长度大于20时,实现动态渲染
if (this.$vuetify.breakpoint.mdAndUp) {
for (let index = 0; index < 20; index += 2) {
this.items1C.push(this.mainArray[index]);
// 防止填入空数据
if (this.mainArray[index + 1]) {
this.items2C.push(this.mainArray[index + 1]);
this.mainIndex = 20;
} else {
this.items.push(...this.mainArray.slice(0, 10));
this.mainIndex = 20;
} else {
// 使用Vuetify的断点检测用户状态
// md以上
// 小于20时,此时不再动态渲染,初始化时即填充所有数据并渲染
// 将PLS设置为false
this.plStatus = false;
if (this.$vuetify.breakpoint.mdAndUp) {
for (let index = 0; index < this.mainArray.length; index += 2) {
this.items1C.push(this.mainArray[index]);
// 防止填入空数据
if (this.mainArray[index + 1]) {
this.items2C.push(this.mainArray[index + 1]);
} else {
this.items.push(...this.mainArray);
// 标识状态,确定数据已经加载完毕
this.status = true;
这里的思路主要是先对数组进行拆分,分别填入两列实现数据初始化,依旧是用户在第一次加载完页面后渲染的内容。 如果数据过少,则会自动渲染所有内容,不再动态加载。
需要解释一下,我的API是我自己实现的,默认会加载所有的数据,所以如果你想实现动态获取内容,则不再是从mainArray获取内容,而是通过axios。
至于两列数组初始化,因为我的资源有序,所以我选择了奇偶方式填充,如果你的数据是完全无序的或者是渲染时排序,可以选择直接将1/2length的数据存储到列数组中。
接下来是滚动加载。
关于Intersection Observer,我不想再写了,可以参考MDN或者下面这篇文章:
// eslint-disable-next-line no-unused-vars
onIntersect1(entries, observer) {
// 当IOS为假时执行回调函数
if (!this.ioStatus1) {
// 立刻使IOS为真,防止target移出时调用
this.ioStatus1 = true;
// 将当前已经填入的mainArray数组长度存入mainIndex,防止C1,C2渲染相同的数据
// 判断MI是否已经为MA尾部
if (this.mainIndex < this.mainArray.length) {
// 节流,同时将push操作放入异步块中
setTimeout(() => {
// 当C1见底,则像C1填充5个数据
// 如果再次见底,则继续填充数据,直至C2见底
// C2与此相同
this.items1C.push(
...this.mainArray.slice(this.mainIndex, this.mainIndex + 5)
this.mainIndex += 5;
}, 450);
// 将IOS的恢复放在异步块中并且延时,防止在target移出之前恢复为false
setTimeout(() => {
this.ioStatus1 = false;
}, 500);
} else {