Skip to content

VirtualScroller 虚拟滚动

比虚拟列表有更好的渲染性能。

组件注册

js
import { FVirtualScroller } from 'fes-design';

app.use(FVirtualScroller);

代码演示

不规则纵向滚动

play
<template>
    <FVirtualScroller
        ref="virtualList"
        class="virtual-scroll-list-vertical"
        :dataSources="dataItems"
        :itemSize="80"
        :height="height"
    >
        <template #default="{ source, index }">
            <div :data-index="index" class="item-inner">
                <div class="head">
                    <span># {{ source.index }}</span>
                    <span>{{ source.name }}</span>
                </div>
                <div class="desc">{{ source.desc }}</div>
            </div>
        </template>
    </FVirtualScroller>
    <FButton style="margin-top: 10px;" @click="addMessage">添加消息{{ dataItems.length }}</FButton>
</template>

<script>
import { ref } from 'vue';

export default {
    name: 'Vertical',
    setup() {
        const virtualList = ref(null);
        const height = ref(400);
        const sentence3 = [
            'BFC(Block formatting context)直译为"块级格式化上下文"。它是一个独立的渲染区域,只有Block-level box参与, 它规定了内部的Block-level Box如何布局,并且与这个区域外部毫不相干。',
            'IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)',
            'margin 重合,margin 塌陷',
            'css3',
            'html5IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)',
            'es6',
        ];
        const genUniqueId = (prefix) => {
            return `${prefix}$${Math.random().toString(16).substr(9)}`;
        };
        const getSentences = () => {
            const index = Math.floor(Math.random() * (sentence3.length - 1));
            return sentence3[index];
        };
        const TOTAL_COUNT = 1000;
        const dataItems = ref([]);
        let count = TOTAL_COUNT;
        while (count--) {
            const index = TOTAL_COUNT - count;
            dataItems.value.push({
                index,
                name: `${Math.random()}`,
                id: genUniqueId(index),
                desc: getSentences(),
            });
        }
        const addMessage = () => {
            const index = dataItems.value.length + 1;
            dataItems.value = [...dataItems.value, {
                index,
                name: `${Math.random()}`,
                id: genUniqueId(index),
                desc: getSentences(),
            }];
            virtualList.value.scrollToBottom();
        };
        return {
            virtualList,
            dataItems,
            height,
            addMessage,
        };
    },
};
</script>

<style scoped>
.virtual-scroll-list-vertical .item-inner .head {
    font-weight: 500;
}
.virtual-scroll-list-vertical .item-inner .head span:first-child {
    margin-right: 1em;
}
.virtual-scroll-list-vertical .item-inner .desc {
    margin-top: 0.5em;
    margin-bottom: 1em;
    text-align: justify;
}
</style>

不规则横向滚动

play
<template>
    <FVirtualScroller
        class="virtual-scroll-list-horizontal"
        :dataSources="items"
        :itemSize="110"
        direction="horizontal"
    >
        <template #default="{ source }">
            <div
                class="item-inner-horizontal"
                :style="{ width: `${source.size}px` }"
            >
                <div class="index"># {{ source.index }}</div>
                <div class="size">{{ source.size }}</div>
            </div>
        </template>
    </FVirtualScroller>
</template>

<script>
const TOTAL_COUNT = 100;
const sizes = [60, 80, 100, 150, 180];

const genUniqueId = (prefix) => {
    return `${prefix}$${Math.random().toString(16).substr(9)}`;
};

const dataItems = [];
let count = TOTAL_COUNT;
while (count--) {
    const index = TOTAL_COUNT - count;
    dataItems.push({
        index,
        id: genUniqueId(index),
        size: sizes[Math.floor(Math.random() * 5)],
    });
}

export default {
    name: 'Horizontal',
    setup() {
        return {
            items: dataItems,
        };
    },
};
</script>

<style scoped>
.virtual-scroll-list-horizontal {
    width: 100%;
    height: 120px;
}
.virtual-scroll-list-horizontal .item-inner-horizontal {
    display: flex;
    align-items: center;
    flex-direction: column;
    padding: 2em 0;
}
.virtual-scroll-list-horizontal .item-inner-horizontal .index {
    width: 100%;
    text-align: center;
}
.virtual-scroll-list-horizontal .item-inner-horizontal .size {
    text-align: right;
    color: darkgray;
    font-size: 16px;
}
</style>

无限滚动

play
<template>
    <FVirtualScroller
        ref="virtualList"
        class="virtual-scroll-list-vertical"
        :dataSources="dataItems"
        :itemSize="80"
        :scrollbarProps="{ height }"
        @toTop="handleToTop"
        @toBottom="handleToBottom"
    >
        <template #default="{ source, index }">
            <div :data-index="index" class="item-inner">
                <div class="head">
                    <span># {{ source.index }}</span>
                    <span>{{ source.name }}</span>
                </div>
                <div class="desc">{{ source.desc }}</div>
            </div>
        </template>
    </FVirtualScroller>
