终于造出了虚拟列表

263 阅读2分钟

虚拟列表 + 瀑布流解决方案

效果

效果

属性

参数说明类型可选值默认值
size一次性添加的数据number-50
column列数number-1
marginBottom每项底边距number-0
loading数据加载状态boolean-false
marginTop距离底部多少开始拉取数据number-1000
data需要进行分裂的数据,{height:300},一定要含有高度字段,防止加载图片文件缓慢计算不准确array-{height:300}

方法

事件名称说明回调参数
fetchData滚动到指定位置通知加载数据-

组件内容

<template>
  <div class="c-virtual-list">
    <!-- 一屏内容有高度 -->
    <div
      class="virtual-screen"
      v-for="(screen, index) in list"
      :key="index"
      :data-index="index"
      :style="{ height: screen.height + 'px' }"
    >
      <!-- 通过v-if进行判断 节点交互是否出现在当前视口内 -->
      <div class="screen-content" v-if="computeShow(index)">
        <!-- 渲染列数 -->
        <div
          class="column"
          v-for="(column, cIndex) in screen.columns"
          :key="cIndex"
          :style="{
            marginTop:
              index > 0 ? `-${list[index - 1].heightOffset[cIndex]}px` : '0',
          }"
        >
          <slot :data="column" :index="index"></slot>
          <!-- 插槽向外部暴露数据 -->
        </div>
      </div>
    </div>
    <div class="loading" :style="{ top: -marginTop + 'px' }">loading</div>
  </div>
</template>
<script setup>
import {
  defineProps,
  defineEmits,
  nextTick,
  onBeforeUnmount,
  onMounted,
  reactive,
  watch,
} from "vue";
​
const props = defineProps({
  // 一次性插入数据
  size: {
    type: Number,
    require: true,
    default: 50,
  },
  // 列数
  column: {
    type: Number,
    default: 1,
  },
  // 每项底边距
  marginBottom: {
    type: Number,
    default: 0,
  },
  // 数据加载状态
  loading: {
    type: Boolean,
    default: false,
  },
  // 距离底部多少开始拉取数据
  marginTop: {
    type: Number,
    default: 1000,
  },
  // 分列数据
  data: {
    type: Array,
    default: () => [],
  },
});
​
// 最终数据结构
const list = reactive([
  // 一屏内容
  // {
  //   // 是否展示
  //   show: false,
  //   // 取最大列高度
  //   height: "",
  //   // 每列高度
  //   heights: [],
  //   // 每列移量
  //   heightOffset: [],
  //   // 每列数据
  //   columns:[[],[],[]],
  // }
]);
// 渲染项  height 为必须项
const item = {
  height: 300,
  color: "",
};
// 可视节点索引
const showList = reactive([-1, 0, 1]);
const emits = defineEmits(["update:loading", "fetchData"]);
​
onMounted(() => {
  createNodeObserve(".loading", () => {
    // 通知父组件去加载数据
    console.log("去加载数据");
    emits("fetchData");
  });
});
​
// 监听传入data发生改变
watch(props.data, (newData, preDate) => {
  // console.log("newData: ", newData);
  // 对最新数据进行分列
  divisionData(newData);
  // 对所有分屏节点进行视口监听
  nextTick(() => {
    createNodeObserve(".virtual-screen", (index) => {
      // 渲染3部分数据
      Object.assign(showList, [index - 1, index, index + 1]);
    });
  });
});
​
/**
 * 可视节点监听
 *
 * @param {string} element 元素类名
 */
let ob;
const createNodeObserve = (element, callback) => {
  // console.log("element: ", element);
  ob = new IntersectionObserver((entries) => {
    // 不可视
    if (!entries[0].isIntersecting) return;
​
    // 存在问题,元素节点 必须传入  data-index
    let showIndex = ~~entries[0].target.dataset.index;
    callback(showIndex);
    // 出现在可视范围之内
  });
  document.body.querySelectorAll(element).forEach((item) => {
    ob.observe(item);
  });
};
onBeforeUnmount(() => {
  ob && ob.disconnect();
});
​
/**
 * 当前索引是否可视
 * @param {number} index
 */
const computeShow = (index) => {
  return showList.findIndex((item) => item === index) > -1 ? true : false;
};
/**
 * 数据分裂
 * 第一屏数据默认从第一列开始插入,此时产生列偏移量
 * 从第二屏数据开始
 */
