虚拟列表

Posted by Shallow Dreameron July 24, 2024

为了确保滚动时元素正确显示并且性能良好,我们可以通过调整数据的管理方式和渲染逻辑,来实现虚拟滚动。这次,我们确保所有计算正确,并使用 flex 布局。以下是一个详细的实现方式,保证元素在滚动时正确显示。

更新后的代码实现

<template>
  <div class="container" ref="container" @scroll="onScroll">
    <div class="spacer" :style="{ height: spacerHeight + 'px' }"></div>
    <div class="items" :style="{ transform: `translateY(${marginTop}px)` }">
      <el-card
        v-for="(item, index) in visibleItems"
        :key="item.id"
        class="item"
      >
        <div class="card-content">
          <el-checkbox :label="item.id" v-model="selectedItems"></el-checkbox>
          <el-select
            v-model="item.selectedOption"
            filterable
            remote
            reserve-keyword
            :remote-method="remoteMethod"
            :loading="loading"
          >
            <el-option
              v-for="option in options"
              :key="option.value"
              :label="option.label"
              :value="option.value"
            ></el-option>
          </el-select>
        </div>
      </el-card>
    </div>
  </div>
</template>

<script>
import { debounce } from 'lodash';

export default {
  data() {
    return {
      listData: Array.from({ length: 1000 }, (_, i) => ({
        id: i,
        text: `Item ${i}`,
        selectedOption: null
      })),
      startIndex: 0,
      visibleCount: 10,
      buffer: 5, // Buffer items to prevent flickering
      selectedItems: [],
      options: [],
      loading: false,
      itemHeight: 150, // Ensure this matches the height of el-card
    };
  },
  computed: {
    visibleItems() {
      const start = Math.max(this.startIndex - this.buffer, 0);
      const end = Math.min(this.startIndex + this.visibleCount + this.buffer, this.listData.length);
      return this.listData.slice(start, end);
    },
    spacerHeight() {
      return this.listData.length * this.itemHeight;
    },
    marginTop() {
      return this.startIndex * this.itemHeight;
    }
  },
  methods: {
    onScroll: debounce(function () {
      const container = this.$refs.container;
      const scrollTop = container.scrollTop;
      this.startIndex = Math.floor(scrollTop / this.itemHeight);
    }, 50), // Adjust debounce time as needed
    deleteSelected() {
      this.listData = this.listData.filter(item => !this.selectedItems.includes(item.id));
      this.selectedItems = [];
      this.onScroll(); // Recalculate visible items
    },
    remoteMethod(query) {
      if (query !== '') {
        this.loading = true;
        setTimeout(() => {
          this.loading = false;
          this.options = this.listData.filter(item => {
            return item.text.toLowerCase().includes(query.toLowerCase());
          }).map(item => {
            return { value: item.id, label: item.text };
          });
        }, 200);
      } else {
        this.options = [];
      }
    }
  },
  mounted() {
    this.onScroll(); // Initialize visible items
  }
};
</script>

<style>
.container {
  height: 400px; /* Adjust this height as needed */
  overflow-y: auto;
  position: relative;
}
.spacer {
  width: 100%;
  background: transparent;
}
.items {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
}
.item {
  box-sizing: border-box;
  border-bottom: 1px solid #ddd;
  display: flex;
  align-items: center;
  padding: 10px;
  cursor: pointer;
  height: 150px; /* Ensure this matches itemHeight */
}
.card-content {
  display: flex;
  flex-direction: column;
  height: 100%;
}
</style>

关键点解释

  1. visibleItems 计算属性:
    • 根据 startIndexbuffer 计算出需要渲染的条目,确保在可视区域及其附近的条目都能正确渲染。
  2. spacerHeight 计算属性:
    • 用于设置 spacer 的高度,确保滚动条的高度正确。
  3. marginTop 计算属性:
    • 根据 startIndex 计算出 items 容器的 margin-top,确保条目在正确的位置显示。
  4. 滚动事件处理 (onScroll)
    • 使用防抖(debounce)优化滚动事件处理,减少计算频率,提高性能。

调试建议

  • 检查控制台:确保没有错误或警告。
  • 浏览器开发者工具:查看元素的实际渲染情况,检查 transform 和条目的渲染位置。
  • 调整 debounce 时间:根据实际性能调整防抖时间,以确保滚动平滑。

通过这些调整,你应该能确保条目在滚动时正确显示。如果这个实现仍然存在问题,请告诉我具体的表现或错误信息,我会根据情况进行进一步调整。


要实现自适应 itemHeight 的虚拟列表,需要在渲染完成后动态计算每个条目的高度,并根据这些高度来调整滚动和渲染逻辑。这种实现会稍微复杂一些,因为需要处理动态高度的情况。以下是一个解决方案:

更新后的虚拟列表组件实现

VirtualList.vue

