设计并实现一个 Vue 组件,用于处理复杂的虚拟滚动(Virtual Scrolling),支持动态高度、可变列和无限加载。

各位靓仔靓女,早上好!我是你们的老朋友,今天咱们来聊点硬核的——Vue 虚拟滚动,而且是Plus版,带动态高度、可变列和无限加载的那种。准备好了吗?系好安全带,发车咯!

一、 虚拟滚动:解决大数据量渲染难题的瑞士军刀

首先,我们要搞清楚,为啥要用虚拟滚动?想象一下,你要展示10万条数据,直接一股脑丢给浏览器,那画面太美我不敢看。浏览器直接卡成PPT,用户体验瞬间跌入谷底。

虚拟滚动的核心思想是:只渲染可见区域的内容。就好比你逛书店,你只会看到书架上你视线范围内的书,而不是把整个图书馆的书都搬出来。

简单来说,就是根据滚动条的位置,计算出当前应该显示哪些数据,然后动态更新DOM。这样,无论数据量有多大,页面上始终只渲染有限的几个元素,性能自然就杠杠的。

二、 动态高度:让每一行都拥有独特的灵魂

传统的虚拟滚动,通常假设每一行的高度都是固定的。但现实总是残酷的,总有一些奇葩数据,比如超长的文本、复杂的图片等等,导致每一行的高度都不一样。

怎么办?动态高度就派上用场了。我们需要记录每一行的高度,然后根据这些高度来计算滚动条位置和可见区域。

1. 记录行高:埋下成功的种子

首先,我们需要一个地方来存放每一行的高度。我们可以用一个数组来存储,数组的索引对应数据的索引。

data() {
  return {
    rowHeights: [], // 存储每一行的高度
    // ...其他数据
  }
},

2. 获取行高:抓住问题的关键

我们需要在每一行渲染完成后,获取它的实际高度,并更新rowHeights数组。这里我们可以使用$nextTick来确保DOM已经渲染完成。

<template>
  <div class="virtual-list" ref="virtualList">
    <div
      v-for="(item, index) in visibleData"
      :key="item.id"
      :style="{ height: rowHeights[index] + 'px' }"
      ref="rowElements"
    >
      {{ item.content }}
    </div>
  </div>
</template>

<script>
export default {
  mounted() {
    this.observeRows(); // 监听行高变化
  },
  methods: {
    observeRows() {
      const observer = new ResizeObserver(entries => {
        entries.forEach(entry => {
          const index = Array.from(this.$refs.rowElements).indexOf(entry.target);
          if (index !== -1) {
            this.rowHeights[index] = entry.contentRect.height;
            this.$forceUpdate();
          }
        });
      });
      this.$nextTick(() => {
        Array.from(this.$refs.rowElements).forEach(el => {
          observer.observe(el);
        });
      });
    }
  }
}
</script>

这里使用了 ResizeObserver 来监听每一个 rowElements 的大小变化,可以更准确地获取高度,避免在复杂情况下获取高度不准确的问题。

3. 计算滚动条高度:掌握全局

滚动条的总高度,需要根据所有行的高度之和来计算。

computed: {
  scrollHeight() {
    return this.rowHeights.reduce((acc, cur) => acc + cur, 0);
  },
  // ...其他计算属性
},

4. 计算可见区域:有的放矢

根据滚动条的位置,计算出可见区域的起始索引和结束索引。

computed: {
  visibleData() {
    const startIndex = this.getStartIndex();
    const endIndex = this.getEndIndex(startIndex);
    return this.data.slice(startIndex, endIndex + 1);
  },
  // ...其他计算属性
},
methods: {
  getStartIndex() {
    let scrollTop = this.$refs.virtualList.scrollTop;
    let sumHeight = 0;
    for (let i = 0; i < this.rowHeights.length; i++) {
      sumHeight += this.rowHeights[i];
      if (sumHeight >= scrollTop) {
        return i;
      }
    }
    return 0;
  },
  getEndIndex(startIndex) {
    let sumHeight = 0;
    for (let i = startIndex; i < this.rowHeights.length; i++) {
      sumHeight += this.rowHeights[i];
      if (sumHeight >= this.$refs.virtualList.clientHeight) {
        return i;
      }
    }
    return this.data.length - 1;
  }
}

三、 可变列:让表格不再单调

有时候,我们需要展示表格数据,而且每一列的宽度还不一样。这就需要我们动态计算每一列的宽度,并应用到对应的单元格上。

1. 定义列配置:心中有数

首先,我们需要定义一个列配置对象,包含每一列的宽度、标题等信息。

data() {
  return {
    columns: [
      { key: 'name', title: '姓名', width: 100 },
      { key: 'age', title: '年龄', width: 80 },
      { key: 'address', title: '地址', width: 200 },
      // ...更多列
    ],
    // ...其他数据
  }
},

2. 动态设置列宽:灵活应变

在渲染单元格时,根据列配置对象,动态设置每一列的宽度。

