可视化连接线性能问题调试

This commit is contained in:
qiuchengw
2025-12-03 07:12:52 +08:00
parent f4555c96c3
commit f5efd082bd
2 changed files with 238 additions and 109 deletions

View File

@@ -1,36 +1,27 @@
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { ref, watch, onUnmounted } from 'vue'
interface Props {
active: boolean
startX: number
startY: number
endX: number
endY: number
// 尺寸和位置(变化频率低)
width: number
height: number
offsetLeft?: number
offsetTop?: number
isValidTarget?: boolean // 是否是合法的连接目标
errorMessage?: string // 错误提示消息
}
const props = withDefaults(defineProps<Props>(), {
offsetLeft: 0,
offsetTop: 0,
isValidTarget: true,
errorMessage: '',
})
const canvasRef = ref<HTMLCanvasElement | null>(null)
// 性能监控
const ENABLE_PERF_MONITOR = true
const SKIP_ACTUAL_DRAW = false // 调试开关:跳过实际绘制,只打印日志
const ENABLE_PERF_MONITOR = false // 生产环境关闭
let drawCount = 0
let drawTotalTime = 0
let lastReportTime = 0
let lastCallTime = 0 // 上次调用时间,用于计算间隔
// 缓存 canvas 上下文和尺寸信息,避免重复初始化
let cachedCtx: CanvasRenderingContext2D | null = null
@@ -76,40 +67,50 @@ const initCanvas = () => {
}
/**
* 绘制拖拽引导线
* 使用贝塞尔曲线,与 GanttLinks 保持一致的视觉风格
* 立即绘制不使用RAF节流确保跟随鼠
* 🚀 命令式绘制方法 - 由父组件直接调用,避免 Vue 响应式开销
* @param startX 起始点X坐标
* @param startY 起始点Y坐
* @param endX 结束点X坐标
* @param endY 结束点Y坐标
* @param isValidTarget 是否是合法的连接目标
* @param errorMessage 错误提示消息
*/
const drawGuideLine = () => {
performDraw()
}
// 🔧 调试开关:跳过实际绘制
const DEBUG_SKIP_ACTUAL_DRAW = false
const USE_SIMPLE_LINE = true // true = 使用简单直线替代贝塞尔曲线(性能更好)
const performDraw = () => {
if (!props.active) return
// 调试模式:跳过实际绘制
if (SKIP_ACTUAL_DRAW) {
return
}
const draw = (
startX: number,
startY: number,
endX: number,
endY: number,
isValidTarget = true,
errorMessage = '',
) => {
// 🔧 调试:跳过实际绘制
if (DEBUG_SKIP_ACTUAL_DRAW) return
const startTime = ENABLE_PERF_MONITOR ? performance.now() : 0
const ctx = initCanvas()
if (!ctx) return
if (!ctx) {
console.warn('[LinkDragGuide] initCanvas returned null')
return
}
const displayWidth = cachedWidth
const displayHeight = cachedHeight
// 清空画布(使用缓存的尺寸)
// 清空画布
ctx.clearRect(0, 0, displayWidth, displayHeight)
// 转换为 Canvas 局部坐标
const localX1 = props.startX - props.offsetLeft
const localY1 = props.startY - props.offsetTop
const localX2 = props.endX - props.offsetLeft
const localY2 = props.endY - props.offsetTop
const localX1 = startX - props.offsetLeft
const localY1 = startY - props.offsetTop
const localX2 = endX - props.offsetLeft
const localY2 = endY - props.offsetTop
// 检查坐标是否在 Canvas 范围内(增加缓冲区以允许部分可见)
// 检查坐标是否在 Canvas 范围内
const buffer = 100
const isInBounds =
(localX1 >= -buffer || localX2 >= -buffer) &&
@@ -119,7 +120,7 @@ const performDraw = () => {
if (!isInBounds) return
// 贝塞尔曲线控制点(与 GanttLinks 一致)
// 贝塞尔曲线控制点
const c1x = localX1 + 40
const c1y = localY1
const c2x = localX2 - 40
@@ -128,16 +129,25 @@ const performDraw = () => {
ctx.save()
// 根据是否是合法目标设置颜色
const color = props.isValidTarget ? '#67c23a' : '#f56c6c'
const color = isValidTarget ? '#67c23a' : '#f56c6c'
ctx.strokeStyle = color
ctx.lineWidth = 3
ctx.setLineDash([8, 4])
// 🔧 测试:移除虚线,看是否是虚线+贝塞尔曲线导致的性能问题
// ctx.setLineDash([8, 4])
ctx.globalAlpha = 0.8
// 绘制贝塞尔曲线
ctx.beginPath()
ctx.moveTo(localX1, localY1)
ctx.bezierCurveTo(c1x, c1y, c2x, c2y, localX2, localY2)
if (USE_SIMPLE_LINE) {
// 🚀 性能优化:使用简单直线(性能最佳)
ctx.lineTo(localX2, localY2)
} else {
// 使用贝塞尔曲线(视觉效果好但性能较差)
ctx.bezierCurveTo(c1x, c1y, c2x, c2y, localX2, localY2)
}
ctx.stroke()
// 绘制箭头
@@ -160,25 +170,23 @@ const performDraw = () => {
ctx.closePath()
ctx.fill()
// 绘制错误提示文字(当连接无效且有错误消息时)
if (!props.isValidTarget && props.errorMessage) {
// 绘制错误提示文字
if (!isValidTarget && errorMessage) {
const textX = (localX1 + localX2) / 2
const textY = (localY1 + localY2) / 2 - 10
// 文字背景
ctx.font = '12px Arial, sans-serif'
const textMetrics = ctx.measureText(props.errorMessage)
const textMetrics = ctx.measureText(errorMessage)
const textWidth = textMetrics.width
const padding = 8
ctx.fillStyle = 'rgba(0, 0, 0, 0.75)'
ctx.fillRect(textX - textWidth / 2 - padding, textY - 12, textWidth + padding * 2, 24)
// 文字内容
ctx.fillStyle = '#ffffff'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(props.errorMessage, textX, textY)
ctx.fillText(errorMessage, textX, textY)
}
ctx.restore()
@@ -203,54 +211,39 @@ const performDraw = () => {
/**
* 清空画布
*/
const clearCanvas = () => {
const ctx = cachedCtx
if (!ctx) return
ctx.clearRect(0, 0, cachedWidth, cachedHeight)
const clear = () => {
if (!cachedCtx) return
cachedCtx.clearRect(0, 0, cachedWidth, cachedHeight)
}
// 监听尺寸变化,需要重新初始化 canvas
watch(
[() => props.width, () => props.height],
() => {
cachedCtx = null // 清除缓存,下次绘制时重新初始化
},
)
// 监听 active 变化,清除缓存(因为 v-if 会移除/重建 canvas 元素)
watch(
() => props.active,
(newActive) => {
if (!newActive) {
// canvas 即将被移除,清除缓存
cachedCtx = null
}
},
)
// 组件卸载时清除缓存
onUnmounted(() => {
cachedCtx = null
})
watch(
[
() => props.active,
() => props.startX,
() => props.startY,
() => props.endX,
() => props.endY,
() => props.isValidTarget,
() => props.errorMessage,
],
() => {
if (props.active) {
drawGuideLine()
} else {
clearCanvas()
}
},
{ flush: 'sync' }, // 同步执行,立即响应坐标变化
)
// 监听尺寸变化,需要重新初始化 canvas尺寸变化频率低
watch(
[() => props.width, () => props.height],
() => {
// 清除缓存,强制重新初始化
cachedCtx = null
if (props.active) {
drawGuideLine()
}
},
)
onMounted(() => {
if (props.active) {
drawGuideLine()
}
// 🚀 暴露命令式 API 给父组件
defineExpose({
draw,
clear,
})
</script>

View File

@@ -653,13 +653,17 @@ const DISABLE_REACTIVE_PROPS_TO_TASKBAR = true // 设为 true 测试是否是 Vu
const dragLinkMode = ref<'predecessor' | 'successor' | null>(null) // 当前拖拽模式
const linkDragSourceTask = shallowRef<Task | null>(null) // 拖拽起始任务(使用 shallowRef 优化性能)
const linkDragCurrentX = ref(0) // 当前鼠标X坐标
const linkDragCurrentY = ref(0) // 当前鼠标Y坐标
const linkDragCurrentX = ref(0) // 当前鼠标X坐标(保留用于兼容)
const linkDragCurrentY = ref(0) // 当前鼠标Y坐标(保留用于兼容)
const linkDragTargetTask = shallowRef<Task | null>(null) // 当前悬停的目标任务(使用 shallowRef 优化性能)
const isValidLinkTarget = ref(false) // 是否是有效的连接目标
const linkValidationError = ref<string>('') // 连接验证失败的原因
const isValidLinkTarget = ref(false) // 是否是有效的连接目标(保留用于 handleLinkDragEnd
const linkValidationError = ref<string>('') // 连接验证失败的原因(保留用于兼容)
const linkAutoScrollInterval = ref<number | null>(null) // 自动滚动定时器
// 🚀 非响应式拖拽状态(用于高频更新,避免 Vue 响应式开销)
let nonReactiveIsValidTarget = false
let nonReactiveErrorMessage = ''
// 🔧 调试用:用于传递给 TaskBar 的静态替代值(避免触发 Vue 更新)
const staticDragLinkMode = computed(() =>
DISABLE_REACTIVE_PROPS_TO_TASKBAR ? null : dragLinkMode.value,
@@ -734,12 +738,16 @@ const handleLinkDragStart = (event: { task: Task; type: 'predecessor' | 'success
// 启动帧监控
startFrameMonitor()
// 初始化鼠标坐标
updateLinkDragCoordinates(event.mouseEvent.clientX, event.mouseEvent.clientY)
// 初始化鼠标坐标(使用非响应式版本)
updateLinkDragCoordinatesNonReactive(event.mouseEvent.clientX, event.mouseEvent.clientY)
linkDragTargetTask.value = null
isValidLinkTarget.value = false
linkValidationError.value = ''
// 🚀 重置非响应式状态
nonReactiveTargetTask = null
nonReactiveIsValidTarget = false
nonReactiveErrorMessage = ''
// 启动自动滚动检测
startLinkAutoScroll()
@@ -760,13 +768,40 @@ let linkDragRafId: number | null = null
let pendingMouseX = 0
let pendingMouseY = 0
// 🚀 优化后的 RAF 回调:在一帧内批量处理坐标更新和目标检测
// 🚀 非响应式拖拽坐标(避免 Vue 响应式系统开销)
let currentDragX = 0
let currentDragY = 0
// 🔧 调试开关:逐步启用各操作以定位性能瓶颈
const DEBUG_ENABLE_COORD_UPDATE = true // 坐标更新 ✅ 不是瓶颈
const DEBUG_ENABLE_TARGET_DETECT = true // 目标检测 ✅ 不是瓶颈
const DEBUG_ENABLE_CANVAS_DRAW = true // Canvas 绘制 - 测试中
// 🚀 优化后的 RAF 回调:在一帧内批量处理坐标更新、目标检测和绘制
const processLinkDragFrame = () => {
linkDragRafId = null
// 批量更新:坐标 + 目标检测
updateLinkDragCoordinates(pendingMouseX, pendingMouseY)
detectLinkTarget(pendingMouseX, pendingMouseY)
// 🔧 调试:逐步启用各操作
if (DEBUG_ENABLE_COORD_UPDATE) {
updateLinkDragCoordinatesNonReactive(pendingMouseX, pendingMouseY)
}
if (DEBUG_ENABLE_TARGET_DETECT) {
detectLinkTargetNonReactive(pendingMouseX, pendingMouseY)
}
if (DEBUG_ENABLE_CANVAS_DRAW) {
if (linkDragGuideRef.value && linkDragSourceTask.value) {
linkDragGuideRef.value.draw(
getLinkDragStartX(),
getLinkDragStartY(),
currentDragX,
currentDragY,
nonReactiveIsValidTarget,
nonReactiveErrorMessage,
)
}
}
}
// 全局鼠标移动处理(🚀 优化:使用 RAF 统一调度,避免每次 mousemove 都触发响应式更新)
@@ -826,13 +861,13 @@ let bodyRectCacheTime = 0
const BODY_RECT_CACHE_DURATION = 200 // 200ms 缓存(优化:增加缓存时间)
let bodyRectInvalidated = false // 缓存失效标记(滚动时失效)
// 快速更新鼠标坐标(无节流,确保引导线跟随
const updateLinkDragCoordinates = (mouseX: number, mouseY: number) => {
// 🚀 非响应式坐标更新(完全绕过 Vue 响应式系统
const updateLinkDragCoordinatesNonReactive = (mouseX: number, mouseY: number) => {
const startTime = ENABLE_PERF_MONITOR ? performance.now() : 0
if (!bodyContentRef.value) {
linkDragCurrentX.value = mouseX
linkDragCurrentY.value = mouseY
currentDragX = mouseX
currentDragY = mouseY
if (ENABLE_PERF_MONITOR) {
perfStats.coordUpdateCount++
@@ -850,8 +885,8 @@ const updateLinkDragCoordinates = (mouseX: number, mouseY: number) => {
bodyRectCacheTime = now
bodyRectInvalidated = false
}
linkDragCurrentX.value = mouseX - cachedBodyRect.left
linkDragCurrentY.value = mouseY - cachedBodyRect.top
currentDragX = mouseX - cachedBodyRect.left
currentDragY = mouseY - cachedBodyRect.top
if (ENABLE_PERF_MONITOR) {
perfStats.coordUpdateCount++
@@ -868,6 +903,7 @@ const updateLinkDragCoordinates = (mouseX: number, mouseY: number) => {
? (perfStats.targetDetectTotalTime / perfStats.targetDetectCount).toFixed(3)
: 0
// eslint-disable-next-line no-console
console.log(
`[LinkDrag Perf] 坐标更新: ${perfStats.coordUpdateCount}次, 平均${avgCoordTime}ms | ` +
`目标检测: ${perfStats.targetDetectCount}次, 平均${avgTargetTime}ms`,
@@ -909,7 +945,12 @@ const handleLinkDragEnd = (event: { task: Task; type: 'predecessor' | 'successor
document.removeEventListener('mouseup', handleGlobalMouseUp)
// 停止自动滚动
stopLinkAutoScroll() // 如果有有效目标,创建连接
stopLinkAutoScroll()
// 🚀 清除 LinkDragGuide 画布
linkDragGuideRef.value?.clear()
// 如果有有效目标,创建连接
if (linkDragTargetTask.value && isValidLinkTarget.value) {
createLink(event.task, linkDragTargetTask.value, event.type)
}
@@ -1048,6 +1089,102 @@ const detectLinkTarget = (mouseX: number, mouseY: number) => {
}
}
}
// 🚀 非响应式目标检测(完全绕过 Vue 响应式系统)
// 缓存当前检测到的目标任务(用于 handleLinkDragEnd
let nonReactiveTargetTask: Task | null = null
const detectLinkTargetNonReactive = (mouseX: number, mouseY: number) => {
const startTime = ENABLE_PERF_MONITOR ? performance.now() : 0
if (!linkDragSourceTask.value || !bodyContentRef.value) return
// 使用缓存的 rect
if (!cachedBodyRect) {
cachedBodyRect = bodyContentRef.value.getBoundingClientRect()
bodyRectCacheTime = Date.now()
}
const relativeX = mouseX - cachedBodyRect.left
const relativeY = mouseY - cachedBodyRect.top
let foundTaskId: number | null = null
const isPredecessorMode = dragLinkMode.value === 'predecessor'
const halfSize = (ANCHOR_SIZE + ANCHOR_TOLERANCE) / 2
const expandedHalfSize = halfSize + 10
for (const taskIdStr in taskBarPositions.value) {
const pos = taskBarPositions.value[taskIdStr]
const taskId = Number(taskIdStr)
if (
relativeY < pos.top - expandedHalfSize ||
relativeY > pos.top + pos.height + expandedHalfSize
) {
continue
}
if (isPredecessorMode) {
const anchorX = pos.left + pos.width
if (relativeX < anchorX - expandedHalfSize || relativeX > anchorX + expandedHalfSize) {
continue
}
const anchorY = pos.top + pos.height / 2
if (
relativeX >= anchorX - halfSize &&
relativeX <= anchorX + halfSize &&
relativeY >= anchorY - halfSize &&
relativeY <= anchorY + halfSize
) {
foundTaskId = taskId
break
}
} else {
const anchorX = pos.left
if (relativeX < anchorX - expandedHalfSize || relativeX > anchorX + expandedHalfSize) {
continue
}
const anchorY = pos.top + pos.height / 2
if (
relativeX >= anchorX - halfSize &&
relativeX <= anchorX + halfSize &&
relativeY >= anchorY - halfSize &&
relativeY <= anchorY + halfSize
) {
foundTaskId = taskId
break
}
}
}
const foundTarget = foundTaskId !== null ? taskIdMap.get(foundTaskId) || null : null
const currentTargetId = nonReactiveTargetTask?.id ?? null
const newTargetId = foundTarget?.id ?? null
if (currentTargetId !== newTargetId) {
nonReactiveTargetTask = foundTarget
if (foundTarget && linkDragSourceTask.value) {
const validation = validateLink(linkDragSourceTask.value, foundTarget, dragLinkMode.value!)
nonReactiveIsValidTarget = validation.valid
nonReactiveErrorMessage = validation.error || ''
// 🚀 同步更新响应式变量(仅在目标变化时,用于 handleLinkDragEnd
isValidLinkTarget.value = validation.valid
linkDragTargetTask.value = foundTarget
} else {
nonReactiveIsValidTarget = false
nonReactiveErrorMessage = ''
isValidLinkTarget.value = false
linkDragTargetTask.value = null
}
}
if (ENABLE_PERF_MONITOR) {
perfStats.targetDetectCount++
perfStats.targetDetectTotalTime += performance.now() - startTime
}
}
// 验证连接是否有效(返回 { valid: boolean, error?: string }
const validateLink = (
sourceTask: Task,
@@ -1249,6 +1386,9 @@ const cleanupLinkDrag = () => {
mouseMoveRafId = null
}
// 🚀 清除 LinkDragGuide 画布
linkDragGuideRef.value?.clear()
// 移除全局监听器
document.removeEventListener('keydown', handleLinkDragEscape)
document.removeEventListener('mousemove', handleGlobalMouseMove)
@@ -2614,6 +2754,8 @@ const taskBarPositions = shallowRef<
const taskBarRenderKey = ref(0)
const bodyContentRef = ref<HTMLElement | null>(null)
// 🚀 LinkDragGuide 命令式 API 引用
const linkDragGuideRef = ref<InstanceType<typeof LinkDragGuide> | null>(null)
const svgWidth = ref(0)
const svgHeight = ref(0)
@@ -4078,20 +4220,14 @@ const handleAddSuccessor = (task: Task) => {
:show-vertical-lines="currentTimeScale === TimelineScale.WEEK"
/>
<!-- 连接线拖拽引导线 -->
<!-- 连接线拖拽引导线 - 🚀 优化:使用命令式 API由 RAF 直接调用 draw() -->
<LinkDragGuide
v-if="dragLinkMode && linkDragSourceTask"
:active="true"
:start-x="getLinkDragStartX()"
:start-y="getLinkDragStartY()"
:end-x="linkDragCurrentX"
:end-y="linkDragCurrentY"
ref="linkDragGuideRef"
:active="!!dragLinkMode && !!linkDragSourceTask"
:width="canvasWidth"
:height="canvasHeight"
:offset-left="canvasOffsetLeft"
:offset-top="canvasOffsetTop"
:is-valid-target="isValidLinkTarget"
:error-message="linkValidationError"
/>
<!-- 年度视图今日标记线 -->