<template>
  <div class="virtual-list-container" ref="container" @scroll="onScroll">
    <div class="spacer" :style="{ height: totalHeight + 'px' }"></div>
    <div class="items" :style="{ transform: `translateY(${offsetTop}px)` }" ref="items">
      <slot v-for="(item, index) in visibleItems" :item="item" :index="index" :key="item.id" />
    </div>
  </div>
</template>

<script>
import { debounce } from 'lodash';

export default {
  name: 'VirtualList',
  props: {
    items: {
      type: Array,
      required: true
    },
    visibleCount: {
      type: Number,
      default: 10
    },
    buffer: {
      type: Number,
      default: 5
    }
  },
  data() {
    return {
      startIndex: 0,
      heights: [],
      offsetTop: 0,
      totalHeight: 0,
    };
  },
  computed: {
    visibleItems() {
      const start = Math.max(this.startIndex - this.buffer, 0);
      const end = Math.min(this.startIndex + this.visibleCount + this.buffer, this.items.length);
      return this.items.slice(start, end);
    }
  },
  methods: {
    onScroll: debounce(function () {
      const container = this.$refs.container;
      const scrollTop = container.scrollTop;
      this.updateVisibleItems(scrollTop);
    }, 50),
    updateVisibleItems(scrollTop) {
      let accumulatedHeight = 0;
      let newStartIndex = 0;
      
      for (let i = 0; i < this.heights.length; i++) {
        if (accumulatedHeight + this.heights[i] > scrollTop) {
          newStartIndex = i;
          break;
        }
        accumulatedHeight += this.heights[i];
      }
      
      this.startIndex = newStartIndex;
      this.offsetTop = accumulatedHeight;
    },
    calculateHeights() {
      const items = this.$refs.items.children;
      this.heights = Array.from(items).map(item => item.getBoundingClientRect().height);
      this.totalHeight = this.heights.reduce((a, b) => a + b, 0);
    }
  },
  watch: {
    items: {
      immediate: true,
      handler() {
        this.$nextTick(() => {
          this.calculateHeights();
          this.updateVisibleItems(this.$refs.container.scrollTop);
        });
      }
    }
  },
  mounted() {
    this.$nextTick(() => {
      this.calculateHeights();
      this.updateVisibleItems(this.$refs.container.scrollTop);
    });
    window.addEventListener('resize', this.calculateHeights);
  },
  beforeDestroy() {
    window.removeEventListener('resize', this.calculateHeights);
  }
};
</script>

<style>
.virtual-list-container {
  height: 400px; /* Adjust this height as needed */
  overflow-y: auto;
  position: relative;
}
.spacer {
  width: 100%;
  background: transparent;
}
.items {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
}
</style>

使用虚拟列表组件

以下是如何使用 VirtualList 组件的示例,将 el-card 元素作为虚拟列表中的项渲染。

App.vue

<template>
  <div id="app">
    <VirtualList :items="listData" :visibleCount="10" :buffer="5">
      <template v-slot="{ item, index }">
        <el-card class="item" :key="item.id">
          <div class="card-content">
            <el-checkbox :label="item.id" v-model="selectedItems"></el-checkbox>
            <el-select
              v-model="item.selectedOption"
              filterable
              remote
              reserve-keyword
              :remote-method="remoteMethod"
              :loading="loading"
            >
              <el-option
                v-for="option in options"
                :key="option.value"
                :label="option.label"
                :value="option.value"
              ></el-option>
            </el-select>
          </div>
        </el-card>
      </template>
    </VirtualList>
  </div>
</template>

<script>
import VirtualList from './components/VirtualList.vue';

export default {
  components: {
    VirtualList
  },
  data() {
    return {
      listData: Array.from({ length: 1000 }, (_, i) => ({
        id: i,
        text: `Item ${i}`,
        selectedOption: null
      })),
      selectedItems: [],
      options: [],
      loading: false
    };
  },
  methods: {
    deleteSelected() {
      this.listData = this.listData.filter(item => !this.selectedItems.includes(item.id));
      this.selectedItems = [];
    },
    remoteMethod(query) {
      if (query !== '') {
        this.loading = true;
        setTimeout(() => {
          this.loading = false;
          this.options = this.listData.filter(item => {
            return item.text.toLowerCase().includes(query.toLowerCase());
          }).map(item => {
            return { value: item.id, label: item.text };
          });
        }, 200);
      } else {
        this.options = [];
      }
    }
  }
};
</script>

<style>
.item {
  box-sizing: border-box;
  border-bottom: 1px solid #ddd;
  display: flex;
  align-items: center;
  padding: 10px;
  cursor: pointer;
}
.card-content {
  display: flex;
  flex-direction: column;
  height: 100%;
}
</style>

关键点解释

  1. calculateHeights 方法
    • 动态计算每个条目的高度,并存储在 heights 数组中,同时计算出 totalHeight
  2. updateVisibleItems 方法
    • 根据滚动位置计算当前可见的条目起始索引和偏移量 offsetTop
  3. 插槽使用
    • VirtualList 中使用插槽,可以灵活地传递任意内容作为条目渲染。