</template>

<script>
import { ref } from 'vue';

export default {
    name: 'Vertical',
    setup() {
        const virtualList = ref(null);
        const height = ref(400);
        const sentence3 = [
            'BFC(Block formatting context)直译为"块级格式化上下文"。它是一个独立的渲染区域,只有Block-level box参与, 它规定了内部的Block-level Box如何布局,并且与这个区域外部毫不相干。',
            'IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)',
            'margin 重合,margin 塌陷',
            'html5IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)',
        ];
        const genUniqueId = (prefix) => {
            return `${prefix}$${Math.random().toString(16).substr(9)}`;
        };
        const getSentences = () => {
            const index = Math.floor(Math.random() * (sentence3.length - 1));
            return sentence3[index];
        };
        const TOTAL_COUNT = 1000;
        const dataItems = ref([]);

        const createData = (length, startIndex, isAdd = true) => {
            const result = [];
            let count = length;
            while (count--) {
                const index = isAdd ? startIndex + length - count - 1 : startIndex - count - 1;
                result.push({
                    index,
                    name: `${Math.random()}`,
                    id: genUniqueId(index),
                    desc: getSentences(),
                });
            }
            return result;
        };

        dataItems.value = createData(TOTAL_COUNT, 1);

        const handleToBottom = () => {
            dataItems.value = [...dataItems.value, ...createData(10, dataItems.value[dataItems.value.length - 1].index + 1)];
        };
        const handleToTop = () => {
            dataItems.value = [...createData(10, dataItems.value[0].index, false), ...dataItems.value];
            setTimeout(() => {
                virtualList.value.scrollToIndex(10);
            }, 0);
        };
        return {
            virtualList,
            dataItems,
            height,
            handleToBottom,
            handleToTop,
        };
    },
};
</script>

<style scoped>
.virtual-scroll-list-vertical .item-inner .head {
    font-weight: 500;
}
.virtual-scroll-list-vertical .item-inner .head span:first-child {
    margin-right: 1em;
}
.virtual-scroll-list-vertical .item-inner .desc {
    margin-top: 0.5em;
    margin-bottom: 1em;
    text-align: justify;
}
</style>

滚动操作

play
<template>
    <FForm :labelWidth="180">
        <FFormItem label="触发 toTop 事件阈值:">
            <FInputNumber
                v-model="topThreshold"
                :min="0"
                :max="50"
                :step="10"
            />
            <span style="margin-left: 10px">px</span>
        </FFormItem>
        <FFormItem label="触发 toBottom 事件阈值:">
            <FInputNumber
                v-model="bottomThreshold"
                :min="0"
                :max="50"
                :step="10"
            />
            <span style="margin-left: 10px">px</span>
        </FFormItem>

        <FSpace>
            <FButton @click="handleScrollToBottom">滚动到底部位置</FButton>
            <FButton @click="handleScrollToIndex">滚动到指定索引</FButton>
            <FButton @click="handleScrollToOffset">
                滚动到相对指定偏移量
            </FButton>
        </FSpace>
    </FForm>

    <FDivider />

    <FVirtualScroller
        ref="virtualList"
        class="virtual-scroll-list-scroll"
        wrapClass="virtual-scroll-list-wrap"
        :dataSources="dataItems"
        :itemSize="100"
        :scrollbarProps="{ height: 200 }"
        :topThreshold="topThreshold"
        :bottomThreshold="bottomThreshold"
        @scroll="handleScroll"
        @toTop="handleToTop"
        @toBottom="handleToBottom"
        @resized="handleResized"
    >
        <template #default="{ source }">
            <div class="item-inner">
                <div class="head">
                    <span># {{ source.index }}</span>
                    <span>{{ source.name }}</span>
                </div>
                <div class="desc">{{ source.desc }}</div>
            </div>
        </template>
    </FVirtualScroller>

    <FDivider />

    <FSpace vertical>
        <span>第50项高度: {{ getSize }}</span>
        <span>当前滚动偏移量: {{ getOffset }}</span>
        <span>容器高度: {{ getClientSize }}</span>
        <span>滚动高度: {{ getScrollSize }}</span>
    </FSpace>
</template>

<script>
import { ref } from 'vue';
import { debounce } from 'lodash-es';

