feat(virtual-list): first implementation of virtual list (#790)

This commit is contained in:
jeremywu 2020-12-15 11:42:21 +08:00 committed by GitHub
parent 15f792de5f
commit d16fbb66e5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 480 additions and 3 deletions

View File

@ -82,6 +82,7 @@ import ElTooltip from '@element-plus/tooltip'
import ElTransfer from '@element-plus/transfer'
import ElTree from '@element-plus/tree'
import ElUpload from '@element-plus/upload'
import ElVirtualList from '@element-plus/virtual-list'
import { use } from '@element-plus/locale'
import { version as version_ } from './version'
import { setConfig } from '@element-plus/utils/config'
@ -175,6 +176,7 @@ const components = [
ElTransfer,
ElTree,
ElUpload,
ElVirtualList,
]
const plugins = [
@ -284,6 +286,7 @@ export {
ElTransfer,
ElTree,
ElUpload,
ElVirtualList,
version,
install,
locale,

View File

@ -3,7 +3,14 @@ import { sleep } from '@element-plus/test-utils'
const makeScroll = async (dom: Element, name: 'scrollTop' | 'scrollLeft', offset: number) => {
const eventTarget = dom === document.documentElement ? window : dom
dom[name] = offset
eventTarget.dispatchEvent(new CustomEvent('scroll'))
const evt = new CustomEvent('scroll', {
detail: {
target: {
[name]: offset,
},
},
})
eventTarget.dispatchEvent(evt)
// must use setTimeout instead of nextTick to wait dom change
return await sleep()
}

View File

@ -13,7 +13,10 @@ export default tick
export const rAF = async () => {
return new Promise(res => {
requestAnimationFrame(() => {
requestAnimationFrame(res)
requestAnimationFrame(async () => {
res()
await nextTick()
})
})
})
}

View File

@ -78,4 +78,5 @@
@import "./avatar.scss";
@import "./drawer.scss";
@import "./popconfirm.scss";
@import "./overlay.scss";
@import "./overlay.scss";
@import "./virtual-list.scss";

View File

@ -0,0 +1,27 @@
@import "mixins/mixins";
@import "common/var";
@include b(vl) {
@include e(viewport) {
overflow: auto;
}
@include e(content) {
overflow: hidden;
will-change: transform;
position: relative;
}
@include e(item-container) {
will-change: transform;
display: flex;
&[data-direction="v"] {
flex-direction: column;
}
&[data-direction="h"] {
flex-direction: row;
}
}
}

View File

@ -0,0 +1,156 @@
import { nextTick } from 'vue'
import makeMount from '@element-plus/test-utils/make-mount'
import makeScroll from '@element-plus/test-utils/make-scroll'
import { rAF } from '@element-plus/test-utils/tick'
import VirtualList from '../src/index.vue'
const containerSelector = '.el-vl__item-container'
const viewportSelector = '.el-vl__viewport'
const AXIOM = 'Rem is the best girl'
const mount = makeMount(VirtualList, {
props: {
windowSize: 300,
itemSize: 30,
data: Array
.from({ length: 100 })
.map((_, idx) => ({
id: idx,
})),
},
slots: {
default: () => AXIOM,
},
})
describe('VirtualList.vue', () => {
test('render test', async () => {
const wrapper = mount()
await nextTick()
expect(wrapper.text()).toContain(AXIOM)
expect(
wrapper
.find(containerSelector)
.attributes('data-direction'),
).toBe('v')
expect(
wrapper
.find(containerSelector)
.attributes('style'),
).toContain(
'transform: translateY(0px)',
)
await wrapper.setProps({
direction: 'h',
})
expect(
wrapper
.find(containerSelector)
.attributes('data-direction'),
).toBe('h')
expect(
wrapper
.find(containerSelector)
.attributes('style'),
).toContain(
'transform: translateX(0px)',
)
})
test('should render with cache', () => {
const wrapper = mount({
props: {
poolSize: 9,
},
})
// rendering item should be 9 + (9 / 3) * 2
expect(wrapper.findAll('.el-vl__item')).toHaveLength(15)
})
test('should handle scroll event', async () => {
const wrapper = mount()
await nextTick()
await makeScroll(
wrapper.find(viewportSelector).element,
'scrollTop',
90,
)
await rAF()
expect(
wrapper
.find(containerSelector)
.attributes('style'),
).toContain(
'transform: translateY(0px)',
)
await makeScroll(
wrapper.find(viewportSelector).element,
'scrollTop',
300,
)
await rAF()
// when scroll, the scroll is calculated with formula
// ((scrollTop / itemSize) - cache) * itemSize
// in this case:
// (300 / 30) - cache (20(poolSize) / 3 = 6) * itemSize(30)
// 120
expect(
wrapper
.find(containerSelector)
.attributes('style'),
).toContain(
'transform: translateY(120px)',
)
await nextTick()
await wrapper.setProps({
direction: 'h',
})
expect(
wrapper
.find(containerSelector)
.attributes('style'),
).toContain(
'transform: translateX(120px)',
)
})
test('should scroll to selected index', async () => {
const wrapper = mount()
await nextTick();
(wrapper.vm as any).scrollTo(10)
await rAF()
expect((wrapper.vm as any).window[0]).toEqual({
id: 4, // 10 - 6(cache size)
});
(wrapper.vm as any).scrollTo(10, 'center')
await nextTick()
await rAF()
expect((wrapper.vm as any).window[0]).toEqual({
id: 0,
});
(wrapper.vm as any).scrollTo(20, 'tail')
await nextTick()
await rAF()
expect((wrapper.vm as any).window[0]).toEqual({
id: 5,
})
})
})

View File

@ -0,0 +1,8 @@
import { App } from 'vue'
import VirtualList from './src/index.vue'
VirtualList.install = (app: App): void => {
app.component(VirtualList.name, VirtualList)
}
export default VirtualList

View File

@ -0,0 +1,12 @@
{
"name": "@element-plus/virtual-list",
"version": "0.0.0",
"main": "dist/index.js",
"license": "MIT",
"peerDependencies": {
"vue": "^3.0.3"
},
"devDependencies": {
"@vue/test-utils": "^2.0.0-beta.3"
}
}

View File

@ -0,0 +1,74 @@
<template>
<div
ref="viewportRef"
class="el-vl__viewport"
:style="viewportStyle"
@scroll.passive="onScroll"
>
<div class="el-vl__content" :style="contentStyle">
<div
class="el-vl__item-container"
:style="itemContainerStyle"
:data-direction="direction"
>
<el-virtual-list-item
v-for="(item, idx) in window"
:key="idx"
class="el-vl__item"
:style="itemStyle"
>
<slot :item="item"></slot>
</el-virtual-list-item>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import useVirtualScroll from './useVirtualScroll'
import VirtualItem from './virtual-item.vue'
import type { PropType } from 'vue'
import type { Direction } from './useVirtualScroll'
export default defineComponent({
name: 'ElVirtualList',
components: {
[VirtualItem.name]: VirtualItem,
},
props: {
direction: {
type: String as PropType<Direction>,
default: 'v',
},
data: {
type: Array as PropType<Array<any>>,
required: true,
},
itemSize: {
type: Number,
required: true,
},
windowSize: {
type: Number,
required: true,
},
poolSize: {
type: Number,
default: 20,
},
},
setup(props) {
// init here
const api = useVirtualScroll(props)
// onMounted(() => {
// })
return api
},
})
</script>
<style scoped></style>

View File

@ -0,0 +1,150 @@
import { ref, computed, watch } from 'vue'
import isServer from '@element-plus/utils/isServer'
import { $ } from '@element-plus/utils/util'
import throwError from '@element-plus/utils/error'
export type Direction = 'h' | 'v'
export type Alignment = 'head' | 'center' | 'tail'
export interface ElVirtualScrollProps<T> {
windowSize: number
direction: Direction // h stands for horizontal, v stands for vertical, defaults to vertical
data: Array<T>
itemSize: number
poolSize: number
}
export default function useVirtualScroll<T>(props: ElVirtualScrollProps<T>) {
const viewportRef = ref<HTMLElement>()
const offset = ref(0)
const cache = ref(0)
// the reason of not using computed here is that, keys are accessed frequently
// in order to avoid unnecessary overhead, we decided to use watch the `direction`
// because direction won't gets changed very frequently.
const isVertical = ref(true)
const sizeKey = ref('')
const scrollKey = ref('')
const translateKey = ref()
const styleKey = ref('')
watch(
() => props.direction,
dir => {
const _isVertical = dir === 'v'
isVertical.value = _isVertical
sizeKey.value = `client${_isVertical ? 'Height' : 'Width'}`
scrollKey.value = `scroll${_isVertical ? 'Top' : 'Left'}`
translateKey.value = `${_isVertical ? 'Y' : 'X'}`
styleKey.value = `${_isVertical ? 'height' : 'width'}`
},
{
immediate: true,
},
)
watch(
() => props.poolSize,
val => {
cache.value = Math.floor(val / 3)
},
{
immediate: true,
},
)
const renderingItems = computed(() => props.poolSize + 2 * $(cache))
// offset sets the value of
const startNode = computed(() => {
return Math.max(0, Math.floor($(offset) / props.itemSize) - $(cache))
})
const viewportStyle = computed(() => {
return {
[$(styleKey)]: `${props.windowSize}px`,
}
})
const contentStyle = computed(() => {
// make this dynamic
return {
[$(styleKey)]: `${props.data.length * props.itemSize}px`,
}
})
const itemContainerStyle = computed(() => {
const _offset = $(startNode) * props.itemSize
return {
transform: `translate${$(translateKey)}(${_offset}px)`,
}
})
const itemStyle = computed(() => {
return {
[$(styleKey)]: `${props.itemSize}px`,
}
})
let animationHandle = null
const onScroll = (e: Event) => {
if (animationHandle) {
cancelAnimationFrame(animationHandle)
}
animationHandle = requestAnimationFrame(() => {
offset.value = (e.target as HTMLElement)[$(scrollKey)]
})
}
const window = computed(() => {
const startNodeVal = $(startNode)
const size = Math.min(props.data.length - startNodeVal, $(renderingItems))
return props.data.slice(startNodeVal, startNodeVal + size)
})
const scrollTo = (idx: number, alignment: Alignment = 'head') => {
if (isServer) return
if (idx < 0 || idx > props.data.length) {
throwError('ElVirtualList]', 'Out of list range')
}
let _offset: number
switch (alignment) {
case 'head': {
_offset = idx * props.itemSize
break
}
case 'center': {
_offset =
(idx -
Math.floor(Math.floor(props.windowSize / props.itemSize) / 2)) *
props.itemSize
break
}
case 'tail': {
_offset =
(idx - Math.floor(props.windowSize / props.itemSize) + 1) * props.itemSize
break
}
default: {
throwError('[ElVirtualList]', 'Unsupported alignment')
}
}
requestAnimationFrame(() => {
offset.value = _offset
viewportRef.value[$(scrollKey)] = _offset
})
}
return {
viewportRef,
contentStyle,
itemContainerStyle,
itemStyle,
viewportStyle,
startNode,
renderingItems,
window,
onScroll,
scrollTo,
}
}

View File

@ -0,0 +1,36 @@
<template>
<div ref="itemRef" class="el-vl__item">
<slot>
</slot>
</div>
</template>
<script lang='ts'>
import { defineComponent, onMounted, onUpdated, ref } from 'vue'
// import type { PropType } from 'vue'
// import type { Direction } from './useVirtualScroll'
export default defineComponent({
name: 'ElVirtualListItem',
props: {
},
setup() {
// init here
const itemRef = ref<HTMLElement>()
onMounted(() => {
// console.log('mounted')
})
onUpdated(() => {
// console.log(itemRef.value)
})
return {
itemRef,
}
},
})
</script>
<style scoped>
</style>