调试建议

  • 检查控制台:确保没有错误或警告。
  • 浏览器开发者工具:查看元素的实际渲染情况,检查 transform 和条目的渲染位置。
  • 调整高度计算:确保 calculateHeights 方法正确计算条目高度,并且 updateVisibleItems 正确更新可见条目。

通过这种方式,可以将虚拟列表功能封装为一个可复用的组件,并在任何需要的地方使用,同时支持动态高度的条目。


<template>
  <div>
    <virtual-list :items="items">
      <template v-slot="{ item, index }">
        <div :class="index % 2 == 0 ? 'custom-item' : 'custom-item1'">
          . 
        </div>
      </template>
    </virtual-list>
  </div>
</template>

<script>
import VirtualList from '@/components/VirtualList.vue';

export default {
  components: {
    VirtualList
  },
  data() {
    return {
      items: Array.from({ length: 999 }, (_, i) => `Item ${i + 1}`)
    };
  }
};
</script>

<style>
.custom-item {
  padding: 20px;
  background-color: #f5f5f5;
  margin-bottom: 5px;
  border-radius: 5px;
}

.custom-item1 {
  padding: 40px;
  background-color: #f5f5f5;
  margin-bottom: 5px;
  border-radius: 5px;
}
</style>
<template>
    <div ref="container" class="virtual-list-container" @scroll="onScroll">
      <div class="spacer" :style="{ height: totalHeight + 'px' }"></div>
      <div class="items" :style="{ transform: `translateY(${offsetTop}px)` }">
        <div
          v-for="(item, index) in visibleItems"
          :key="startIndex + index"
          :ref="el => setItemRef(el, startIndex + index)"
          class="item"
          :class="{ selected: selectedItems.includes(startIndex + index) }"
          @click="toggleSelection(startIndex + index)"
        >
          <slot :item="item" :index="startIndex + index" />
        </div>
      </div>
    </div>
  </template>
  
  <script>
  export default {
    name: 'VirtualList',
    props: {
      items: {
        type: Array,
        required: true
      },
      buffer: {
        type: Number,
        default: 5
      }
    },
    data() {
      return {
        startIndex: 0,
        offsetTop: 0,
        totalHeight: 0,
        containerHeight: 0,
        visibleCount: 0,
        itemHeights: [],
        selectedItems: []
      };
    },
    computed: {
      visibleItems() {
        return this.items.slice(this.startIndex, this.startIndex + this.visibleCount);
      }
    },
    methods: {
      onScroll() {
        const scrollTop = this.$refs.container.scrollTop;
        this.updateVisibleRange(scrollTop);
      },
      updateVisibleRange(scrollTop) {
        let sumHeight = 0;
        let found = false;
        
        for (let i = 0; i < this.itemHeights.length; i++) {
          sumHeight += this.itemHeights[i];
          if (sumHeight > scrollTop) {
            this.startIndex = i;
            this.offsetTop = sumHeight - this.itemHeights[i];
            found = true;
            break;
          }
        }
        
        if (!found) {
          this.startIndex = 0;
          this.offsetTop = 0;
        }
  
        this.updateVisibleCount();
      },
      updateVisibleCount() {
        this.containerHeight = this.$refs.container.clientHeight;
        let sumHeight = 0;
        let count = 0;
        
        for (let i = this.startIndex; i < this.itemHeights.length; i++) {
          sumHeight += this.itemHeights[i];
          count++;
          if (sumHeight > this.containerHeight) break;
        }
        
        this.visibleCount = count + this.buffer;
      },
      toggleSelection(index) {
        const selectedIndex = this.selectedItems.indexOf(index);
        if (selectedIndex > -1) {
          this.selectedItems.splice(selectedIndex, 1);
        } else {
          this.selectedItems.push(index);
        }
      },
      setItemRef(el, index) {
        if (el) {
          this.$nextTick(() => {
            this.itemHeights[index] = el.offsetHeight;
            this.updateTotalHeight();
            this.updateVisibleCount();
          });
        }
      },
      updateTotalHeight() {
        this.totalHeight = this.itemHeights.reduce((acc, height) => acc + height, 0);
      }
    },
    mounted() {
      this.containerHeight = this.$refs.container.clientHeight;
      this.updateVisibleCount();
    }
  };
  </script>
  
  <style>
  .virtual-list-container {
    height: 400px;
    overflow-y: auto;
    position: relative;
  }
  .spacer {
    width: 100%;
  }
  .items {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
  }
  .item {
    padding: 10px;
    border-bottom: 1px solid #ccc;
    box-sizing: border-box;
  }
  .item.selected {
    background-color: #b3d4fc;
  }
  </style>

明白了,我会提供一个只包含虚拟滚动功能的组件。这个组件将只负责渲染可视区域内的元素,而不处理选择等其他操作。这些额外的操作可以在使用该组件的地方自行实现。