const divisionData = (data) => {
  // console.log("data: ", data);
  // 存储每列数据
  let columns = [];
  // 每列高度
  let columnsHeight = [];
​
  // 上一次的偏移差高度  -
  let tempHeights = [];
​
  // 初始列数 column => []
  for (let i = 0; i < props.column; i++) {
    columns.push([]);
​
    columnsHeight.push(0);
​
    tempHeights.push(0);
  }
​
  // 子项插入位置  默认第一列
  let input = 0;
  // 最大偏移量
  let maxOffset = 0;
  // 最大高度
  let maxHeight = 0;
  // 高度偏移量
  let heightOffset = [];
  // 下次插入顺序
  let nextSortInput = [];
​
  // list 长度大于0 存在当前插入的位置
  if (list.length > 0) {
    let sortInput = list[list.length - 1].nextSortInput;
    // 当前列数大于1
    if (props.column > 1) {
      input = sortInput.length - 1;
​
      // 将上一次遗留的偏移量转接到这一次的高度
      list[list.length - 1].heightOffset.forEach((offsetH, index) => {
        tempHeights[index] = -offsetH;
      });
​
      // 插入数据 并结算当前列高度
      sortInput.forEach((sort, index) => {
        columns[sort].push(data[index]);
        // console.log("data[index]: ", data[index]);
        columnsHeight[index] += data[index].height + props.marginBottom;
      });
    }
  }
​
  // 开始插入数据
  for (let dIndex = input; dIndex < data.length; dIndex++) {
    // 计算插入位置
    const position = computeInput(columnsHeight);
    // 插入数据
    columns[position].push(data[dIndex]);
    // 计算高度
    columnsHeight = computeHeight(columns, columnsHeight, tempHeights);
  }
  // 计算下次插入位置
  maxHeight = Math.max(...columnsHeight);
  // 计算下次插入的位置
  nextSortInput = computeNextSortPosition(columnsHeight);
  // 计算每列偏移量
  heightOffset = computeOffset(columnsHeight);
  /**
   * 插入位置计算
   * @param {array<number>} heights
   */
  function computeInput(heights) {
    let min = Math.min(...heights);
    return heights.findIndex((item) => item === min) === -1
      ? 0
      : heights.findIndex((item) => item === min);
  }
​
  /**
   * 高度计算
   * @param {array} cols 列数据
   * @param {array} heights 每列高度
   */
  function computeHeight(cols, heights, tHeights) {
    let hs = JSON.parse(JSON.stringify(heights));
    cols.forEach((col, index) => {
      // 一定要先减去上次的偏移量
      // 在加载第一屏数据之后,造成的高度差在第二次进行减去再判断插入位置
      hs[index] = tHeights[index];
      col.forEach((item) => {
        hs[index] += item.height + props.marginBottom;
      });
    });
    return hs;
  }
​
  /**
   * 计算下次插入的位置
   * @param {*} heights
   */
  function computeNextSortPosition(heights) {
    let hs = JSON.parse(JSON.stringify(heights));
    hs = hs.sort((a, b) => a - b);
    let result = [];
    heights.forEach((item, index) => {
      result.push(hs.findIndex((h) => h === item));
    });
    result = arrayAddOne(result);
    return result;
  }
​
  /**
   * 计算每列的偏移量
   * @param {array<number>} heights
   */
  function computeOffset(heights) {
    const max = Math.max(...heights);
    const result = [];
    heights.forEach((item, index) => {
      result[index] = max - item;
    });
    return result;
  }
​
  let screen = {
    // 是否展示
    show: false,
    // 取最大列高度
    height: maxHeight,
    // 每列高度
    heights: columnsHeight,
    // 每列移量
    heightOffset,
    // 下次插入顺序
    nextSortInput,
    // 每列数据
    columns,
  };
  list.push(screen);
};
​
/**
 * 防止排序重叠 给相同项+1
 *
 * @param {array} arr
 */
function arrayAddOne(arr) {
  const result = [...arr];
  result.forEach((item, index) => {
    for (let i = index + 1; i < result.length; i++) {
      if (item === arr[i]) {
        // console.log(i, item);
        result[i]++;
      }
    }
  });
  return result;
}
/**
 * 模拟请求时间
 *
 * @param {number} timer
 */
const sleep = (timer = 10) => {
  return new Promise((resolve) => {
    setTimeout(() => resolve(), timer);
  });
};
/**
 * 随机生成颜色
 *
 */
const randomColor = (min = 0, max = 150) => {
  //  Math.round(Math.random() * (max - min)) + min
  // Math.round(Math.random() * (max - min)) + min
  const r = Math.round(Math.random() * (max - min)) + min;
  const g = Math.round(Math.random() * (max - min)) + min;
  const b = Math.round(Math.random() * (max - min)) + min;
  const a = Math.floor(Math.random() * 100) / 100;
  return `rgba(${r},${g},${b},${a})`;
};
​
/**
 * 随机高度
 *
 * @param {array} rang
 */
const randomHeight = (rang = [100, 120, 140, 160, 180, 200]) => {
  return rang[parseInt(Math.random() * rang.length)];
};
</script>
<style scoped>
.btn {
  position: fixed;
  top: 0;
  left: 0;
}
​
.c-virtual-list {
  width: 100%;
  height: 100%;
}
.virtual-screen {
  border-bottom: 1px solid #ccc;
}
​
.screen-content {
  display: flex;
  justify-content: space-around;
  /* flex-direction: row; */
}
​
/* 临时使用 */
.column {
  display: flex;
  /* justify-content: space-around; */
  flex-direction: column;
}
.column .item {
  flex-shrink: 0;
  /* margin-bottom: 10px; */
  width: 300px;
}
​
.loading {
  position: relative;
  z-index: -1;
  opacity: 0;
  text-align: center;
}
</style>

组件使用

