相关文章推荐
气宇轩昂的遥控器  ·  Effectiveness of an ...·  1 年前    · 
实现伪瀑布流和滚动加载

实现伪瀑布流和滚动加载

前言

这是我在我的 个人站点 部署时遇到的一个业务需求,最后的实现方式和目前很多博主都不太一样,分享一下。

伪瀑布流,滚动加载以及背后的故事

先说瀑布流吧。

瀑布流已经出现了很多年了,我第一次知道还是初中的时候看极客之家推送的一篇微博,里面举例了一个 成人网站 使用瀑布流部署 成人内容 ,需要承认的一点是,效果 十分惊艳。

我当时没有对瀑布流的实现感什么兴趣,现在看一看,瀑布流的实现还是比较麻烦的。

传统的瀑布流一般适应两种情况:

  • 子元素高度和宽度都恒定,类似栅格布局
  • 子元素高度不固定,但是内容可控,如图库,图片+文字

这两者实现的方式基本统一。首先需要对子元素的高度进行计算和排序,例如我需要让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 {