简化版 VirtualScroll.vue 组件

<template>
  <div ref="container" class="virtual-scroll-container" @scroll="onScroll">
    <div class="spacer" :style="{ height: totalHeight + 'px' }"></div>
    <div class="items" :style="{ transform: `translateY(${offsetTop}px)` }">
      <div
        v-for="(item, index) in visibleItems"
        :key="startIndex + index"
        :ref="el => setItemRef(el, startIndex + index)"
        class="item"
      >
        <slot :item="item" :index="startIndex + index" />
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: 'VirtualScroll',
  props: {
    items: {
      type: Array,
      required: true
    },
    buffer: {
      type: Number,
      default: 5
    }
  },
  data() {
    return {
      startIndex: 0,
      offsetTop: 0,
      totalHeight: 0,
      containerHeight: 0,
      visibleCount: 0,
      itemHeights: []
    };
  },
  computed: {
    visibleItems() {
      return this.items.slice(this.startIndex, this.startIndex + this.visibleCount);
    }
  },
  methods: {
    onScroll() {
      const scrollTop = this.$refs.container.scrollTop;
      this.updateVisibleRange(scrollTop);
    },
    updateVisibleRange(scrollTop) {
      let sumHeight = 0;
      let found = false;

      for (let i = 0; i < this.itemHeights.length; i++) {
        sumHeight += this.itemHeights[i];
        if (sumHeight > scrollTop) {
          this.startIndex = i;
          this.offsetTop = sumHeight - this.itemHeights[i];
          found = true;
          break;
        }
      }

      if (!found) {
        this.startIndex = 0;
        this.offsetTop = 0;
      }

      this.updateVisibleCount();
    },
    updateVisibleCount() {
      this.containerHeight = this.$refs.container.clientHeight;
      let sumHeight = 0;
      let count = 0;

      for (let i = this.startIndex; i < this.itemHeights.length; i++) {
        sumHeight += this.itemHeights[i];
        count++;
        if (sumHeight > this.containerHeight) break;
      }

      this.visibleCount = count + this.buffer;
    },
    setItemRef(el, index) {
      if (el) {
        this.$nextTick(() => {
          this.itemHeights[index] = el.offsetHeight;
          this.updateTotalHeight();
          this.updateVisibleCount();
        });
      }
    },
    updateTotalHeight() {
      this.totalHeight = this.itemHeights.reduce((acc, height) => acc + height, 0);
    }
  },
  mounted() {
    this.containerHeight = this.$refs.container.clientHeight;
    this.updateVisibleCount();
  }
};
</script>

<style>
.virtual-scroll-container {
  height: 400px;
  overflow-y: auto;
  position: relative;
}
.spacer {
  width: 100%;
}
.items {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
}
.item {
  box-sizing: border-box;
}
</style>

使用组件并实现选择操作

下面是一个使用 VirtualScroll 组件的例子,其中在使用组件中自行实现选择操作:

<template>
  <div>
    <virtual-scroll :items="items">
      <template v-slot="{ item, index }">
        <div
          class="custom-item"
          :class="{ selected: selectedItems.includes(index) }"
          @click="toggleSelection(index)"
        >
          . 
        </div>
      </template>
    </virtual-scroll>
  </div>
</template>

<script>
import VirtualScroll from './VirtualScroll.vue';

export default {
  components: {
    VirtualScroll
  },
  data() {
    return {
      items: Array.from({ length: 1000 }, (_, i) => `Item ${i + 1}`),
      selectedItems: []
    };
  },
  methods: {
    toggleSelection(index) {
      const selectedIndex = this.selectedItems.indexOf(index);
      if (selectedIndex > -1) {
        this.selectedItems.splice(selectedIndex, 1); // 取消选择
      } else {
        this.selectedItems.push(index); // 选择
      }
    }
  }
};
</script>

<style>
.custom-item {
  padding: 20px;
  background-color: #f5f5f5;
  margin-bottom: 5px;
  border-radius: 5px;
  cursor: pointer;
}
.custom-item.selected {
  background-color: #b3d4fc;
}
</style>

说明

  • VirtualScroll 组件:只处理虚拟滚动,不包括选择等其他操作。
  • 使用组件:在 VirtualScroll 组件中插入自定义内容,并在此处实现选择操作或其他逻辑。

这种方式可以将虚拟滚动和业务逻辑分开,实现更好的模块化和代码复用。


