Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
b2e9340
feat: add name
william-xue Mar 3, 2025
dda43c3
feat: add function signature
william-xue Mar 3, 2025
960ad7b
fix: adjust css
william-xue Mar 3, 2025
46e4e7b
fix: adjust css
william-xue Mar 4, 2025
ca79f54
feat: signature finish
william-xue Mar 4, 2025
f4cf2a7
Merge branch 'varletjs:dev' into dev
william-xue Mar 4, 2025
cf7df45
feat: add test cases
william-xue Mar 4, 2025
b15d038
feat: add test cases
william-xue Mar 5, 2025
0796c74
feat: test cases
william-xue Mar 5, 2025
e8bf4ca
feat: resolve
william-xue Mar 9, 2025
600a11c
feat: fix
william-xue Mar 10, 2025
375c477
Merge branch 'varletjs:dev' into dev
william-xue Mar 10, 2025
bb63493
feat: test case
william-xue Mar 10, 2025
62895d4
feat: test case
william-xue Mar 10, 2025
bd7fe46
feat: expose empty
william-xue Mar 10, 2025
5b746cd
Merge branch 'varletjs:dev' into dev
william-xue Mar 11, 2025
8857fb8
feat: resolve
william-xue Mar 11, 2025
34158dc
refactor: optimize event handling with useEventListener hook
william-xue Mar 11, 2025
5cfc324
refactor: remove duplicate onMounted hook and clean up unnecessary code
william-xue Mar 11, 2025
f228328
refactor: add
william-xue Mar 11, 2025
faf5c08
Merge branch 'varletjs:dev' into dev
william-xue Mar 11, 2025
fd9a16c
refactor: add
william-xue Mar 11, 2025
f8ab947
refactor: add
william-xue Mar 11, 2025
c5ad7cd
feat: resolve comment
william-xue Mar 14, 2025
75682a1
feat: resolve comment
william-xue Mar 14, 2025
1268d01
feat: resolve comment
william-xue Mar 14, 2025
a52f477
feat: cleanup
haoziqaq Mar 15, 2025
01f61e7
feat: test cases
william-xue Mar 18, 2025
4e61cfb
feat: docs finish
william-xue Mar 18, 2025
d6811dd
feat: resolve comment
william-xue Mar 18, 2025
e71f0f3
feat: "resolve comment"
william-xue Mar 19, 2025
3e7c88f
refactor: cleanup
haoziqaq Mar 20, 2025
f50640e
types: fix types
haoziqaq Mar 20, 2025
8a6c042
types: fix types
haoziqaq Mar 20, 2025
05a17bd
Merge branch 'varletjs:dev' into dev
william-xue Mar 20, 2025
d150b65
Merge branch 'varletjs:dev' into dev
william-xue Jun 3, 2025
f71fde3
feat(virtual-list): 添加虚拟滚动列表组件及相关文档和示例
Jun 4, 2025
4c0ce3e
refactor(virtual-list): update comments and improve code readability
Jun 27, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
263 changes: 263 additions & 0 deletions packages/varlet-ui/src/virtual-list/VirtualList.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
<template>
<div ref="containerRef" :class="n()" :style="containerStyle" @scroll.passive="handleScroll">
<!-- Phantom layer for scroll area -->
<div :class="n('phantom')" :style="{ height: `${phantomHeight}px` }" />
<!-- Content layer for actual rendering -->
<div ref="contentRef" :class="n('content')" :style="{ transform: getTransform() }">
<div v-for="(item, index) in visibleData" :key="index" :class="n('item')">
<slot :item="item" :index="start + index" />
</div>
</div>
</div>
</template>

<script lang="ts">
import { computed, defineComponent, onMounted, reactive, ref, toRefs, watch } from 'vue'
import { call, toNumber } from '@varlet/shared'
import { createNamespace } from '../utils/components'
import { props } from './props'

const { name, n, classes } = createNamespace('virtual-list')

interface CachedPosition {
index: number
top: number
bottom: number
height: number
dValue: number
}

enum CompareResult {
eq = 1,
lt,
gt,
}

// Binary search for fast index lookup
function binarySearch<T, VT>(list: T[], value: VT, compareFunc: (current: T, value: VT) => CompareResult): number {
let start = 0
let end = list.length - 1
let tempIndex = 0

while (start <= end) {
tempIndex = Math.floor((start + end) / 2)
const midValue = list[tempIndex]
const compareRes = compareFunc(midValue, value)
if (compareRes === CompareResult.eq) {
return tempIndex
}
if (compareRes === CompareResult.lt) {
start = tempIndex + 1
} else {
end = tempIndex - 1
}
}

return tempIndex
}

