Skip to content

Commit e4f3cf5

Browse files
committed
v1.4.3 - 垂直虚拟加载优化
1 parent a87f006 commit e4f3cf5

File tree

3 files changed

+154
-12
lines changed

3 files changed

+154
-12
lines changed

src/components/TaskList.vue

Lines changed: 107 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,20 @@ const { t } = useI18n()
3535
3636
// TaskList 容器引用
3737
const taskListRef = ref<HTMLElement | null>(null)
38+
const taskListBodyRef = ref<HTMLElement | null>(null)
3839
3940
// 缓存容器宽度,避免频繁读取 offsetWidth 导致强制重排
4041
const cachedContainerWidth = ref(0)
4142
4243
// 使用 ResizeObserver 监听容器宽度变化
4344
let containerResizeObserver: ResizeObserver | null = null
45+
let bodyResizeObserver: ResizeObserver | null = null
46+
47+
// 纵向虚拟滚动相关状态
48+
const ROW_HEIGHT = 51 // 每行高度(与TaskList Row一致)
49+
const VERTICAL_BUFFER = 5 // 上下额外渲染的缓冲行数
50+
const taskListScrollTop = ref(0)
51+
const taskListBodyHeight = ref(0)
4452
4553
// 获取列宽度样式(百分比转像素)
4654
const getColumnWidthStyle = (column: { width?: number | string }) => {
@@ -242,6 +250,61 @@ const getAllTasks = (taskList: Task[]): Task[] => {
242250
return allTasks
243251
}
244252
253+
// 获取当前折叠状态下的可见任务列表
254+
const getFlattenedVisibleTasks = (
255+
taskList: Task[],
256+
level = 0,
257+
): Array<{ task: Task; level: number }> => {
258+
const result: Array<{ task: Task; level: number }> = []
259+
260+
for (const task of taskList) {
261+
result.push({ task, level })
262+
263+
const isMilestoneGroup = task.type === 'milestone-group'
264+
265+
if (!isMilestoneGroup && task.children && task.children.length > 0 && !task.collapsed) {
266+
result.push(...getFlattenedVisibleTasks(task.children, level + 1))
267+
}
268+
}
269+
270+
return result
271+
}
272+
273+
// 扁平化后的可见任务列表
274+
const flattenedTasks = computed(() => getFlattenedVisibleTasks(localTasks.value))
275+
276+
// 计算可视区域任务范围
277+
const visibleTaskRange = computed(() => {
278+
const scrollTop = taskListScrollTop.value
279+
const containerHeight = taskListBodyHeight.value || 600
280+
281+
const startIndex = Math.floor(scrollTop / ROW_HEIGHT) - VERTICAL_BUFFER
282+
const endIndex = Math.ceil((scrollTop + containerHeight) / ROW_HEIGHT) + VERTICAL_BUFFER
283+
284+
const total = flattenedTasks.value.length
285+
const clampedStart = Math.min(Math.max(0, startIndex), total)
286+
const clampedEnd = Math.min(total, Math.max(clampedStart + 1, endIndex))
287+
288+
return {
289+
startIndex: clampedStart,
290+
endIndex: clampedEnd,
291+
}
292+
})
293+
294+
// 虚拟列表中需要渲染的任务
295+
const visibleTasks = computed(() => {
296+
const { startIndex, endIndex } = visibleTaskRange.value
297+
return flattenedTasks.value.slice(startIndex, endIndex)
298+
})
299+
300+
// Spacer 高度用于撑起滚动区域
301+
const totalContentHeight = computed(() => flattenedTasks.value.length * ROW_HEIGHT)
302+
const startSpacerHeight = computed(() => visibleTaskRange.value.startIndex * ROW_HEIGHT)
303+
const endSpacerHeight = computed(() => {
304+
const visibleHeight = visibleTasks.value.length * ROW_HEIGHT
305+
return Math.max(0, totalContentHeight.value - startSpacerHeight.value - visibleHeight)
306+
})
307+
245308
// 监听外部传入的 tasks 数据变化
246309
watch(
247310
() => props.tasks,
@@ -345,16 +408,23 @@ const handleTaskListScroll = (event: Event) => {
345408
346409
const scrollTop = target.scrollTop
347410
411+
taskListScrollTop.value = scrollTop
412+
348413
// 同步垂直滚动到Timeline
349414
window.dispatchEvent(
350415
new CustomEvent('task-list-vertical-scroll', {
351416
detail: { scrollTop },
352417
}),
353418
)
354-
} // 处理Timeline垂直滚动同步
419+
}
420+
421+
// 处理Timeline垂直滚动同步
355422
const handleTimelineVerticalScroll = (event: CustomEvent) => {
356423
const { scrollTop } = event.detail
357-
const taskListBodyElement = document.querySelector('.task-list-body') as HTMLElement
424+
const taskListBodyElement = taskListBodyRef.value
425+
426+
taskListScrollTop.value = scrollTop
427+
358428
if (taskListBodyElement && Math.abs(taskListBodyElement.scrollTop - scrollTop) > 1) {
359429
// 使用更精确的比较,避免1px以内的细微差异导致的循环触发
360430
taskListBodyElement.scrollTop = scrollTop
@@ -372,6 +442,7 @@ const handleTaskRowContextMenu = (event: { task: Task; position: { x: number; y:
372442
}),
373443
)
374444
} catch (error) {
445+
// eslint-disable-next-line no-console
375446
console.error('TaskList - Failed to dispatch context-menu event', error)
376447
}
377448
}
@@ -417,6 +488,20 @@ onMounted(async () => {
417488
containerResizeObserver.observe(taskListRef.value)
418489
}
419490
491+
// 监听TaskList body高度变化,提供虚拟滚动所需尺寸
492+
if (taskListBodyRef.value) {
493+
taskListBodyHeight.value = taskListBodyRef.value.clientHeight
494+
taskListScrollTop.value = taskListBodyRef.value.scrollTop
495+
496+
bodyResizeObserver = new ResizeObserver(entries => {
497+
for (const entry of entries) {
498+
taskListBodyHeight.value = entry.contentRect.height
499+
}
500+
})
501+
502+
bodyResizeObserver.observe(taskListBodyRef.value)
503+
}
504+
420505
window.addEventListener('task-updated', handleTaskUpdated as EventListener)
421506
window.addEventListener('task-added', handleTaskAdded as EventListener)
422507
window.addEventListener('request-task-list', handleRequestTaskList as EventListener)
@@ -438,6 +523,11 @@ onUnmounted(() => {
438523
containerResizeObserver = null
439524
}
440525
526+
if (bodyResizeObserver) {
527+
bodyResizeObserver.disconnect()
528+
bodyResizeObserver = null
529+
}
530+
441531
window.removeEventListener('task-updated', handleTaskUpdated as EventListener)
442532
window.removeEventListener('task-added', handleTaskAdded as EventListener)
443533
window.removeEventListener('request-task-list', handleRequestTaskList as EventListener)
@@ -470,17 +560,20 @@ onUnmounted(() => {
470560
{{ (t as any)[column.key] || column.label }}
471561
</div>
472562
</div>
473-
<div class="task-list-body" @scroll="handleTaskListScroll">
474-
<TaskRow
475-
v-for="task in localTasks"
563+
<div ref="taskListBodyRef" class="task-list-body" @scroll="handleTaskListScroll">
564+
<div class="task-list-body-spacer" :style="{ height: `${startSpacerHeight}px` }"></div>
565+
566+
<TaskRow
567+
v-for="{ task, level } in visibleTasks"
476568
:key="task.id"
477569
:task="task"
478-
:level="0"
570+
:level="level"
479571
:is-hovered="hoveredTaskId === task.id"
480572
:hovered-task-id="hoveredTaskId"
481573
:on-hover="handleTaskRowHover"
482574
:columns="visibleColumns"
483575
:get-column-width-style="getColumnWidthStyle"
576+
:disable-children-render="true"
484577
@toggle="toggleCollapse"
485578
@dblclick="handleTaskRowDoubleClick"
486579
@contextmenu="handleTaskRowContextMenu"
@@ -494,6 +587,8 @@ onUnmounted(() => {
494587
<slot name="custom-task-content" v-bind="rowScope" />
495588
</template>
496589
</TaskRow>
590+
591+
<div class="task-list-body-spacer" :style="{ height: `${endSpacerHeight}px` }"></div>
497592
</div>
498593
</div>
499594
</template>
@@ -546,14 +641,18 @@ onUnmounted(() => {
546641
width: max-content;
547642
background: var(--gantt-bg-primary);
548643
flex: 1;
549-
overflow-x: hidden; /* 让body部分可以滚动 */
550-
overflow-y: auto; /* 允许垂直滚动 */
644+
overflow-x: hidden; /* 允许横向滚动,确保列完整展示 */
645+
overflow-y: auto;
551646
552647
/* Webkit浏览器滚动条样式 */
553648
scrollbar-width: thin;
554649
scrollbar-color: var(--gantt-scrollbar-thumb) transparent;
555650
}
556651
652+
.task-list-body-spacer {
653+
width: 100%;
654+
}
655+
557656
.task-list-body::-webkit-scrollbar {
558657
width: 8px;
559658
height: 8px;

src/components/TaskRow.vue

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ interface Props {
3636
onHover?: (taskId: number | null) => void
3737
columns: TaskListColumnConfig[]
3838
getColumnWidthStyle?: (column: { width?: number | string }) => object
39+
disableChildrenRender?: boolean
3940
}
4041
const props = defineProps<Props>()
4142
const emit = defineEmits([
@@ -468,7 +469,7 @@ onUnmounted(() => {
468469
</template>
469470
</div>
470471
</div>
471-
<template v-if="hasChildren && !props.task.collapsed && !isMilestoneGroup">
472+
<template v-if="!props.disableChildrenRender && hasChildren && !props.task.collapsed && !isMilestoneGroup">
472473
<TaskRow
473474
v-for="child in props.task.children"
474475
:key="child.id"
@@ -479,6 +480,7 @@ onUnmounted(() => {
479480
:on-hover="props.onHover"
480481
:columns="props.columns"
481482
:get-column-width-style="props.getColumnWidthStyle"
483+
:disable-children-render="props.disableChildrenRender"
482484
@toggle="emit('toggle', $event)"
483485
@dblclick="emit('dblclick', $event)"
484486
@start-timer="emit('start-timer', $event)"

src/components/Timeline.vue

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -657,6 +657,11 @@ let hideBubblesTimeout: number | null = null // 半圆显示恢复定时器
657657
const HOUR_WIDTH = 40 // 每小时40px
658658
const VIRTUAL_BUFFER = 10 // 减少缓冲区以提升滑动性能
659659
660+
// 纵向虚拟滚动相关状态
661+
const ROW_HEIGHT = 51 // 每行高度51px (50px + 1px border)
662+
const VERTICAL_BUFFER = 5 // 纵向缓冲区行数
663+
const timelineBodyScrollTop = ref(0) // 纵向滚动位置
664+
660665
// 数据缓存
661666
const timelineDataCache = new Map<string, unknown>()
662667
@@ -701,6 +706,30 @@ const visibleHourRange = computed(() => {
701706
}
702707
})
703708
709+
// 计算纵向可视区域的任务范围
710+
const visibleTaskRange = computed(() => {
711+
const scrollTop = timelineBodyScrollTop.value
712+
const containerHeight = timelineBodyHeight.value || 600
713+
714+
// 计算可视区域的开始和结束任务索引
715+
const startIndex = Math.floor(scrollTop / ROW_HEIGHT) - VERTICAL_BUFFER
716+
const endIndex = Math.ceil((scrollTop + containerHeight) / ROW_HEIGHT) + VERTICAL_BUFFER
717+
718+
return {
719+
startIndex: Math.max(0, startIndex),
720+
endIndex: Math.min(tasks.value.length, Math.max(startIndex + 1, endIndex)),
721+
}
722+
})
723+
724+
// 获取虚拟滚动优化后的可见任务列表
725+
const visibleTasks = computed(() => {
726+
const { startIndex, endIndex } = visibleTaskRange.value
727+
return tasks.value.slice(startIndex, endIndex).map((task, index) => ({
728+
task,
729+
originalIndex: startIndex + index,
730+
}))
731+
})
732+
704733
// 防抖处理滚动事件(优化:增加防抖时间)
705734
const debounce = <T extends (...args: unknown[]) => void>(func: T, wait: number): T => {
706735
let timeout: number | null = null
@@ -724,6 +753,11 @@ const debouncedUpdateCanvasPosition = debounce(() => {
724753
updateSvgSize() // 重新计算 Canvas 位置和尺寸
725754
}, 50)
726755
756+
// 防抖更新纵向滚动位置
757+
const debouncedUpdateVerticalScroll = debounce((scrollTop: number) => {
758+
timelineBodyScrollTop.value = scrollTop
759+
}, 16) // 使用16ms约等于60fps,保证流畅性
760+
727761
// 缓存时间轴数据的函数
728762
const getCachedTimelineData = (): unknown => {
729763
const scale = currentTimeScale.value
@@ -2162,6 +2196,10 @@ onMounted(() => {
21622196
// 处理TaskList垂直滚动同步
21632197
const handleTaskListVerticalScroll = (event: CustomEvent) => {
21642198
const { scrollTop } = event.detail
2199+
2200+
// 立即更新纵向滚动位置(用于虚拟滚动计算)
2201+
timelineBodyScrollTop.value = scrollTop
2202+
21652203
if (timelineBodyElement.value && Math.abs(timelineBodyElement.value.scrollTop - scrollTop) > 1) {
21662204
// 使用更精确的比较,避免1px以内的细微差异导致的循环触发
21672205
timelineBodyElement.value.scrollTop = scrollTop
@@ -2175,6 +2213,9 @@ const handleTimelineBodyScroll = (event: Event) => {
21752213
21762214
const scrollTop = target.scrollTop
21772215
2216+
// 立即更新纵向滚动位置(用于虚拟滚动计算)
2217+
timelineBodyScrollTop.value = scrollTop
2218+
21782219
// 拖拽时不同步滚动事件,避免性能问题
21792220
if (isDragging.value) return
21802221
@@ -3510,13 +3551,13 @@ const handleAddSuccessor = (task: Task) => {
35103551
<!-- 同时需要考虑左侧TaskList包含1px的bottom border -->
35113552
<div class="task-bar-container" :style="{ height: `${contentHeight}px` }">
35123553
<div class="task-rows" :style="{ height: `${contentHeight}px` }">
3513-
<!-- 使用v-memo减少渲染 -->
3554+
<!-- 使用虚拟滚动渲染可见任务 -->
35143555
<div
3515-
v-for="(task, index) in tasks"
3556+
v-for="{ task, originalIndex } in visibleTasks"
35163557
:key="task.id"
35173558
class="task-row"
35183559
:class="{ 'task-row-hovered': hoveredTaskId === task.id }"
3519-
:style="{ top: `${index * 51}px` }"
3560+
:style="{ top: `${originalIndex * 51}px` }"
35203561
@mouseenter="handleTaskRowHover(task.id)"
35213562
@mouseleave="handleTaskRowHover(null)"
35223563
>

0 commit comments

Comments
 (0)