<template>
    <div ref="container" class="virtual-scroll-container" @scroll="onScroll">
      <div class="spacer" :style="{ height: totalHeight + 'px' }"></div>
      <div class="items" :style="{ transform: `translateY(${offsetTop}px)` }">
        <div
          v-for="(item, index) in visibleItems"
          :key="startIndex + index"
          :ref="el => setItemRef(el, startIndex + index)"
          class="item"
        >
          <slot :item="item" :index="startIndex + index" />
        </div>
      </div>
    </div>
  </template>
  
  <script>
  export default {
    name: 'VirtualScroll',
    props: {
      items: {
        type: Array,
        required: true
      },
      buffer: {
        type: Number,
        default: 5
      }
    },
    data() {
      return {
        startIndex: 0,
        offsetTop: 0,
        totalHeight: 0,
        containerHeight: 0,
        visibleCount: 0,
        itemHeights: []
      };
    },
    computed: {
      visibleItems() {
        return this.items.slice(this.startIndex, this.startIndex + this.visibleCount);
      }
    },
    methods: {
      onScroll() {
        const scrollTop = this.$refs.container.scrollTop;
        this.updateVisibleRange(scrollTop);
      },
      updateVisibleRange(scrollTop) {
        let sumHeight = 0;
        let found = false;
  
        for (let i = 0; i < this.itemHeights.length; i++) {
          sumHeight += this.itemHeights[i];
          if (sumHeight > scrollTop) {
            this.startIndex = i;
            this.offsetTop = sumHeight - this.itemHeights[i];
            found = true;
            break;
          }
        }
  
        if (!found) {
          this.startIndex = 0;
          this.offsetTop = 0;
        }
  
        this.updateVisibleCount();
      },
      updateVisibleCount() {
        let sumHeight = 0;
        let count = 0;
  
        for (let i = this.startIndex; i < this.itemHeights.length; i++) {
          sumHeight += this.itemHeights[i];
          count++;
          if (sumHeight > this.containerHeight) break;
        }
  
        this.visibleCount = count + this.buffer;
      },
      setItemRef(el, index) {
        if (el) {
          this.$nextTick(() => {
            const height = el.offsetHeight;
            if (height > 0) {
              // 使用普通赋值来更新数组中的元素
              this.$data.itemHeights[index] = height;
              this.updateTotalHeight();
              this.updateVisibleCount();
            }
          });
        }
      },
      updateTotalHeight() {
        this.totalHeight = this.itemHeights.reduce((acc, height) => acc + height, 0);
      },
      initializeVisibleCount() {
        this.containerHeight = this.$refs.container.clientHeight;
        if (this.itemHeights.length > 0) {
          this.updateVisibleCount();
        } else {
          const averageHeight = 50; // 假设初始渲染的默认高度
          this.visibleCount = Math.ceil(this.containerHeight / averageHeight) + this.buffer;
        }
      }
    },
    mounted() {
      this.$nextTick(() => {
        this.initializeVisibleCount();
        this.updateVisibleRange(0);
      });
    }
  };
  </script>
  
  <style>
  .virtual-scroll-container {
    width: 100%;
    height: 100%; /* 确保容器有高度 */
    overflow-y: auto;
    position: relative;
  }
  .spacer {
    width: 100%;
  }
  .items {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
  }
  .item {
    box-sizing: border-box;
  }
  </style>
  
<template>
  <div class="app-container">
    <VirtualScroll :items="items">
      <template v-slot="{ item, index }">
        <div
          class="custom-item"
          :class="{ selected: selectedItems.includes(index) }"
          @click="toggleSelection(index)"
        >
          . 
        </div>
      </template>
    </VirtualScroll>
  </div>
</template>

<script>
import { ref } from 'vue';
import VirtualScroll from './VirtualScroll.vue';

export default {
  components: {
    VirtualScroll,
  },
  setup() {
    const items = ref(Array.from({ length: 1000 }, (_, i) => `Item ${i + 1}`));
    const selectedItems = ref([]);

    const toggleSelection = (index) => {
      const selectedIndex = selectedItems.value.indexOf(index);
      if (selectedIndex > -1) {
        selectedItems.value.splice(selectedIndex, 1); // 取消选择
      } else {
        selectedItems.value.push(index); // 选择
      }
    };

    return {
      items,
      selectedItems,
      toggleSelection,
    };
  },
};
</script>

<style>
.app-container {
  height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
}

.custom-item {
  padding: 20px;
  background-color: #f5f5f5;
  margin-bottom: 5px;
  border-radius: 5px;
  cursor: pointer;
}

.custom-item.selected {
  background-color: #b3d4fc;
}
</style>


为了让你能够直接在 ElTabPane 中使用通用的虚拟列表组件,下面是一个完整的实现代码。这个虚拟列表组件将独立处理滚动和数据渲染,并在每个 tab 中显示不同的数据。

通用虚拟列表组件:VirtualList.vue