export default defineComponent({
name,
props,
setup(props) {
// DOM refs
const containerRef = ref<HTMLElement | null>(null)
const contentRef = ref<HTMLElement | null>(null)

// Virtual scroll state
const state = reactive({
start: 0,
originStartIndex: 0,
scrollTop: 0,
cachePositions: [] as CachedPosition[],
phantomHeight: 0,
})

// Computed container style
const containerStyle = computed(() => {
if (props.containerHeight) {
return {
height: typeof props.containerHeight === 'number' ? `${props.containerHeight}px` : props.containerHeight,
Copy link
Member

@rzzf rzzf Jul 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

- height: typeof props.containerHeight === 'number' ? `${props.containerHeight}px` : props.containerHeight,
+ height: `${ toPxNum(props.containerHeight)}px `

使用 toPxNum import { toPxNum } from '../utils/elements'

}
}
return {}
})

// Number of visible items
const visibleCount = computed(() => {
if (!containerRef.value) {
return 0
}
return Math.ceil(containerRef.value.clientHeight / toNumber(props.itemHeight))
})

// End index (start + visible + buffer)
const end = computed(() => {
return Math.min(state.originStartIndex + visibleCount.value + props.bufferSize, props.data.length)
})

// Visible data
const visibleData = computed(() => {
return props.data.slice(state.start, end.value)
})

// Get transform style for content layer
const getTransform = () => {
return `translate3d(0, ${state.start >= 1 ? state.cachePositions[state.start - 1].bottom : 0}px, 0)`
}

// Initialize cached positions
function initCachedPosition() {
state.cachePositions = []
const itemHeight = toNumber(props.itemHeight)
for (let i = 0; i < props.data.length; ++i) {
state.cachePositions[i] = {
index: i,
height: itemHeight,
top: i * itemHeight,
bottom: (i + 1) * itemHeight,
dValue: 0,
}
}
state.phantomHeight = props.data.length * itemHeight
}

// Update cached positions based on actual DOM height
function updateCachedPosition() {
if (!contentRef.value) {
return
}
const nodes = Array.from(contentRef.value.childNodes).filter((node: Node) => node.nodeType === 1) as HTMLElement[]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

node.nodeType === Node.ELEMENT_NODE

nodes.forEach((node, index) => {
const height = node.offsetHeight
const pos = state.cachePositions[index + state.start]
const dValue = pos.height - height
if (dValue) {
pos.bottom -= dValue
pos.height = height
pos.dValue = dValue
}
})
// Update following positions
const startIndex = state.start
const len = state.cachePositions.length
let cumulativeDiffHeight = state.cachePositions[startIndex].dValue
state.cachePositions[startIndex].dValue = 0
for (let i = startIndex + 1; i < len; ++i) {
const prev = state.cachePositions[i - 1]
const curr = state.cachePositions[i]
curr.top = prev.bottom
curr.bottom = curr.bottom - cumulativeDiffHeight
if (curr.dValue !== 0) {
cumulativeDiffHeight += curr.dValue
curr.dValue = 0
}
}
// Update phantom height
state.phantomHeight = state.cachePositions[len - 1]?.bottom || 0
}

// Get start index for given scrollTop
function getStartIndex(scrollTop = 0) {
let idx = binarySearch<CachedPosition, number>(state.cachePositions, scrollTop, (current, target) => {
const val = current.bottom
if (val === target) {
return CompareResult.eq
}
if (val < target) {
return CompareResult.lt
}
return CompareResult.gt
})
const targetItem = state.cachePositions[idx]
if (targetItem && targetItem.bottom < scrollTop) {
idx += 1
}
return idx
}

// Reset all virtual scroll params
function resetAllVirtualParam() {
state.originStartIndex = 0
state.start = 0
state.scrollTop = 0
if (containerRef.value) {
containerRef.value.scrollTop = 0
}
initCachedPosition()
}

// Handle scroll event
function handleScroll(e: Event) {
if (!containerRef.value) {
return
}
const scrollTop = containerRef.value.scrollTop
const { originStartIndex } = state
const currentIndex = getStartIndex(scrollTop)
if (currentIndex !== originStartIndex) {
state.originStartIndex = currentIndex
state.start = Math.max(state.originStartIndex - props.bufferSize, 0)
}
state.scrollTop = scrollTop
call(props.onScroll, e)
}

// Scroll to specified index
function scrollTo(index: number) {
if (!containerRef.value || index < 0 || index >= props.data.length) {
return
}
if (state.cachePositions[index]) {
containerRef.value.scrollTop = state.cachePositions[index].top
} else {
const itemHeight = toNumber(props.itemHeight)
containerRef.value.scrollTop = index * itemHeight
}
}

// Watch data changes, reset virtual params
watch(
() => props.data,
(newVal, oldVal) => {
if (newVal.length !== oldVal.length) {
resetAllVirtualParam()
}
},
{ deep: true },
)

// Watch start index, update cached positions
watch(
() => state.start,
() => {
if (contentRef.value && props.data.length > 0) {
updateCachedPosition()
}
},
)

// Init cached positions on mount
onMounted(() => {
initCachedPosition()
})

return {
n,
classes,
containerRef,
contentRef,
containerStyle,
scrollTo,
handleScroll,
visibleData,
getTransform,
...toRefs(state),
}
},
})
</script>

<style lang="less">
@import './virtualList';
</style>
8 changes: 8 additions & 0 deletions packages/varlet-ui/src/virtual-list/__tests__/index.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { createApp } from 'vue'
import { expect, test } from 'vitest'
import VirtualList from '..'

test('virtual-list plugin', () => {
const app = createApp({}).use(VirtualList)
expect(app.component(VirtualList.name)).toBeTruthy()
})
Loading