<template>
  <div class="virtual-table">
    <div class="table-header">
      <div
        v-for="column in columns"
        :key="column.key"
        :style="{ width: column.width + 'px' }"
      >
        {{ column.title }}
      </div>
    </div>
    <div class="table-body" ref="virtualList">
      <div
        v-for="(item, index) in visibleData"
        :key="item.id"
        class="table-row"
        :style="{ height: rowHeights[index] + 'px' }"
      >
        <div
          v-for="column in columns"
          :key="column.key"
          :style="{ width: column.width + 'px' }"
        >
          {{ item[column.key] }}
        </div>
      </div>
    </div>
  </div>
</template>

3. 自适应列宽:锦上添花

如果希望列宽能够自适应内容,可以使用CSS的table-layout: auto属性。但是,这种方式可能会导致性能问题,特别是当表格内容非常复杂时。

另一种方式是,在渲染完成后,动态计算每一列的最大宽度,然后应用到对应的单元格上。

mounted() {
  this.$nextTick(() => {
    this.calculateColumnWidths();
  });
},
methods: {
  calculateColumnWidths() {
    const headerCells = document.querySelectorAll('.table-header > div');
    const bodyCells = document.querySelectorAll('.table-body .table-row > div');
    const columnWidths = this.columns.map(() => 0);

    headerCells.forEach((cell, index) => {
      columnWidths[index] = Math.max(columnWidths[index], cell.offsetWidth);
    });

    bodyCells.forEach((cell, index) => {
      const columnIndex = index % this.columns.length;
      columnWidths[columnIndex] = Math.max(columnWidths[columnIndex], cell.offsetWidth);
    });

    this.columns.forEach((column, index) => {
      column.width = columnWidths[index];
    });

    this.$forceUpdate();
  },
}

四、 无限加载:永无止境的数据流

当数据量非常庞大时,一次性加载所有数据显然是不现实的。我们需要实现无限加载,即当滚动条滚动到底部时,自动加载更多数据。

1. 监听滚动事件:时刻准备着

我们需要监听滚动事件,判断滚动条是否滚动到底部。

mounted() {
  this.$refs.virtualList.addEventListener('scroll', this.handleScroll);
},
beforeDestroy() {
  this.$refs.virtualList.removeEventListener('scroll', this.handleScroll);
},
methods: {
  handleScroll() {
    const scrollTop = this.$refs.virtualList.scrollTop;
    const clientHeight = this.$refs.virtualList.clientHeight;
    const scrollHeight = this.scrollHeight;

    if (scrollTop + clientHeight >= scrollHeight - 20) { // 提前20像素加载
      this.loadMoreData();
    }
  },
  // ...其他方法
}

2. 加载更多数据:添砖加瓦

当滚动条滚动到底部时,调用loadMoreData方法,加载更多数据。

methods: {
  loadMoreData() {
    if (this.isLoading || this.isAllLoaded) {
      return;
    }

    this.isLoading = true;
    setTimeout(() => { // 模拟异步加载
      const newData = this.generateData(this.data.length, 20);
      this.data = this.data.concat(newData);
      this.isLoading = false;
      if (this.data.length >= 1000) {
        this.isAllLoaded = true;
      }
    }, 500);
  },
  // ...其他方法
}

3. 加载状态:让用户知道发生了什么

在加载数据时,我们需要显示加载状态,让用户知道正在加载更多数据。

<template>
  <div class="virtual-list" ref="virtualList">
    <div
      v-for="(item, index) in visibleData"
      :key="item.id"
      :style="{ height: rowHeights[index] + 'px' }"
    >
      {{ item.content }}
    </div>
    <div class="loading-indicator" v-if="isLoading">
      Loading...
    </div>
    <div class="no-more-data" v-if="isAllLoaded">
      No more data
    </div>
  </div>
</template>

五、 性能优化:精益求精

虚拟滚动虽然已经很强大了,但我们还可以进一步优化性能。

1. 减少不必要的渲染:避免无效劳动

尽量避免不必要的渲染。比如,只有当可见区域发生变化时,才需要更新DOM。可以使用shouldComponentUpdatePureComponent来避免不必要的渲染。在Vue中可以使用computedgetter/setter结合watch来实现类似的功能。

2. 使用缓存:空间换时间

可以使用缓存来存储已经计算过的行高、滚动条位置等信息,避免重复计算。

3. 使用Web Workers:释放主线程

如果计算量非常大,可以使用Web Workers将计算任务放到后台线程中执行,避免阻塞主线程。

六、 代码示例:让理论落地

下面是一个完整的代码示例,包含了动态高度、可变列和无限加载功能。

