Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

feat: 增加虚拟列表、结构重构优化 #17

Merged
merged 2 commits into from
Jun 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
297 changes: 297 additions & 0 deletions src/components/VirtualList/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
import {
defineComponent,
onActivated,
onBeforeMount,
onMounted,
onUnmounted,
ref,
watch
} from 'vue'
import Virtual from './virtual'
import Item from './item'

interface Range {
start: number
end: number
padFront: number
padBehind: number
}

interface DataSource {
[key: string]: any;
}

export default defineComponent({
name: 'VirtualList',
props: {
// 数据
data: {
type: Array,
required: true,
default: () => [],
},
// 唯一标识键值
dataKey: {
type: [String, Function],
required: true,
},
// 数据项组件
item: {
type: [Object, Function],
required: true,
},
// 可视区域内保留的数据项个数
keeps: {
type: Number,
default: 30,
},
size: {
type: Number,
default: 50,
},
// 起始索引-用来指定默认从哪里开始渲染
start: {
type: Number,
default: 0,
},
// 偏移量
offset: {
type: Number,
default: 0,
},
// 顶部触发阈值
topThreshold: {
type: Number,
default: 0,
},
// 底部触发阈值
bottomThreshold: {
type: Number,
default: 0,
},
},
setup(props, { emit, expose }) {
const range = ref<Range | null>(null)
const rootRef = ref<HTMLElement | null>()
const shepherd = ref<HTMLDivElement | null>(null)
let virtual: Virtual

// 监听数据数组长度变化 更新数据
watch(
() => props.data.length,
() => {
virtual.updateParam('uniqueIds', getUniqueIdFromDataSources())
virtual.handleDataSourcesChange()
},
)
watch(
() => props.keeps,
(newValue) => {
virtual.updateParam('keeps', newValue)
virtual.handleSlotSizeChange()
},
)
watch(
() => props.start,
(newValue) => {
scrollToIndex(newValue)
},
)
watch(
() => props.offset,
(newValue) => scrollToOffset(newValue),
)

// 根据id获取数据项大小
const getSize = (id: string) => {
return virtual.sizes.get(id)
}
// 获取滚动条偏移量
const getOffset = () => {
return rootRef.value ? Math.ceil(rootRef.value.scrollTop) : 0
}
// 获取可视区域大小
const getClientSize = () => {
const key = 'clientHeight'
return rootRef.value ? Math.ceil(rootRef.value[key]) : 0
}
// 获取滚动条总高度
const getScrollSize = () => {
const key = 'scrollHeight'
return rootRef.value ? Math.ceil(rootRef.value[key]) : 0
}

// 统一处理向外暴露事件
const emitEvent = (offset:number, clientSize:number, scrollSize :number) => {
emit('scroll', {offset, clientSize, scrollSize})

if (virtual.isFront() && !!props.data.length && offset - props.topThreshold <= 0) {
emit('totop')
} else if (virtual.isBehind() && offset + clientSize + props.bottomThreshold >= scrollSize) {
emit('tobottom')
}
}
const onScroll = () => {
const offset = getOffset()
const clientSize = getClientSize()
const scrollSize = getScrollSize()
if (offset < 0 || offset + clientSize > scrollSize + 1 || !scrollSize) {
return
}

virtual.handleScroll(offset)
emitEvent(offset, clientSize, scrollSize)
}

// 获取数据源中的唯一标识
const getUniqueIdFromDataSources = () => {
const { dataKey, data = [] } = props
// 如果dataKey是函数 则调用传入的函数执行获取唯一标识
return data.map((dataSource: any) =>
typeof dataKey === 'function' ? dataKey(dataSource) : dataSource[dataKey],
)
}
const onRangeChanged = (newRange: any) => {
range.value = newRange
}
/**
* 初始化一个virtual实例
* @description 详细参数见virtual.ts
*/
const installVirtual = () => {
virtual = new Virtual(
{
slotHeaderSize: 0,
slotFooterSize: 0,
keeps: props.keeps,
estimateSize: props.size,
buffer: Math.round(props.keeps / 3),
uniqueIds: getUniqueIdFromDataSources(),
},
onRangeChanged,
)

range.value = virtual.getRange()
}

/**
* 滚动到指定索引
* @param index 索引值
* @description 如果索引值大于等于数据长度说明到底了则滚动到底部
*/
const scrollToIndex = (index: number) => {
if (index >= props.data.length - 1) {
scrollToBottom()
} else {
const offset = virtual.getOffset(index)
scrollToOffset(offset)
}
}

/**
* 滚动到指定偏移量
* @param offset 滚动条偏移量
*/
const scrollToOffset = (offset: number) => {
if (rootRef.value) {
rootRef.value.scrollTop = offset
}
}

/**
* 渲染插槽列表-(重点函数)
* @returns {VNode[]} 插槽列表
*/
const getRenderSlots = () => {
const slots = []
const { start, end } = range.value! // 解构获取范围的起始、结束索引
const { data, dataKey, item } = props
for (let index = start; index <= end; index++) {
const dataSource = data[index] as DataSource // 获取当前索引的数据项
if (dataSource) {
// 取这个项里面的唯一标识拿来做key
const uniqueKey = typeof dataKey === 'function' ? dataKey(dataSource) : dataSource[dataKey]
// 如果唯一标识是字符串或者数字则渲染
if (typeof uniqueKey === 'string' || typeof uniqueKey === 'number') {
slots.push(
<Item
index={index}
uniqueKey={uniqueKey}
source={dataSource}
component={item}
onItemResize={onItemResized}
/>,
)
}
}
}
return slots
}

// 数据项大小改变时触发
const onItemResized = (id: string, size: number) => {
virtual.saveSize(id, size)
emit('resized', id, size)
}

// 滚动到底部
const scrollToBottom = () => {
if (shepherd.value) {
const offset = shepherd.value.offsetTop
scrollToOffset(offset)
setTimeout(() => {
if (getOffset() + getClientSize() < getScrollSize()) {
scrollToBottom()
}
}, 3)
}
}

const getSizes = () => {
return virtual.sizes.size
}

onBeforeMount(() => {
installVirtual()
})

onActivated(() => {
scrollToOffset(virtual.offset)
})

onMounted(() => {
if (props.start) {
scrollToIndex(props.start)
} else if (props.offset) {
scrollToOffset(props.offset)
}
})

onUnmounted(() => {
virtual.destroy()
})

expose({
scrollToBottom,
getSizes,
getSize,
getOffset,
getScrollSize,
getClientSize,
scrollToOffset,
scrollToIndex,
})

return () => {
const { padFront, padBehind } = range.value!
return (
<div ref={rootRef} onScroll={onScroll}>
<div style={{padding: `${padFront}px 0px ${padBehind}px`}}>
{getRenderSlots()}
</div>
<div ref={shepherd} style={{ width: '100%', height: '0px' }}/>
</div>
)
}
},
})
79 changes: 79 additions & 0 deletions src/components/VirtualList/item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* 这个文件负责渲染出传入自定义组件项
* 主要作用和逻辑:
* 1. 监听元素尺寸变化 - (耗性能可以考虑在高度固定的情况下不监听)
* 2. 通知父组件当前项的高度-(这样做的目的是解决聊天信息这样不固定高度项的)
* 3. 渲染出传入自定义组件项
*/
import { defineComponent, onMounted, onUnmounted, onUpdated, ref } from 'vue'
export default defineComponent({
name: 'VirtualListItem',
props: {
// 下标
index: {
type: Number,
},
// 数据源
source: {
type: Object,
},
// 数据项组件
component: {
type: [Object, Function],
},
// 唯一标识键值
uniqueKey: {
type: [String, Number],
},
},
emits: ['itemResize'],
setup(props, { emit }) {
const rootRef = ref<HTMLElement | null>(null) // 根节点
// ResizeObserver实例 参考文档:https://developer.mozilla.org/zh-CN/docs/Web/API/ResizeObserver
let resizeObserver: ResizeObserver | null = null

/**
* 尺寸变化事件
* @description: 通知父组件当前项的高度
*/
const dispatchSizeChange = () => {
const { uniqueKey } = props
const size = rootRef.value ? rootRef.value.offsetHeight : 0 // 当前项的高度
emit('itemResize', uniqueKey, size)
}

onMounted(() => {
if (typeof ResizeObserver !== 'undefined') {
// 监听元素尺寸变化
resizeObserver = new ResizeObserver(() => {
dispatchSizeChange()
})
// 创建观察者实例并传入回调函数
rootRef.value && resizeObserver.observe(rootRef.value as HTMLElement)
}
})

onUpdated(() => {
dispatchSizeChange()
})

// 组件卸载时关闭观察者监听实例
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver = null
}
})

return () => {
const { component: Comp, index, source, uniqueKey } = props
// 渲染出传入自定义组件项-这里的Comp就是传入的自定义组件 (ts-ignore避免类型警告)
return (
<div key={uniqueKey} ref={rootRef}>
{/* @ts-ignore */}
<Comp {...{source,index}} />
</div>
)
}
},
})
Loading