浏览代码

feat(list): 优化虚拟列表,支持不固定高度 #1658 #1382

suzigang 2 年之前
父节点
当前提交
1a1bde23b5

+ 7 - 7
src/packages/__VUE/list/demo.vue

@@ -2,10 +2,10 @@
   <div class="demo">
     <h2>{{ translate('basic') }}</h2>
     <nut-cell>
-      <nut-list :height="50" :listData="count" @scroll-bottom="handleScroll">
-        <template v-slot="{ item }">
+      <nut-list :listData="count" @scroll-bottom="handleScroll">
+        <template v-slot="{ item, index }">
           <div class="list-item">
-            {{ item }}
+            {{ index }}
           </div>
         </template>
       </nut-list>
@@ -20,7 +20,7 @@ import { useTranslate } from '@/sites/assets/util/useTranslate';
 const initTranslate = () =>
   useTranslate({
     'zh-CN': {
-      basic: '基用法'
+      basic: '基用法'
     },
     'en-US': {
       basic: 'Basic Usage'
@@ -48,18 +48,18 @@ export default createDemo({
   }
 });
 </script>
-<style lang="scss" scoped>
+<style lang="scss">
 .demo {
   .nut-cell {
     height: 100%;
   }
-  .list-item {
+  .nut-list-item {
     display: flex;
     align-items: center;
     justify-content: center;
     width: 100%;
-    height: 50px;
     margin-bottom: 10px;
+    height: 50px;
     background-color: #f4a8b6;
     border-radius: 10px;
   }

+ 16 - 21
src/packages/__VUE/list/doc.en-US.md

@@ -12,6 +12,7 @@ import { List } from '@nutui/nutui';
 
 const app = createApp();
 app.use();
+
 ```
 
 ### Basic Usage
@@ -23,10 +24,10 @@ app.use();
   <div class="demo">
     <h2>Basic Usage</h2>
     <nut-cell>
-      <nut-list :height="50" :listData="count" @scroll-bottom="handleScroll">
-        <template v-slot="{ item }">
+      <nut-list :listData="count" @scroll-bottom="handleScroll">
+        <template v-slot="{ item. index }">
           <div class="list-item">
-            {{ item }}
+            {{ index }}
           </div>
         </template>
       </nut-list>
@@ -66,16 +67,16 @@ body {
   height: 100%;
 }
 .demo {
-  height: 100%;
   .nut-cell {
     height: 100%;
   }
-  .list-item {
+  .nut-list-item {
     display: flex;
     align-items: center;
     justify-content: center;
     width: 100%;
-    height: 100%;
+    margin-bottom: 10px;
+    height: 50px;
     background-color: #f4a8b6;
     border-radius: 10px;
   }
@@ -91,29 +92,23 @@ body {
 
 | Attribute         | Description                             | Type   | Default           |
 |--------------|----------------------------------|--------|------------------|
-| height         | The height of the list item               | number | `50`                |
+| height         | The height/estimated height of the list item, supports unfixed height    | Number | `80`                |
 | list-data         | List data               | any[] | `[]`                |
-| container-height       | Container height(The maximum value cannot exceed the viewable area)              | number | `Visual area height`                |
+| container-height        | Container height(The maximum value cannot exceed the viewable area)              | Number | `Visual area height`                |
+| buffer-size         | data buffer length              | Number | `5`                |
+| margin        | The gap between the lists is consistent with the custom style         | Number | `10`                |
 
 ### Slots
 
-| Name         | Description                             | Type   |
+| Attribute         | Description                             | Type   |
 |--------------|----------------------------------|--------|
-| item         | List item data               | object |
-| index         | Indexes               | number |
+| item         | List item data               | Object |
+| index         | Indexes               | Number |
 
 ### Events
 
 | Event | Description           | Arguments     |
 |--------|----------------|--------------|
 | scroll-bottom   | Triggered when scrolling to the bottom | - |
-
-## Theming
-
-### CSS Variables
-
-The component provides the following CSS variables, which can be used to customize styles. Please refer to [ConfigProvider component](#/en-US/component/configprovider).
-
-| Name | Default Value |
-| --------------------------------------- | -------------------------- |
-| --nut-list-item-margin       | _0 0 10px 0_        |
+| scroll-up   | scroll up | - |
+| scroll-down  | scroll down | - |

+ 16 - 22
src/packages/__VUE/list/doc.md

@@ -12,6 +12,7 @@ import { List } from '@nutui/nutui';
 
 const app = createApp();
 app.use();
+
 ```
 
 ### 基础用法
@@ -24,9 +25,9 @@ app.use();
     <h2>基础用法</h2>
     <nut-cell>
       <nut-list :height="50" :listData="count" @scroll-bottom="handleScroll">
-        <template v-slot="{ item }">
+        <template v-slot="{ item, index }">
           <div class="list-item">
-            {{ item }}
+            {{ index }}
           </div>
         </template>
       </nut-list>
@@ -66,16 +67,16 @@ body {
   height: 100%;
 }
 .demo {
-  height: 100%;
   .nut-cell {
     height: 100%;
   }
-  .list-item {
+  .nut-list-item {
     display: flex;
     align-items: center;
     justify-content: center;
     width: 100%;
-    height: 100%;
+    height: 50px;
+    margin-bottom: 10px;
     background-color: #f4a8b6;
     border-radius: 10px;
   }
@@ -91,30 +92,23 @@ body {
 
 | 参数         | 说明                             | 类型   | 默认值           |
 |--------------|----------------------------------|--------|------------------|
-| height         | 列表项的高度               | number | `50`                |
+| height         | 列表项的高度/预估高度,支持不固定高度               | Number | `80`                |
 | list-data         | 列表数据               | any[] | `[]`                |
-| container-height        | 容器高度(最大值不能超过可视区)              | number | `可视区高度`                |
+| container-height        | 容器高度(最大值不能超过可视区)              | Number | `可视区高度`                |
+| buffer-size         | 数据缓冲区长度              | Number | `5`                |
+| margin        | 列表之间的间隙,和自定义样式保持一致         | Number | `10`                |
 
 ### Slots
 
-| 名称         | 说明                             | 类型   |
+| 参数         | 说明                             | 类型   |
 |--------------|----------------------------------|--------|
-| item         | 列表项数据               | object |
-| index         | 索引               | number |
+| item         | 列表项数据               | Object |
+| index         | 列表索引               | Number |
 
 ### Events
 
 | 事件名 | 说明           | 回调参数     |
 |--------|----------------|--------------|
-| scroll-bottom   | 滚动到底部时触发 | - |
-
-
-## 主题定制
-
-### 样式变量
-
-组件提供了下列 CSS 变量,可用于自定义样式,使用方法请参考 [ConfigProvider 组件](#/zh-CN/component/configprovider)。
-
-| 名称                                    | 默认值                     |
-| --------------------------------------- | -------------------------- |
-| --nut-list-item-margin       | _0 0 10px 0_        |
+| scroll-bottom    | 滚动到底部时触发 | - |
+| scroll-up   | 向上滚动 | - |
+| scroll-down   | 向下滚动 | - |

+ 15 - 20
src/packages/__VUE/list/doc.taro.md

@@ -12,6 +12,7 @@ import { List } from '@nutui/nutui-taro';
 
 const app = createApp();
 app.use();
+
 ```
 
 ### 基础用法
@@ -24,9 +25,9 @@ app.use();
     <h2>基础用法</h2>
     <nut-cell>
       <nut-list :height="50" :listData="count" @scroll-bottom="handleScroll">
-        <template v-slot="{ item }">
+        <template v-slot="{ item, index }">
           <div class="list-item">
-            {{ item }}
+            {{ index }}
           </div>
         </template>
       </nut-list>
@@ -66,16 +67,16 @@ body {
   height: 100%;
 }
 .demo {
-  height: 100%;
   .nut-cell {
     height: 100%;
   }
-  .list-item {
+  .nut-list-item {
     display: flex;
     align-items: center;
     justify-content: center;
     width: 100%;
-    height: 100%;
+    height: 50px;
+    margin-bottom: 10px;
     background-color: #f4a8b6;
     border-radius: 10px;
   }
@@ -91,29 +92,23 @@ body {
 
 | 参数         | 说明                             | 类型   | 默认值           |
 |--------------|----------------------------------|--------|------------------|
-| height         | 列表项的高度               | number | `50`                |
+| height         | 列表项的高度/预估高度,支持不固定高度               | Number | `80`                |
 | list-data         | 列表数据               | any[] | `[]`                |
-| container-height        | 容器高度(最大值不能超过可视区)              | number | `可视区高度`                |
+| container-height        | 容器高度(最大值不能超过可视区)              | Number | `可视区高度`                |
+| buffer-size        | 数据缓冲区长度              | Number | `5`                |
+| margin        | 列表之间的间隙,和自定义样式保持一致         | Number | `10`                |
 
 ### Slots
 
-| 名称         | 说明                             | 类型   |
+| 参数         | 说明                             | 类型   |
 |--------------|----------------------------------|--------|
-| item         | 列表项数据               | object |
-| index         | 索引               | number |
+| item         | 列表项数据               | Object |
+| index         | 列表索引               | Number |
 
 ### Events
 
 | 事件名 | 说明           | 回调参数     |
 |--------|----------------|--------------|
 | scroll-bottom   | 滚动到底部时触发 | - |
-
-## 主题定制
-
-### 样式变量
-
-组件提供了下列 CSS 变量,可用于自定义样式,使用方法请参考 [ConfigProvider 组件](#/zh-CN/component/configprovider)。
-
-| 名称                                    | 默认值                     |
-| --------------------------------------- | -------------------------- |
-| --nut-list-item-margin       | _0 0 10px 0_        |
+| scroll-up   | 向上滚动 | - |
+| scroll-down   | 向下滚动 | - |

+ 8 - 8
src/packages/__VUE/list/index.scss

@@ -1,23 +1,23 @@
 .nut-list {
   width: 100%;
-  overflow: scroll;
   position: relative;
+  overflow-x: hidden;
+  overflow-y: auto;
   -webkit-overflow-scrolling: touch;
+
   &-phantom {
-    position: absolute;
-    left: 0;
-    top: 0;
-    right: 0;
+    position: relative;
     z-index: -1;
   }
+
   &-container {
+    position: absolute;
+    top: 0;
     left: 0;
     right: 0;
-    top: 0;
-    position: absolute;
   }
+
   &-item {
     overflow: hidden;
-    margin: $list-item-margin;
   }
 }

+ 187 - 24
src/packages/__VUE/list/index.taro.vue

@@ -3,13 +3,30 @@
     :class="classes"
     :scroll-y="true"
     :style="{ height: `${getContainerHeight}px` }"
+    scroll-top="0"
     @scroll="handleScrollEvent"
     ref="list"
+    :id="'list' + refRandomId"
   >
-    <div class="nut-list-phantom" :style="{ height: listHeight + 'px' }"></div>
-    <div class="nut-list-container" :style="{ transform: getTransform }">
-      <div class="nut-list-item" :style="{ height: height + 'px' }" v-for="(item, index) in visibleData" :key="item">
-        <slot :item="item" :index="index"></slot>
+    <div
+      class="nut-list-phantom"
+      :style="{ height: phantomHeight + 'px' }"
+      ref="phantom"
+      :id="'phantom' + refRandomId"
+    ></div>
+    <div
+      class="nut-list-container"
+      :style="{ transform: getTransform() }"
+      ref="actualContent"
+      :id="'actualContent' + refRandomId"
+    >
+      <div
+        class="nut-list-item"
+        v-for="(item, index) in visibleData"
+        :key="item"
+        :id="'list-item-' + Number(index + start)"
+      >
+        <slot :item="item" :index="index + start"></slot>
       </div>
     </div>
   </Nut-Scroll-View>
@@ -19,8 +36,11 @@ import { reactive, toRefs, computed, ref, Ref, watch, ComputedRef } from 'vue';
 import { createComponent } from '@/packages/utils/create';
 import NutScrollView from '../scrollView/index.taro.vue';
 import Taro from '@tarojs/taro';
+import { useTaroRect } from '@/packages/utils/useTaroRect';
+import { CachedPosition, CompareResult, binarySearch } from './type';
 const { componentName, create } = createComponent('list');
 const clientHeight = Taro.getSystemInfoSync().windowHeight || 667;
+
 export default create({
   components: {
     NutScrollView
@@ -36,19 +56,37 @@ export default create({
         return [];
       }
     },
+    bufferSize: {
+      type: Number,
+      default: 5
+    },
     containerHeight: {
       type: [Number],
       default: clientHeight
+    },
+    estimateRowHeight: {
+      type: Number,
+      default: 80
+    },
+    margin: {
+      type: Number,
+      default: 10
     }
   },
-  emits: ['scroll', 'scroll-bottom'],
+  emits: ['scroll-up', 'scroll-down', 'scroll-bottom'],
 
   setup(props, { emit }) {
     const list = ref(null) as Ref;
+    const phantom = ref(null) as Ref;
+    const actualContent = ref(null) as Ref;
+    const refRandomId = Math.random().toString(36).slice(-8);
     const state = reactive({
-      startOffset: 0,
       start: 0,
-      list: props.listData.slice()
+      originStartIndex: 0,
+      scrollTop: 0,
+      list: props.listData.slice(),
+      cachePositions: [] as CachedPosition[],
+      phantomHeight: props.estimateRowHeight * props.listData.length
     });
 
     const getContainerHeight = computed(() => {
@@ -56,15 +94,11 @@ export default create({
     });
 
     const visibleCount = computed(() => {
-      return Math.ceil(getContainerHeight.value / props.height);
+      return Math.ceil(getContainerHeight.value / props.estimateRowHeight);
     });
 
     const end = computed(() => {
-      return state.start + visibleCount.value;
-    });
-
-    const getTransform = computed(() => {
-      return `translate3d(0, ${state.startOffset}px, 0)`;
+      return Math.min(state.originStartIndex + visibleCount.value + props.bufferSize, state.list.length - 1);
     });
 
     const classes = computed(() => {
@@ -74,37 +108,166 @@ export default create({
       };
     });
 
-    const listHeight = computed(() => {
-      return state.list.length * props.height;
-    });
-
     const visibleData: ComputedRef = computed(() => {
-      return state.list.slice(state.start, Math.min(end.value, state.list.length));
+      return state.list.slice(state.start, end.value);
     });
 
+    const getTransform = () => {
+      if (actualContent.value) {
+        return `translate3d(0, ${state.start >= 1 ? state.cachePositions[state.start - 1].bottom : 0}px, 0)`;
+      }
+    };
+
+    const initCachedPosition = () => {
+      state.cachePositions = [];
+      for (let i = 0; i < state.list.length; ++i) {
+        state.cachePositions[i] = {
+          index: i,
+          height: props.estimateRowHeight,
+          top: i * props.estimateRowHeight,
+          bottom: (i + 1) * (props.estimateRowHeight + props.margin),
+          dValue: 0
+        };
+      }
+    };
+
+    const updateCachedPosition = () => {
+      let nodes: any[] = actualContent.value.childNodes;
+      nodes = Array.from(nodes).filter((node: HTMLDivElement) => node.nodeType === 1);
+      const start = nodes[0];
+      nodes.forEach(async (node: HTMLDivElement, index: number) => {
+        if (!node) return;
+        const rect = await useTaroRect(node, Taro);
+        if (rect && rect.height) {
+          const { height } = rect;
+          const oldHeight = state.cachePositions[index + state.start]
+            ? state.cachePositions[index + state.start].height
+            : props.height;
+          const dValue = oldHeight - height;
+
+          if (dValue && state.cachePositions[index + state.start]) {
+            state.cachePositions[index + state.start].bottom -= dValue;
+            state.cachePositions[index + state.start].height = height;
+            state.cachePositions[index + state.start].dValue = dValue;
+          }
+        }
+      });
+
+      let startIndex = 0;
+      if (start) {
+        startIndex = state.start;
+      }
+
+      const cachedPositionsLen = state.cachePositions.length;
+      let cumulativeDiffHeight = state.cachePositions[startIndex].dValue;
+      state.cachePositions[startIndex].dValue = 0;
+
+      for (let i = startIndex + 1; i < cachedPositionsLen; ++i) {
+        const item = state.cachePositions[i];
+
+        state.cachePositions[i].top = state.cachePositions[i - 1].bottom;
+        state.cachePositions[i].bottom = state.cachePositions[i].bottom - cumulativeDiffHeight;
+
+        if (item.dValue !== 0) {
+          cumulativeDiffHeight += item.dValue;
+          item.dValue = 0;
+        }
+      }
+
+      const height = state.cachePositions[cachedPositionsLen - 1].bottom;
+
+      state.phantomHeight = height;
+    };
+
+    const getStartIndex = (scrollTop = 0) => {
+      let idx = binarySearch<CachedPosition, number>(
+        state.cachePositions,
+        scrollTop,
+        (currentValue: CachedPosition, targetValue: number) => {
+          const currentCompareValue = currentValue.bottom;
+          if (currentCompareValue === targetValue) {
+            return CompareResult.eq;
+          }
+
+          if (currentCompareValue < targetValue) {
+            return CompareResult.lt;
+          }
+
+          return CompareResult.gt;
+        }
+      ) as number;
+
+      const targetItem = state.cachePositions[idx];
+
+      if (targetItem.bottom < scrollTop) {
+        idx += 1;
+      }
+
+      return idx;
+    };
+
+    const resetAllVirtualParam = () => {
+      state.originStartIndex = 0;
+      state.start = 0;
+      state.scrollTop = 0;
+      list.value.scrollTop = 0;
+      initCachedPosition();
+      state.phantomHeight = props.estimateRowHeight * state.list.length;
+    };
+
     const handleScrollEvent = async (e: any) => {
       const scrollTop = Math.max(e.detail ? e.detail.scrollTop : e.target.scrollTop, 0.1);
-      state.start = Math.floor(scrollTop / props.height);
-      if (end.value >= state.list.length) {
-        emit('scroll-bottom');
+      const { originStartIndex } = state;
+      const currentIndex = getStartIndex(scrollTop);
+      if (currentIndex !== originStartIndex) {
+        state.originStartIndex = currentIndex;
+        state.start = Math.max(state.originStartIndex - props.bufferSize, 0);
+        if (end.value >= state.list.length - 1) {
+          emit('scroll-bottom');
+        }
       }
-      state.startOffset = scrollTop - (scrollTop % props.height);
+      emit(scrollTop > state.scrollTop ? 'scroll-up' : 'scroll-down', scrollTop);
+      state.scrollTop = scrollTop;
     };
 
     watch(
       () => props.listData,
+      (val: any[]) => {
+        state.list = val.slice();
+        if (state.list.length === val.length) {
+          setTimeout(() => {
+            initCachedPosition();
+            updateCachedPosition();
+          }, 200);
+        } else {
+          resetAllVirtualParam();
+          return;
+        }
+      }
+    );
+
+    watch(
+      () => state.start,
       () => {
-        state.list = props.listData.slice();
+        if (actualContent.value && state.list.length > 0) {
+          Taro.nextTick(() => {
+            setTimeout(() => {
+              updateCachedPosition();
+            }, 200);
+          });
+        }
       }
     );
 
     return {
       ...toRefs(state),
       list,
+      phantom,
+      actualContent,
       getTransform,
-      listHeight,
       visibleData,
       classes,
+      refRandomId,
       getContainerHeight,
       handleScrollEvent
     };

+ 157 - 27
src/packages/__VUE/list/index.vue

@@ -1,9 +1,9 @@
 <template>
   <div :class="classes" :style="{ height: `${getContainerHeight}px` }" @scroll.passive="handleScrollEvent" ref="list">
-    <div class="nut-list-phantom" :style="{ height: listHeight + 'px' }"></div>
-    <div class="nut-list-container" :style="{ transform: getTransform }">
-      <div class="nut-list-item" :style="{ height: height + 'px' }" v-for="(item, index) in visibleData" :key="item">
-        <slot :item="item" :index="index"></slot>
+    <div class="nut-list-phantom" :style="{ height: phantomHeight + 'px' }" ref="phantom"></div>
+    <div class="nut-list-container" :style="{ transform: getTransform() }" ref="actualContent">
+      <div class="nut-list-item" v-for="(item, index) in visibleData" :key="item">
+        <slot :item="item" :index="index + start"></slot>
       </div>
     </div>
   </div>
@@ -11,33 +11,49 @@
 <script lang="ts">
 import { reactive, toRefs, computed, ref, Ref, watch, ComputedRef } from 'vue';
 import { createComponent } from '@/packages/utils/create';
+import { CachedPosition, CompareResult, binarySearch } from './type';
+import { useRect } from '@/packages/utils/useRect';
 const { componentName, create } = createComponent('list');
 const clientHeight = document.documentElement.clientHeight || document.body.clientHeight || 667;
+
 export default create({
   props: {
-    height: {
-      type: [Number],
-      default: 50
-    },
     listData: {
       type: Array,
       default: () => {
         return [];
       }
     },
+    bufferSize: {
+      type: Number,
+      default: 5
+    },
     containerHeight: {
       type: [Number],
       default: clientHeight
+    },
+    height: {
+      type: Number,
+      default: 80
+    },
+    margin: {
+      type: Number,
+      default: 10
     }
   },
-  emits: ['scroll', 'scroll-bottom'],
+  emits: ['scroll-up', 'scroll-down', 'scroll-bottom'],
 
   setup(props, { emit }) {
     const list = ref(null) as Ref;
+    const phantom = ref(null) as Ref;
+    const actualContent = ref(null) as Ref;
     const state = reactive({
-      startOffset: 0,
       start: 0,
-      list: props.listData.slice()
+      originStartIndex: 0,
+      scrollTop: 0,
+      list: props.listData.slice(),
+      cachePositions: [] as CachedPosition[],
+      phantomHeight: props.height * props.listData.length
     });
 
     const getContainerHeight = computed(() => {
@@ -49,11 +65,7 @@ export default create({
     });
 
     const end = computed(() => {
-      return state.start + visibleCount.value;
-    });
-
-    const getTransform = computed(() => {
-      return `translate3d(0, ${state.startOffset}px, 0)`;
+      return Math.min(state.originStartIndex + visibleCount.value + props.bufferSize, state.list.length - 1);
     });
 
     const classes = computed(() => {
@@ -63,35 +75,153 @@ export default create({
       };
     });
 
-    const listHeight = computed(() => {
-      return state.list.length * props.height;
-    });
-
     const visibleData: ComputedRef = computed(() => {
-      return state.list.slice(state.start, Math.min(end.value, state.list.length));
+      return state.list.slice(state.start, end.value);
     });
 
+    const getTransform = () => {
+      if (actualContent.value) {
+        return `translate3d(0, ${state.start >= 1 ? state.cachePositions[state.start - 1].bottom : 0}px, 0)`;
+      }
+    };
+
+    const initCachedPosition = () => {
+      state.cachePositions = [];
+      for (let i = 0; i < state.list.length; ++i) {
+        state.cachePositions[i] = {
+          index: i,
+          height: props.height,
+          top: i * props.height,
+          bottom: (i + 1) * (props.height + props.margin),
+          dValue: 0
+        };
+      }
+    };
+
+    const updateCachedPosition = () => {
+      let nodes: any[] = actualContent.value.childNodes;
+      nodes = Array.from(nodes).filter((node: HTMLDivElement) => node.nodeType === 1);
+      const start = nodes[0];
+      nodes.forEach((node: HTMLDivElement, index: number) => {
+        if (!node) return;
+        const rect = useRect(node);
+        const { height } = rect;
+        const oldHeight = state.cachePositions[index + state.start].height;
+        const dValue = oldHeight - height;
+
+        if (dValue) {
+          state.cachePositions[index + state.start].bottom -= dValue;
+          state.cachePositions[index + state.start].height = height;
+          state.cachePositions[index + state.start].dValue = dValue;
+        }
+      });
+
+      let startIndex = 0;
+      if (start) {
+        startIndex = state.start;
+      }
+
+      const cachedPositionsLen = state.cachePositions.length;
+      let cumulativeDiffHeight = state.cachePositions[startIndex].dValue;
+      state.cachePositions[startIndex].dValue = 0;
+
+      for (let i = startIndex + 1; i < cachedPositionsLen; ++i) {
+        const item = state.cachePositions[i];
+
+        state.cachePositions[i].top = state.cachePositions[i - 1].bottom;
+        state.cachePositions[i].bottom = state.cachePositions[i].bottom - cumulativeDiffHeight;
+
+        if (item.dValue !== 0) {
+          cumulativeDiffHeight += item.dValue;
+          item.dValue = 0;
+        }
+      }
+
+      const height = state.cachePositions[cachedPositionsLen - 1].bottom;
+
+      state.phantomHeight = height;
+    };
+
+    const getStartIndex = (scrollTop = 0) => {
+      let idx = binarySearch<CachedPosition, number>(
+        state.cachePositions,
+        scrollTop,
+        (currentValue: CachedPosition, targetValue: number) => {
+          const currentCompareValue = currentValue.bottom;
+          if (currentCompareValue === targetValue) {
+            return CompareResult.eq;
+          }
+
+          if (currentCompareValue < targetValue) {
+            return CompareResult.lt;
+          }
+
+          return CompareResult.gt;
+        }
+      ) as number;
+
+      const targetItem = state.cachePositions[idx];
+
+      if (targetItem.bottom < scrollTop) {
+        idx += 1;
+      }
+
+      return idx;
+    };
+
+    const resetAllVirtualParam = () => {
+      state.originStartIndex = 0;
+      state.start = 0;
+      state.scrollTop = 0;
+      list.value.scrollTop = 0;
+      initCachedPosition();
+      state.phantomHeight = props.height * state.list.length;
+    };
+
     const handleScrollEvent = () => {
       const scrollTop = list.value?.scrollTop as number;
-      state.start = Math.floor(scrollTop / props.height);
-      if (end.value >= state.list.length) {
-        emit('scroll-bottom');
+      const { originStartIndex } = state;
+      const currentIndex = getStartIndex(scrollTop);
+      if (currentIndex !== originStartIndex) {
+        state.originStartIndex = currentIndex;
+        state.start = Math.max(state.originStartIndex - props.bufferSize, 0);
+        if (end.value >= state.list.length - 1) {
+          emit('scroll-bottom');
+        }
       }
-      state.startOffset = scrollTop - (scrollTop % props.height);
+      emit(scrollTop > state.scrollTop ? 'scroll-up' : 'scroll-down', scrollTop);
+      state.scrollTop = scrollTop;
     };
 
     watch(
       () => props.listData,
+      (val: any[]) => {
+        state.list = val.slice();
+        if (state.list.length === val.length) {
+          initCachedPosition();
+          updateCachedPosition();
+        } else {
+          resetAllVirtualParam();
+          return;
+        }
+      }
+    );
+
+    watch(
+      () => state.start,
       () => {
-        state.list = props.listData.slice();
+        if (actualContent.value && state.list.length > 0) {
+          updateCachedPosition();
+        }
       }
     );
 
     return {
       ...toRefs(state),
       list,
+      phantom,
+      actualContent,
       getTransform,
-      listHeight,
       visibleData,
       classes,
       getContainerHeight,

+ 37 - 0
src/packages/__VUE/list/type.ts

@@ -0,0 +1,37 @@
+export interface CachedPosition {
+  index: number;
+  top: number;
+  bottom: number;
+  height: number;
+  dValue: number;
+}
+
+export enum CompareResult {
+  eq = 1,
+  lt,
+  gt
+}
+
+export function binarySearch<T, VT>(list: T[], value: VT, compareFunc: (current: T, value: VT) => CompareResult) {
+  let start = 0;
+  let end = list.length - 1;
+  let tempIndex = null;
+
+  while (start <= end) {
+    tempIndex = Math.floor((start + end) / 2);
+    const midValue = list[tempIndex];
+
+    const compareRes: CompareResult = compareFunc(midValue, value);
+    if (compareRes === CompareResult.eq) {
+      return tempIndex;
+    }
+
+    if (compareRes === CompareResult.lt) {
+      start = tempIndex + 1;
+    } else if (compareRes === CompareResult.gt) {
+      end = tempIndex - 1;
+    }
+  }
+
+  return tempIndex;
+}