function useDataItems() {
    // The Climb (From Miley Cyrus)
    const sentence3 = [
        'BFC(Block formatting context)直译为"块级格式化上下文"。它是一个独立的渲染区域,只有Block-level box参与, 它规定了内部的Block-level Box如何布局,并且与这个区域外部毫不相干。',
        'IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)',
        'margin 重合,margin 塌陷',
        'css3',
        'html5IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)IFC(Inline Formatting Contexts)直译为”内联格式化上下文”,IFC的line box(线框)高度由其包含行内元素中最高的实际高度计算而来(不受到竖直方向的padding/margin影响)',
        'es6',
    ];

    const TOTAL_COUNT = 1000;

    const genUniqueId = (prefix) => {
        return `${prefix}$${Math.random().toString(16).substr(9)}`;
    };

    const getSentences = () => {
        const index = Math.floor(Math.random() * (sentence3.length - 1));
        return sentence3[index];
    };

    const dataItems = ref([]);
    let count = TOTAL_COUNT;
    while (count--) {
        const index = TOTAL_COUNT - count;
        dataItems.value.push({
            index,
            name: `${Math.random()}`,
            id: genUniqueId(index),
            desc: getSentences(),
        });
    }

    return dataItems;
}

export default {
    setup() {
        const virtualList = ref(null);
        const topThreshold = ref(20);
        const bottomThreshold = ref(20);
        const getSize = ref();
        const getOffset = ref();
        const getClientSize = ref();
        const getScrollSize = ref();

        const dataItems = useDataItems();

        function updateSize() {
            getSize.value = virtualList.value.getItemSize(49);
            getOffset.value = virtualList.value.getOffset();
            getClientSize.value = virtualList.value.getClientSize();
            getScrollSize.value = virtualList.value.getScrollSize();
        }

        const handleScroll = debounce(() => {
            updateSize();
        }, 100);
        const handleToTop = () => {
            console.log('[virtualList.scroll] [toTop]');
        };
        const handleToBottom = () => {
            console.log('[virtualList.scroll] [toBottom]');
        };
        const handleResized = debounce((id, size) => {
            console.log(
                '[virtualList.scroll] [resized] id:',
                id,
                ' size:',
                size,
            );
            updateSize();
        }, 100);

        const handleScrollToBottom = () => {
            virtualList.value.scrollToBottom();
        };
        const handleScrollToIndex = () => {
            virtualList.value.scrollToIndex(50);
        };
        const handleScrollToOffset = () => {
            virtualList.value.scrollBy(-50);
        };

        return {
            virtualList,
            topThreshold,
            bottomThreshold,
            dataItems,
            handleScroll,
            handleToTop,
            handleToBottom,
            handleResized,

            handleScrollToBottom,
            handleScrollToIndex,
            handleScrollToOffset,

            getSize,
            getOffset,
            getClientSize,
            getScrollSize,
        };
    },
};
</script>

<style>
.virtual-scroll-list-scroll .virtual-scroll-list-wrap {
    width: 1000px;
}
.virtual-scroll-list-scroll .item-inner .head {
    font-weight: 500;
}
.virtual-scroll-list-scroll .item-inner .head span:first-child {
    margin-right: 1em;
}
.virtual-scroll-list-scroll .item-inner .desc {
    margin-top: 0.5em;
    margin-bottom: 1em;
    text-align: justify;
}
</style>

VirtualScroller Props

属性说明类型默认值
dataSources为列表生成的源数组,每个数组数据必须是一个对象Array<Object>-
keeps您期望虚拟列表在真实 dom 中保持渲染的项目数量。number30
itemSize每项的估计大小,如果它更接近平均大小,滚动条长度看起来更准确。建议指定自己计算的平均值number50
itemTag子项的包裹元素numberdiv
itemProps子项包裹元素的属性function({ item, index }: { item: ItemData; index: number }) => any
direction滚动的方向, 可选值为 verticalhorizontalstringvertical
wrapTag列表包裹元素名称stringdiv
wrapClass列表包裹元素类名string-
wrapStyle列表包裹元素内联样式object{}
topThreshold触发toTop 事件的阈值number0
bottomThreshold触发toBottom 事件的阈值number0
scrollbarProps滚动条样式,参考滚动条组件object-

VirtualScroller Events

事件名称说明回调参数
scroll滚动时触发(event: Event, range) => void
toTop当滚动到顶部或者左边时触发() => void
toBottom当滚动到底部或者右边时触发,无参数() => void

VirtualScroller Methods

名称说明参数
scrollRef滚动条-
scrollToIndex手动将滚动位置设置为指定索引() => void
scrollToBottom手动将滚动位置设置到最底部(index: number) => void
scrollBy手动将滚动位置设置为相对指定偏移量(offset: number) => void
scrollTo手动将滚动位置设置为指定偏移量(offset: number) => void
getItemOffset获取选项位置(index: number) => number
getItemSize获取选项大小(index: number) => number
getOffset获取当前滚动偏移量() => number
getClientSize获取包装器元素客户端视口大小(宽度或高度)() => number
getScrollSize获取所有滚动大小(滚动高度或滚动宽度)() => number
findStartIndex获取显示的初始元素索引() => number
findEndIndex获取显示的结束元素索引() => number