<template>
  <div ref="container" class="virtual-scroll-container" @scroll="onScroll">
    <div class="spacer" :style="{ height: totalHeight + 'px' }"></div>
    <div class="items" :style="{ transform: `translateY(${offsetTop}px)` }">
      <div
        v-for="(item, index) in visibleItems"
        :key="startIndex + index"
        class="item"
      >
        <slot :item="item" :index="startIndex + index"></slot>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: 'VirtualList',
  props: {
    items: {
      type: Array,
      required: true,
    },
    itemHeight: {
      type: Number,
      default: 50, // 每个项目的默认高度
    },
    buffer: {
      type: Number,
      default: 5, // 缓冲区大小,控制渲染时多加载的项
    },
  },
  data() {
    return {
      startIndex: 0,
      offsetTop: 0,
      totalHeight: 0,
      visibleCount: 0,
    };
  },
  computed: {
    visibleItems() {
      return this.items.slice(this.startIndex, this.startIndex + this.visibleCount);
    },
  },
  methods: {
    onScroll() {
      const scrollTop = this.$refs.container.scrollTop;
      this.updateVisibleRange(scrollTop);
    },
    updateVisibleRange(scrollTop) {
      this.startIndex = Math.floor(scrollTop / this.itemHeight);
      this.offsetTop = this.startIndex * this.itemHeight;
      this.updateVisibleCount();
    },
    updateVisibleCount() {
      const containerHeight = this.$refs.container.clientHeight;
      this.visibleCount = Math.ceil(containerHeight / this.itemHeight) + this.buffer;
    },
  },
  mounted() {
    this.$nextTick(() => {
      const containerHeight = this.$refs.container.clientHeight;
      this.visibleCount = Math.ceil(containerHeight / this.itemHeight) + this.buffer;
      this.totalHeight = this.items.length * this.itemHeight;
    });
  },
};
</script>

<style>
.virtual-scroll-container {
  width: 100%;
  height: 100%;
  overflow-y: auto;
  position: relative;
}
.spacer {
  width: 100%;
}
.items {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
}
.item {
  box-sizing: border-box;
  padding: 10px;
}
</style>

主页面:App.vue (直接在 ElTabPane 中使用虚拟组件)

<template>
  <el-tabs v-model="activeTab" class="tab-container">
    <el-tab-pane label="Tab 1" name="1">
      <VirtualList :items="tab1Items" :itemHeight="50">
        <template #default="{ item, index }">
          <div class="item-content">: </div>
        </template>
      </VirtualList>
    </el-tab-pane>
    <el-tab-pane label="Tab 2" name="2">
      <VirtualList :items="tab2Items" :itemHeight="50">
        <template #default="{ item, index }">
          <div class="item-content">: </div>
        </template>
      </VirtualList>
    </el-tab-pane>
  </el-tabs>
</template>

<script>
import { ref } from 'vue';
import VirtualList from './components/VirtualList.vue';

export default {
  components: {
    VirtualList,
  },
  setup() {
    const tab1Items = ref(
      Array.from({ length: 1000 }, (v, i) => ({
        id: i,
        name: `Tab 1 Item ${i + 1}`,
      }))
    );

    const tab2Items = ref(
      Array.from({ length: 800 }, (v, i) => ({
        id: i,
        name: `Tab 2 Item ${i + 1}`,
      }))
    );

    const activeTab = ref('1');

    return {
      activeTab,
      tab1Items,
      tab2Items,
    };
  },
};
</script>

<style>
.el-tabs__content {
  height: 400px; /* 设定Tab容器的高度 */
}

.item-content {
  padding: 10px;
  border-bottom: 1px solid #ddd;
}
.el-tab-pane{
  height: 100%;
}
</style>

关键说明:

  1. VirtualList.vue 通用组件:通过 props 传递数据 items 和每个条目的高度 itemHeight,并通过 slot 来灵活定义每个条目的显示内容。
  2. ElTabPane 中直接使用虚拟列表:通过 slot 的插槽机制,你可以在 ElTabPane 中直接嵌入虚拟列表,而不需要额外的封装。
  3. 支持多 Tab 独立渲染:每个 tab 页都会有独立的虚拟列表实例,并且在切换 tab 时不会相互影响。

测试:

  1. 切换 Tab,观察每个 Tab 中的数据是否独立正常渲染。
  2. 滚动列表,确保列表项不会闪烁,滚动效果正常。
  3. ElTabPane 中切换时,确保滚动条和数据都不会混乱。

这个结构简洁地将虚拟列表逻辑封装到了通用组件 VirtualList.vue 中,可以复用到其他场景中,满足你的 ElTabs 虚拟列表需求。


虚拟列表hooks

import { ref, onMounted, onBeforeUnmount, nextTick, watch } from "vue";

