美文网首页前端实践
一步步创建一个Vue超长列表组件

一步步创建一个Vue超长列表组件

作者: forzoom | 来源:发表于2019-12-05 10:50 被阅读0次

文章以及代码存放于Github

今天所实现的组件我称为“超长列表”,列表是当前互联网产品中常见的组织/展现数据的一种形式,随着数据量不断变得庞大,我们会对数据进行分页,但是目前庞大的数据以及愈加丰富的内容,让我们的设备在维持大量数据时,性能上的瓶颈渐渐显示出来,我们的网页在滑动时可能会出现卡顿,这是这个组件所需要处理的问题。

在iOS开发中有名为UITableView的组件,在android开发中有被称为ListView的组件,它们通过销毁不可见区域的元素,来达到性能优化的目的,我们在组件当中也采用这样的逻辑,即便列表中有10000个元素,当屏幕可视区域中可能只有5个元素,那么我们只显示5个元素,这将大大减少我们的网页对于硬件资源的消耗。

下面是常见的列表渲染形式,<PostCard>就是我们所需要的展现的列表元素。

<div class="large-list">
  <PostCard v-for="(post, index) in list" :key="post.id" :post="post"></PostCard>
</div>
export default {
  name: 'LargeList',
  props: {
    list: {
      type: Array,
      default() {
        return [];
      },
    },
  },
};

先来考虑最简单的情况,假设所有的<PostCard>的高度都是100,来实现上面所说的效果。list数组依然包含所有的数据,但我们需要另外一个数组,决定需要展示哪些数据。

{
  // ...
  data() {
    return {
      startIndex: 0,
      endIndex: 0,
      // 容器高度信息
      containerHeight: 0,
    };
  },
  computed: {
    /**
     * 展示列表
     */
    displayList() {
      return this.list.slice(this.startIndex, this.endIndex);
    },
  }
  // ...
}
<PostCard v-for="(post, index) in displayList" :key="post.id" :post="post"></PostCard>

现在我们要想办法确定startIndexendIndexstartIndex是可视列表(displayList)中的第一个元素的下标,endIndex是最后一个元素的下标+1,在固定高度的情况下,startIndexendIndex的计算十分简单

// 首先我们为LargeList添加scroll事件的监听
{
  // ...
  created() {
    window.addEventListener('scroll', this.scrollCallback);
  },
  beforeDestroy() {
    window.removeEventListener('scroll', this.scrollCallback);
  },
  // ...
}
// 添加scroll处理函数 scrollCallback
{
  // ...
  methods: {
    scrollCallback() {
      this.startIndex = Math.floor(window.scrollY / 100);
      this.endIndex = Math.floor((window.scrollY + window.innerHeight) / 100) + 1;
    },
  },
  // ...
}

到此为止,已经完成了一个最简单的逻辑,但是还没有完,还需要对元素的样式进行一些适当的补充

<div class="large-list" :style="{height: containerHeight + 'px'}">
  <PostCard v-for="(post, index) in displayList" :key="post.id" :style="{top: metaMap[post.id].top + 'px'}"></PostCard>
</div>
// 存储每个PostCard的一些样式数据
{
  // ...
  data() {
    return {
      metaMap: {},
    };
  },
  // ...
  crerated() {
    for (let i = 0, len = this.list.length; i < len; i++) {
      Vue.set(this.metaMap, post.id, {
        top: i * 100,
        height: 100,
      });
    }
  },
}

通用化:允许子元素高度变化

<PostCard>的高度可能在不同情况下显示高度不同,甚至在浏览过程中,可能实时地发生变化,组件应该做好子元素的高度会发生变化的准备。

当子元素的高度发生变化时,应该做什么?元素高度发生变化,其他元素的位置可能需要发生相应的修改,但是只需要更新其他可视元素的数据即可。

{
  // ...
  methods: {
    /**
     * 子元素高度发生变化时的处理函数
     */
    onHeightChange(height, id) {
      // 更新容器的高度数据
      this.containerHeight += height - this.metaMap[id].height;
      // 更新元素的高度数据
      this.metaMap[id].height = height;
      // 更新 __高度发生变化的元素__ 之后的 __其他可视元素__ 的top数据
      const pos = this.displayList.map(post => post.id).indexOf(id) + 1;
      this.displayList.slice(pos).forEach((post, index) => {
        const prevPost = this.displayList[index - 1];
        this.metaMap[id].top = this.metaMap[prevPost.id].top + height;
      })
    },
  },
  // ...
}