<template>
  <div>
    <h3>测试虚拟列表</h3>
    <VirtualList
      :data="listData"
      :size="50"
      :column="3"
      :marginBottom="10"
      @fetchData="generateData(50)"
    >
      <template v-slot="column">
        <div
          v-for="(item, iIndex) in column.data"
          class="item"
          :key="iIndex"
          :style="{
            height: item.height + 'px',
            background: item.color,
            marginBottom: 10 + 'px',
          }"
        ></div>
      </template>
    </VirtualList>
  </div>
</template>
<script setup>
import { onBeforeMount, reactive, ref } from "vue";
import VirtualList from "@/components/VirtualList";
​
let loading = ref(false);
​
const item = {
  height: 300,
  color: "",
};
​
const listData = reactive([]);
​
/**
 * 模拟数据生成
 * @param {*} num
 */
const generateData = async (num) => {
  const data = [];
  if (!loading.value) {
    // emits("update:loading", true);
    loading.value = true;
    await sleep(500);
    for (let i = 0; i < num; i++) {
      item.color = randomColor();
      // randomHeight()
      item.height = randomHeight();
      data.push(JSON.parse(JSON.stringify(item)));
    }
    Object.assign(listData, data);
    loading.value = false;
  }
};
onBeforeMount(() => {
  generateData(50);
});
/**
 * 模拟请求时间
 *
 * @param {number} timer
 */
const sleep = (timer = 10) => {
  return new Promise((resolve) => {
    setTimeout(() => resolve(), timer);
  });
};
/**
 * 随机生成颜色
 *
 */
const randomColor = (min = 0, max = 150) => {
  //  Math.round(Math.random() * (max - min)) + min
  // Math.round(Math.random() * (max - min)) + min
  const r = Math.round(Math.random() * (max - min)) + min;
  const g = Math.round(Math.random() * (max - min)) + min;
  const b = Math.round(Math.random() * (max - min)) + min;
  const a = Math.floor(Math.random() * 100) / 100;
  return `rgba(${r},${g},${b},${a})`;
};
​
/**
 * 随机高度
 *
 * @param {array} rang
 */
const randomHeight = (rang = [100, 120, 140, 160, 180, 200]) => {
  return rang[parseInt(Math.random() * rang.length)];
};
</script><style scoped>
.item {
  flex-shrink: 0;
  /* margin-bottom: 10px; */
  width: 300px;
}
</style>

分析

  • IntersectionObserver判断元素节点是否在可视区域之内
let ob;
const createNodeObserve = (element, callback) => {
  // console.log("element: ", element);
  ob = new IntersectionObserver((entries) => {
    // 不可视
    if (!entries[0].isIntersecting) return;
​
    // 存在问题,元素节点 必须传入  data-index
    let showIndex = ~~entries[0].target.dataset.index;
    callback(showIndex);
    // 出现在可视范围之内
  });
  document.body.querySelectorAll(element).forEach((item) => {
    ob.observe(item);
  });
};
onBeforeUnmount(() => {
  ob && ob.disconnect();
});
  • 每一屏数据结构分析
 {
    // 是否展示
    show: false,
    // 取最大列高度
    height: "",
    // 每列高度
    heights: [],
    // 每列偏移量
    heightOffset: [],
    // 每列数据
    columns:[[],[],[]],
  }
  • 计算每列的高度
// 这里分两种情况
// 当前只用第一屏数据的时候,不需要将偏移量 加入到 tempHeights 中
// 第二种情况,存在偏移量,每次计算时加入偏移量计算,得到最终的每列高度
​
  function computeHeight(cols, heights, tHeights) {
    let hs = JSON.parse(JSON.stringify(heights));
    cols.forEach((col, index) => {
      // 一定要先减去上次的偏移量
      // 在加载第一屏数据之后,造成的高度差在第二次进行减去再判断插入位置
      hs[index] = tHeights[index];
      col.forEach((item) => {
        hs[index] += item.height + props.marginBottom;
      });
    });
    return hs;
  }
  • 每列偏移量计算
// 求出每一列的高度,
// 计算出最大值,
// 用最大值减去高度的每一项,得到每列的偏移量
  function computeOffset(heights) {
    const max = Math.max(...heights);
    const result = [];
    heights.forEach((item, index) => {
      result[index] = max - item;
    });
    return result;
  }
  • 下次插入位置计算
// 将高度进行生效排序之后,在根据原高度确定插入排序的数组
// 例如高度 [120,110,130]
// 返回顺序 [1,0,2]
  function computeNextSortPosition(heights) {
    let hs = JSON.parse(JSON.stringify(heights));
    hs = hs.sort((a, b) => a - b);
    let result = [];
    heights.forEach((item, index) => {
      result.push(hs.findIndex((h) => h === item));
    });
    result = arrayAddOne(result);
    return result;
  }
  • 插入第几列计算
// 获取最小值
// 获取最小值位置
  function computeInput(heights) {
    let min = Math.min(...heights);
    return heights.findIndex((item) => item === min) === -1
      ? 0
      : heights.findIndex((item) => item === min);
  }

未解决问题

  • 兼容性问题IntersectionObserver
  • 依赖height,当动态加载图片时高度无法计算准确