<template>
  <div class="virtual-table" ref="virtualList">
    <div class="table-header">
      <div
        v-for="column in columns"
        :key="column.key"
        :style="{ width: column.width + 'px' }"
      >
        {{ column.title }}
      </div>
    </div>
    <div class="table-body" @scroll="handleScroll">
      <div
        v-for="(item, index) in visibleData"
        :key="item.id"
        class="table-row"
        :style="{ height: rowHeights[getVisibleIndex(index)] + 'px', top: getOffset(getVisibleIndex(index)) + 'px' }"
        ref="rowElements"
      >
        <div
          v-for="column in columns"
          :key="column.key"
          :style="{ width: column.width + 'px' }"
        >
          {{ item[column.key] }}
        </div>
      </div>
    </div>
    <div class="loading-indicator" v-if="isLoading">Loading...</div>
    <div class="no-more-data" v-if="isAllLoaded">No more data</div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      columns: [
        { key: 'id', title: 'ID', width: 50 },
        { key: 'name', title: '姓名', width: 100 },
        { key: 'age', title: '年龄', width: 80 },
        { key: 'address', title: '地址', width: 200 },
        { key: 'description', title: '描述', width: 300 },
      ],
      data: [],
      rowHeights: [],
      visibleDataCount: 20, // 可见区域的行数
      isLoading: false,
      isAllLoaded: false,
      startIndex: 0, //起始索引
    };
  },
  computed: {
    visibleData() {
      return this.data.slice(this.startIndex, this.startIndex + this.visibleDataCount);
    },
  },
  mounted() {
    this.loadInitialData();
  },
  methods: {
    loadInitialData() {
      this.isLoading = true;
      setTimeout(() => {
        this.data = this.generateData(0, 50);
        this.rowHeights = new Array(this.data.length).fill(50); // 初始高度
        this.$nextTick(() => {
          this.observeRows();
          this.isLoading = false;
        });
      }, 500);
    },
    observeRows() {
      const observer = new ResizeObserver(entries => {
        entries.forEach(entry => {
          const index = Array.from(this.$refs.rowElements).indexOf(entry.target);
          if (index !== -1) {
            this.rowHeights[this.startIndex + index] = entry.contentRect.height;
          }
        });
      });
      this.$nextTick(() => {
        Array.from(this.$refs.rowElements).forEach(el => {
          observer.observe(el);
        });
      });
    },
    handleScroll() {
      const scrollTop = this.$refs.virtualList.scrollTop;
      const clientHeight = this.$refs.virtualList.clientHeight;
      const scrollHeight = this.$refs.virtualList.scrollHeight;

      // 计算新的startIndex
      let newStartIndex = 0;
      let sumHeight = 0;
      for (let i = 0; i < this.data.length; i++) {
        sumHeight += this.rowHeights[i];
        if (sumHeight >= scrollTop) {
          newStartIndex = Math.max(0, i - 5); // 提前渲染5行
          break;
        }
      }

      if (newStartIndex !== this.startIndex) {
        this.startIndex = newStartIndex;
      }

      if (scrollTop + clientHeight >= scrollHeight - 20 && !this.isLoading && !this.isAllLoaded) {
        this.loadMoreData();
      }
    },
    loadMoreData() {
      if (this.isLoading || this.isAllLoaded) {
        return;
      }

      this.isLoading = true;
      setTimeout(() => {
        const newData = this.generateData(this.data.length, 50);
        this.data = this.data.concat(newData);
        this.rowHeights = this.rowHeights.concat(new Array(newData.length).fill(50)); // 初始高度
        this.$nextTick(() => {
          this.observeRows();
          this.isLoading = false;
          if (this.data.length >= 500) {
            this.isAllLoaded = true;
          }
        });
      }, 500);
    },
    generateData(start, count) {
      const data = [];
      for (let i = start; i < start + count; i++) {
        data.push({
          id: i,
          name: `姓名${i}`,
          age: Math.floor(Math.random() * 50) + 20,
          address: `地址${i}`,
          description: `这是一段很长的描述${i},用于测试动态高度。内容可能比较长,也可能比较短,总之就是为了测试高度不一致的情况。`,
        });
      }
      return data;
    },
    getVisibleIndex(index) {
      return index;
    },
    getOffset(index) {
      let offset = 0;
      for (let i = 0; i < this.startIndex + index; i++) {
        offset += this.rowHeights[i];
      }
      return offset;
    },
  },
};
</script>

<style scoped>
.virtual-table {
  width: 800px;
  height: 400px;
  border: 1px solid #ccc;
  overflow-y: auto;
  position: relative;
}

.table-header {
  display: flex;
  background-color: #f0f0f0;
  position: sticky;
  top: 0;
  z-index: 1;
}

.table-header > div {
  padding: 8px;
  border-right: 1px solid #ccc;
  box-sizing: border-box; /* 确保 padding 不会增加元素的总宽度 */
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.table-body {
  position: relative;
}

.table-row {
  display: flex;
  position: absolute;
  width: 100%;
  box-sizing: border-box;
}

.table-row > div {
  padding: 8px;
  border-right: 1px solid #ccc;
  border-bottom: 1px solid #ccc;
  box-sizing: border-box; /* 确保 padding 不会增加元素的总宽度 */
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.loading-indicator,
.no-more-data {
  text-align: center;
  padding: 10px;
}
</style>

七、 总结:掌握虚拟滚动的真谛

今天我们一起学习了Vue虚拟滚动的高级用法,包括动态高度、可变列和无限加载。希望通过今天的学习,大家能够掌握虚拟滚动的真谛,并在实际项目中灵活运用。

记住,虚拟滚动不是银弹,它也有自己的局限性。在选择使用虚拟滚动时,需要根据实际情况进行权衡。

好了,今天的讲座就到这里。感谢大家的聆听,下次再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注