当元素的高度不再固定时,startIndexendIndex就不能那么轻松地计算出来,需要在整个list中寻找需要显示的列表内容,二分查找是个不错的选择。(二分查找并非本文重点,这里不再列出)

{
  // ...
  methods: {
    scrollCallback() {
      this.startIndex = this.binarySearch(window.scrollY);
      this.endIndex = this.binarySearch(window.scrollY + window.innerHeight) + 1;
    },
  },
  // ...
}

通用化:允许子元素是任意组件

之前将<PostCard>组件直接在<LargeList>组件中注册,为了让<LargeList>适用于各种场景,子元素是什么样的,应该有<LargeList>的父元素决定

<LargeList :list="list" @display-change="onDisplayChange">
  <PostCard v-for="(post, index) in displayList" :key="post.id" :post="post"></PostCard>
</LargeList>
export default {
  data() {
    return {
      startIndex: 0,
      endIndex: 0,
    };
  },
  computed: {
    displayList() {
      return this.list.slice(this.startIndex, this.endIndex);
    },
  },
  methods: {
    onDisplayChange(startIndex, endIndex) {
      this.startIndex = startIndex;
      this.endIndex = endIndex;
    },
  },
}

自然我们更新LargeList中关于scroll的处理

{
  methods: {
    scrollCallback() {
      startIndex = this.binarySearch(window.scrollY);
      endIndex = this.binarySearch(window.scrollY + window.innerHeight) + 1;
      // 使用display-change的形式来通知外部组件更新数据
      this.$emit('display-change', startIndex, endIndex);
    },
  },
}

使用slot的方式,需要解决一些问题:

  1. 子元素样式改变,例如top位置的改变
  2. 子元素的height-change事件监听

这是使用模板(template字段)所无法做到的事情,需要使用更加灵活的render函数来实现。

ps: 这里实现的render函数使用了官方文档中没有的内容,仅供参考。

{
  // ...
  render(h) {
    const displayList = this.$slots.default || [];
    displayList.forEach((vnode) => {
      /** 组件实例 */
      const instance = vnode.componentInstance;
      /** 组件配置 */
      const options = vnode.componentOptions;
      // tip: 依赖于未公开的instance._events,并不是一件好事
      // 如果组件已经实例化,并且没有监听heightChange事件
      if (instance && !instance._events.heightChange) {
        instance.$on('heightChange', this.onHeightChange);
      } else if (options) {
        // 
        if (options.listeners) {
          options.listeners.heightChange = this.onHeightChange;
        } else {
          options.listeners = {
            heightChange: this.onHeightChange,
          };
        }
      } else if (!instance) {
        // 组件尚未实例化,还可以通过修改虚拟节点的数据的形式,来实施监听
        if (vnode.data) {
          if (vnode.data.on) {
            vnode.data.on.heightChange = this.onHeightChange;
          } else {
            vnode.data.on = {
              heightChange: this.onHeightChange,
            };
          }
        }
      }
      // 没有data的话,可能哪里存在问题
      if (vnode.data) {
        const style = vnode.data.style;
        // @ts-ignore
        const id = vnode.componentOptions!.propsData!.id;
        const top = this.topMap[id] + 'px';
        if (!style) {
          vnode.data.style = {
            top,
          };
        } else if (typeof style === 'string') {
          vnode.data.style = style + `; top: ${top}`;
        } else if (isPlainObject(style)) {
          // @ts-ignore
          vnode.data.style.top = top;
        }
      }
    });

    return h('div', {
      class: 'large-list',
      style: {
          height: this.containerHeight + 'px',
      },
    }, displayList);
  },
  // ...
}

优化: 完善细节表现

预先加载部分子元素

目前的逻辑是:当子元素进入可视区域内,再开始渲染元素。这种逻辑下,假如设备性能不佳,用户可能会有子元素“突然出现”的感觉。为了减轻这个问题的影响,在滑动过程中,不论向上还是向下滑动,都需要多渲染几个元素,通过修改scrollCallback函数的逻辑能够很方便地实现这个功能。