export default function useVirtualList(config) {
  // 获取元素
  let actualHeightContainerEl = null,
    translateContainerEl = null,
    scrollContainerEl = null;

  let size = 10; // 可视区展示列表项个数

  // 数据源,便于后续直接访问
  let dataSource = [];

  onMounted(() => {
    scrollContainerEl = document.querySelector(config.scrollContainer);
    actualHeightContainerEl = document.querySelector(config.actualHeightContainer);
    translateContainerEl = document.querySelector(config.translateContainer);

    // 获取可视区的高度,用于计算可视区的展示列表项个数
    size = Math.ceil(scrollContainerEl.clientHeight / config.itemHeight);

    dataSource = config.data.value;
    // 根据滚动高度计算需要渲染的数据
    updateActualRenderData(0);
  });

  // 数据源发生变动
  watch(
    () => config.data.value,
    newVal => {
      // 更新数据源
      dataSource = newVal;

      // 计算需要渲染的数据
      updateActualRenderData(0);
    }
  );

  // 缓存已渲染元素的高度
  const renderedItemsCache = {};

  // 获取缓存高度,无缓存,取配置项的 itemHeight
  const getItemHeightFromCache = (index) => {
    const val = renderedItemsCache[index];
    return val === undefined ? config.itemHeight : val;
  };

  /**
   * 更新虚拟区实际高度
   */
  const updateActualHeight = () => {
    // 计算所有项目的总高度
    const totalHeight = dataSource.reduce((accumulator, _, index) => {
      try {
        // 从缓存中获取每个项目的高度并累加
        return accumulator + getItemHeightFromCache(index);
      } catch (error) {
        // 如果获取项目高度时发生错误,打印错误信息并保持当前累加值不变
        console.error(`Error getting height for item ${index}:`, error);
        return accumulator;
      }
    }, 0);

    // 更新容器的高度
    if (actualHeightContainerEl) {
      // 如果容器元素存在,则设置其高度为计算出的总高度
      actualHeightContainerEl.style.height = totalHeight + "px";
    } else {
      // 如果容器元素未定义,则打印错误信息
      console.error("actualHeightContainerEl is not defined");
    }
  };

  // 更新已渲染列表项的缓存高度
  const updateRenderedItemCache = (index) => {
    const start = index;
    // 当所有元素的实际高度更新完毕,就不需要重新计算高度
    const shouldUpdate = Object.keys(renderedItemsCache).length < dataSource.length;
    if (!shouldUpdate) return;

    nextTick(() => {
      // 获取所有列表项元素
      const Items = Array.from(document.querySelectorAll(config.itemContainer));

      // 进行缓存
      Items.forEach(el => {
        if (!renderedItemsCache[index]) {
          renderedItemsCache[index] = el.offsetHeight;
        }
        index++;
      });

      // 更新实际高度
      updateActualHeight();

      // 更新偏移值
      getOffsetY(start);
    });
  };

  // 实际渲染的数据
  const actualRenderData = ref([]);

  // 更新实际渲染数据
  const updateActualRenderData = (scrollTop) => {
    // 异常处理
    if (!dataSource || !config) {
      console.error("dataSource or config is undefined");
      return;
    }

    let startIndex = 0;
    let endIndex = 0;
    let offsetHeight = 0;

    // 使用二分查找来加速 startIndex 的计算
    let low = 0;
    let high = dataSource.length - 1;
    while (low <= high) {
      const mid = Math.floor((low + high) / 2);
      offsetHeight = getAccumulatedHeight(mid);
      if (offsetHeight >= scrollTop) {
        high = mid - 1;
      } else {
        low = mid + 1;
      }
    }
    startIndex = low;
    endIndex = startIndex + size;

    // 边界条件处理 确保 startIndex 和 endIndex 在有效范围内
    startIndex = Math.max(0, startIndex);
    endIndex = Math.min(dataSource.length, endIndex);

    // 起始缓冲数量
    const aboveCount = Math.min(startIndex, size * config.bufferRatio);
    // 终止缓冲数量
    const belowCount = Math.min(config.data.value.length - endIndex, size * config.bufferRatio);
    // 计算得出的渲染数据
    let start = startIndex - aboveCount;
    const end = endIndex + belowCount;
    actualRenderData.value = dataSource.slice(start, end);

    // 缓存渲染列表中各个列表项高度
    updateRenderedItemCache(start);

    nextTick(() => {
      // 更新偏移值
      getOffsetY(start);
    });
  };

  // 辅助函数:获取累积高度
  const getAccumulatedHeight = (index) => {
    let height = 0;
    for (let i = 0; i <= index; i++) {
      height += getItemHeightFromCache(i);
    }
    return height;
  };

  // 获取偏移量
  const getOffsetY = (start) => {
    let startOffset = 0;
    if (start >= 1) {
      startOffset = new Array(start).fill("").reduce((acc, _, index) => {
        return acc + getItemHeightFromCache(index);
      }, 0);
    }
    translateContainerEl.style.transform = `translateY(${startOffset}px)`;
  };

  // 滚动事件
  const handleScroll = (e) => {
    // 渲染正确的数据
    updateActualRenderData(e.target.scrollTop);
  };

  // 注册滚动事件
  onMounted(() => {
    scrollContainerEl?.addEventListener("scroll", throttle(handleScroll, 100));
  });

  // 移除滚动事件
  onBeforeUnmount(() => {
    scrollContainerEl?.removeEventListener("scroll", handleScroll);
  });

  function throttle(fn, delay) {
    // last为上一次触发回调的时间, timer是定时器
    let last = 0,
      timer = null;
    // 将throttle处理结果当作函数返回

    return function (...args) {
      // 保留调用时的this上下文
      const context = this;
      // 记录本次触发回调的时间
      let now = +new Date();

      // 判断上次触发的时间和本次触发的时间差是否小于时间间隔的阈值
      if (now - last < delay) {
        // 如果时间间隔小于我们设定的时间间隔阈值,则为本次触发操作设立一个新的定时器
        clearTimeout(timer);
        timer = setTimeout(function () {
          last = now;
          fn.apply(context, args);
        }, delay);
      } else {
        // 如果时间间隔超出了我们设定的时间间隔阈值,那就不等了,无论如何要反馈给用户一次响应
        last = now;
        fn.apply(context, args);
      }
    };
  }

  return { actualRenderData };
}