{
  // ...
  props: {
    // 需要预先加载的高度
    preloadHeight: {
      type: Number,
      default: 100,
    },
  },
  methods: {
    scrollCallback() {
      const top = window.scrollY - this.preloadHeight;
      const bottom = window.scrollY + window.innerHeight + this.preloadHeight;
      this.startIndex = top < 0 ? 0 : this.binarySearch(top);
      this.endIndex = bottom < 0 ? 0 : this.binarySearch(bottom) + 1;
    },
  },
  // ...
}

解决metaMap中数据丢失的问题

组件中的子元素显示位置全依赖于metaMap中的数据,当用户离开有<LargeList>的页面,<LargeList>被销毁时metaMap中也就丢失了,当用户再次回来时,<LargeList>遇到的第一个问题:需要额外消耗性能来重新处理子元素的高度变化。更严重的问题是:一般返回上一页时,会将页面滚动区域固定在离开时的位置,此时因为没有原本的metaMap数据,所以渲染的结果与用户离开时所看到的内容可能不符。所能想到解决问题最简单的做法就是:当用户离开页面将metaMap保存下来。

<LargeList>对外提供两个prop: persistenceload,分别接收一个函数,用于存储数据和加载数据,具体数据存储和加载的方式,将由外层组件决定,这提供更好的灵活性。

{
  // ...
  props: {
    /** 持久化 */
    persistence: {},
    /** 加载数据 */
    load: {},
  },
  created() {
    let data = null;
    if (this.load && (data = this.load())) {
      // 如果存在持久化数据情况下
      this.metaMap = data.metaMap;
      this.containerHeight = data.containerHeight;
      this.startIndex = data.startIndex;
      this.endIndex = data.endIndex;
    } else {
      // 如果不存在持久化数据
      // 向metaMap中加入数据
      let containerHeight = 0;
      for (let i = 0, len = this.idList.length; i < len; i++) {
        const id = '' + this.idList[i];
        const height = this.defaultItemHeight + this.defaultItemGap;
        Vue.set(this.metaMap, id, {
          top: i * height,
          height,
        });
        containerHeight += height;
      }
      this.containerHeight = containerHeight;
    }
  },
  beforeDestroy() {
    // 完成持久化过程
    if (this.persistence) {
      this.persistence({
        metaMap: this.metaMap,
        startIndex: this.startIndex,
        endIndex: this.endIndex,
        containerHeight: this.containerHeight,
      });
    }
  },
  // ...
}

相关文章

  • 一步步创建一个Vue超长列表组件

    文章以及代码存放于Github。 今天所实现的组件我称为“超长列表”,列表是当前互联网产品中常见的组织/展现数据的...

  • vue 组件和路由

    === Vue组件Vue组件的创建vue.extend结合vue.component创建vue.component...

  • Vue组件和父子组件

    【一】Vue组件化 创建组件构造器对象Vue.extend() 创建组件构造器const cpnc = Vue.e...

  • vue-cli兄弟组件传值文章

    1.创建两个兄弟组件分别是header.vue组件和middle.vue组件 1.创建一个父组件chuanzhi....

  • Vue 2.0 制作列表组件,实现分页、搜索、批量操作等

    本文仅讲解如何使用 Vue 创建一个实现分页、搜索、批量操作的列表组件,所以只提供此小组件的代码及说明,不提供其之...

  • Vue组件创建和传值

    Vue创建组件的方式 使用Vue.Extend()和Vue.component全局注册组件 首先我们定义一个组件并...

  • Vue组件化开发

    一.如何创建Vue全局组件 特点:在任何一个Vue控制的区域都能使用1.创建组件构造器注意点:创建组件模板的时候只...

  • Vue磨刀嚯嚯

    Vue开发风格——传统方法应用vue.js Vue开发风格——单个组件式 组件 基本操作 创建一个组件构造器 注册...

  • vue.component、vue.extend、vue

    vue.component 注册全局组件 vue.extend 创建组件构造器 vue.component(组件名...

  • Vue学习笔记2

    组件化应用构建 Vue实例 创建一个Vue实例 每个 Vue 应用都是通过用 Vue 函数创建一个新的 Vue 实...

网友评论

    本文标题:一步步创建一个Vue超长列表组件

    本文链接:https://www.haomeiwen.com/subject/ishdgctx.html