用法

<template>
  <div class="virtual-list-container" ref="scrollContainer">
    <div class="virtual-list-actual" ref="actualContainer">
      <div class="virtual-list-translate" ref="translateContainer">
        <div v-for="item in actualRenderData" 
             :key="item.id" 
             class="list-item">
          <div class="item-content">
            <h3></h3>
            <p></p>
            <div class="item-images" v-if="item.images?.length">
              <img v-for="(img, index) in item.images" 
                   :key="index" 
                   :src="img" 
                   :alt="item.title">
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import { ref } from 'vue'
import useVirtualList from '@/hooks/useVirtualList'

export default {
  name: 'VirtualList',
  setup() {
    const scrollContainer = ref(null)
    const actualContainer = ref(null)
    const translateContainer = ref(null)

    const listData = ref(Array.from({ length: 1000 }, (_, index) => ({
      id: index,
      title: `标题 ${index}`,
      content: `这是一段动态内容 ${index} ${Math.random() > 0.5 ? '包含很多很多文字'.repeat(Math.floor(Math.random() * 3)) : '短文本'}`,
      images: Math.random() > 0.7 ? [
        'https://placeholder.com/150',
        'https://placeholder.com/150'
      ] : []
    })))

    const { actualRenderData } = useVirtualList({
      data: listData,
      scrollContainer: '.virtual-list-container',
      actualHeightContainer: '.virtual-list-actual',
      translateContainer: '.virtual-list-translate',
      itemContainer: '.list-item',
      itemHeight: 100,
      bufferRatio: 1
    })

    return {
      actualRenderData
    }
  }
}
</script>

<style scoped>
.virtual-list-container {
  height: 600px;
  overflow-y: auto;
}

.virtual-list-actual {
  position: relative;
}

.virtual-list-translate {
  position: absolute;
  width: 100%;
  left: 0;
}

.list-item {
  padding: 16px;
  border-bottom: 1px solid #eee;
}

.item-content h3 {
  margin: 0 0 8px;
}

.item-content p {
  margin: 0 0 12px;
  line-height: 1.5;
}

.item-images {
  display: flex;
  gap: 8px;
}

.item-images img {
  width: 100px;
  height: 100px;
  object-fit: cover;
}
</style>

<template>
  <div class="virtual-table-container" ref="scrollContainer">
    <div class="virtual-table-actual" ref="actualContainer">
      <div class="virtual-table-translate" ref="translateContainer">
        <el-table :data="actualRenderData" style="width: 100%">
          <el-table-column prop="name" label="姓名" />
          <el-table-column prop="description" label="描述">
            <template #default="{ row }">
              <div class="dynamic-height-cell">
                
              </div>
            </template>
          </el-table-column>
          <el-table-column prop="status" label="状态" />
        </el-table>
      </div>
    </div>
  </div>
</template>

<script>
import { ref } from 'vue'
import useVirtualList from '@/hooks/useVirtualList'

export default {
  name: 'VirtualTable',
  setup() {
    const scrollContainer = ref(null)
    const actualContainer = ref(null)
    const translateContainer = ref(null)

    const tableData = ref(Array.from({ length: 10000 }, (_, index) => ({
      id: index,
      name: `用户${index}`,
      description: `这是一段不定长的描述文本,可能会有${Math.random() > 0.5 ? '很长很长的内容。'.repeat(Math.floor(Math.random() * 5)) : '短文本'}`,
      status: Math.random() > 0.5 ? '活跃' : '离线'
    })))

    const { actualRenderData } = useVirtualList({
      data: tableData,
      scrollContainer: '.virtual-table-container',
      actualHeightContainer: '.virtual-table-actual',
      translateContainer: '.virtual-table-translate',
      itemContainer: '.el-table__row',
      itemHeight: 50,
      bufferRatio: 1
    })

    return {
      actualRenderData
    }
  }
}
</script>

<style scoped>
.virtual-table-container {
  height: 400px;
  overflow-y: auto;
}

.virtual-table-actual {
  position: relative;
}

.virtual-table-translate {
  position: absolute;
  width: 100%;
  left: 0;
}

.dynamic-height-cell {
  padding: 8px 0;
  line-height: 1.5;
  white-space: pre-wrap;
}
</style>