perf: 传统族谱视图全面优化

P0 性能优化:
- 连线计算用 requestAnimationFrame + useLayoutEffect 替代 7个 setTimeout 轮询
- 节点注册改为批量 ref 模式,消除 N 次 setState 重渲染
- 初始定位用 useLayoutEffect + 双 rAF 替代 600ms setTimeout
- 高亮定位用三 rAF 替代 1000ms setTimeout
- 使用 ResizeObserver 替代 window.resize 监听

P1 视觉优化:
- 连线从 div 改为 SVG path 绘制,清晰且支持动画
- 背景纹理图从 transparenttextures.com 本地化到 public/
- 添加骨架屏 loading 状态替代 return null

P2 代码质量:
- 移除 page.tsx 中 5 处 console.log
- 移除 family-node.tsx 中 3 处 console.log
- 消除 handleD3MemberClick 中的直接 DOM 操作(createElement/innerHTML)
- parseName 提取为模块级函数,避免每次渲染重建

P3 增强:
- 缩放范围从 0.1~2 调整为 0.15~3
- 工具栏显示当前缩放比例
- SVG 连线添加 transition 动画
This commit is contained in:
selfrelease
2026-07-16 15:45:24 +08:00
parent b5612a3c2e
commit ce06674cba
5 changed files with 212 additions and 363 deletions
-3
View File
@@ -193,10 +193,8 @@ function PersonCard({
}
const url = buildAddUrl(type)
console.log('handleAddMember called:', type, url)
// 使用 setTimeout 延迟跳转,确保菜单关闭后再执行
setTimeout(() => {
console.log('Navigating to:', url)
window.location.href = url
}, 100)
}
@@ -205,7 +203,6 @@ function PersonCard({
const handleConfirmAddParent = () => {
if (pendingRelationType) {
const url = buildAddUrl(pendingRelationType)
console.log('Confirmed add parent, navigating to:', url)
setTimeout(() => {
window.location.href = url
}, 100)
+117 -203
View File
@@ -5,7 +5,7 @@ import { FamilyNode } from "./family-node"
import { TreeNode } from "./tree-node"
import { AddRelationDialog, type RelationType } from "./add-relation-dialog"
import { useFamily } from "@/context/family-context"
import { useEffect, useRef, useState, useCallback, useMemo, memo } from "react"
import { useEffect, useRef, useState, useCallback, useMemo, memo, useLayoutEffect } from "react"
import { useRouter } from "next/navigation"
import { permissions } from "@/lib/permissions"
@@ -26,6 +26,33 @@ interface Connection {
type: 'vertical' | 'horizontal'
}
/** 常见复姓列表 */
const COMPOUND_SURNAMES = [
'欧阳', '太史', '端木', '上官', '司马', '东方', '独孤', '南宫', '万俟', '闻人',
'夏侯', '诸葛', '尉迟', '公羊', '赫连', '澹台', '皇甫', '宗政', '濮阳', '公冶',
'太叔', '申屠', '公孙', '慕容', '仲孙', '钟离', '长孙', '宇文', '司徒', '鲜于',
'司空', '闾丘', '子车', '亓官', '司寇', '巫马', '公西', '颛孙', '壤驷', '公良',
'漆雕', '乐正', '宰父', '谷梁', '拓跋', '夹谷', '轩辕', '令狐', '段干', '百里',
'呼延', '东郭', '南门', '羊舌', '微生', '公户', '公玉', '公仪', '梁丘', '公仲',
'公上', '公门', '公山', '公坚', '左丘', '公伯', '西门', '公祖', '第五', '公乘',
'贯丘', '公皙', '南荣', '东里', '东宫', '仲长', '子书', '子桑', '即墨', '达奚', '褚师'
]
/** 将全名分解为姓和名(模块级函数,避免每次渲染重建) */
function parseName(fullName: string): { surname: string; givenName: string } {
const name = fullName.trim()
if (!name) return { surname: '', givenName: '' }
for (const compound of COMPOUND_SURNAMES) {
if (name.startsWith(compound) && name.length > compound.length) {
return { surname: compound, givenName: name.slice(compound.length) }
}
}
if (name.length >= 2) {
return { surname: name.charAt(0), givenName: name.slice(1) }
}
return { surname: name, givenName: '' }
}
export function TreeLayout({
rootId,
onSelectMember,
@@ -52,9 +79,6 @@ export function TreeLayout({
const VERTICAL_GAP = 48 // 3rem = 48px
const CONNECTOR_HEIGHT = 48 // 连接线垂直高度
// 节点注册计数,用于触发连线重新计算
const [nodeRegisteredCount, setNodeRegisteredCount] = useState(0)
// 折叠状态管理:存储被折叠的节点ID
const [collapsedNodes, setCollapsedNodes] = useState<Set<string>>(() => new Set())
@@ -80,12 +104,10 @@ export function TreeLayout({
}
}, [expandAll, onExpandAllChange])
// 处理节点 ref 注册
// 节点 ref 注册(批量模式,不再触发 N 次 setState)
const handleNodeRef = useCallback((memberId: string, el: HTMLDivElement | null) => {
if (el) {
nodeRefs.current.set(memberId, el)
// 触发连线重新计算
setNodeRegisteredCount(prev => prev + 1)
} else {
nodeRefs.current.delete(memberId)
}
@@ -128,161 +150,101 @@ export function TreeLayout({
return checkHidden(nodeId)
}, [getMember, collapsedNodes])
// 计算所有连线位置
// 计算所有连线位置(使用 requestAnimationFrame 批量计算,避免 layout thrashing
const rafIdRef = useRef<number | null>(null)
const calculateConnections = useCallback(() => {
const newConnections: Connection[] = []
const containerRect = containerRef.current?.getBoundingClientRect()
if (!containerRect) {
return
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current)
}
rafIdRef.current = requestAnimationFrame(() => {
rafIdRef.current = null
const newConnections: Connection[] = []
const containerRect = containerRef.current?.getBoundingClientRect()
if (!containerRect || nodeRefs.current.size === 0) return
// 检查是否有足够的节点已注册
if (nodeRefs.current.size === 0) {
return
}
nodeRefs.current.forEach((nodeElement, nodeId) => {
const member = getMember(nodeId)
if (!member || !member.childrenIds || member.childrenIds.length === 0) return
if (collapsedNodes.has(nodeId)) return
if (isNodeHiddenByCollapse(nodeId)) return
if (!document.body.contains(nodeElement)) return
const parentRect = nodeElement.getBoundingClientRect()
if (parentRect.width === 0 || parentRect.height === 0) return
let processedCount = 0
let skippedNoChildren = 0
let skippedCollapsed = 0
let skippedHidden = 0
let skippedNotInDom = 0
let skippedZeroSize = 0
const parentCenterX = (parentRect.left + parentRect.width / 2 - containerRect.left) / scale
const parentBottomY = (parentRect.bottom - containerRect.top) / scale
nodeRefs.current.forEach((nodeElement, nodeId) => {
const member = getMember(nodeId)
if (!member || !member.childrenIds || member.childrenIds.length === 0) {
skippedNoChildren++
return
}
// 如果该节点被折叠,不绘制到子节点的连线
if (collapsedNodes.has(nodeId)) {
skippedCollapsed++
return
}
// 如果该节点被祖先折叠隐藏,跳过
if (isNodeHiddenByCollapse(nodeId)) {
skippedHidden++
return
}
// 检查元素是否仍在 DOM 中且可见
if (!document.body.contains(nodeElement)) {
skippedNotInDom++
return
}
const parentRect = nodeElement.getBoundingClientRect()
// 如果元素不可见(宽高为0),跳过
if (parentRect.width === 0 || parentRect.height === 0) {
skippedZeroSize++
return
}
processedCount++
const childPositions = member.childrenIds
.map(childId => {
const childElement = nodeRefs.current.get(childId)
if (!childElement || !document.body.contains(childElement)) return null
const childRect = childElement.getBoundingClientRect()
if (childRect.width === 0 || childRect.height === 0) return null
return {
id: childId,
centerX: (childRect.left + childRect.width / 2 - containerRect.left) / scale,
topY: (childRect.top - containerRect.top) / scale
}
})
.filter(Boolean) as Array<{ id: string; centerX: number; topY: number }>
// 考虑缩放因素:getBoundingClientRect 返回的是缩放后的尺寸
// 需要除以 scale 来获取实际的逻辑位置
const parentCenterX = (parentRect.left + parentRect.width / 2 - containerRect.left) / scale
const parentBottomY = (parentRect.bottom - containerRect.top) / scale
if (childPositions.length === 0) return
// 获取所有子节点的位置
const childPositions = member.childrenIds
.map(childId => {
const childElement = nodeRefs.current.get(childId)
if (!childElement) return null
// 检查子元素是否仍在 DOM 中
if (!document.body.contains(childElement)) return null
const childRect = childElement.getBoundingClientRect()
// 如果子元素不可见,跳过
if (childRect.width === 0 || childRect.height === 0) return null
return {
id: childId,
centerX: (childRect.left + childRect.width / 2 - containerRect.left) / scale,
topY: (childRect.top - containerRect.top) / scale
}
})
.filter(Boolean) as Array<{ id: string; centerX: number; topY: number }>
if (childPositions.length === 0) return
// 父节点到水平线的垂直连线
const verticalLineY = parentBottomY + CONNECTOR_HEIGHT / 2
newConnections.push({
from: { x: parentCenterX, y: parentBottomY },
to: { x: parentCenterX, y: verticalLineY },
type: 'vertical'
})
// 如果有多个子节点,绘制水平连线
if (childPositions.length > 1) {
const leftmostX = Math.min(...childPositions.map(p => p.centerX))
const rightmostX = Math.max(...childPositions.map(p => p.centerX))
const verticalLineY = parentBottomY + CONNECTOR_HEIGHT / 2
newConnections.push({
from: { x: leftmostX, y: verticalLineY },
to: { x: rightmostX, y: verticalLineY },
type: 'horizontal'
})
}
// 每个子节点的垂直连线
childPositions.forEach(child => {
newConnections.push({
from: { x: child.centerX, y: verticalLineY },
to: { x: child.centerX, y: child.topY },
from: { x: parentCenterX, y: parentBottomY },
to: { x: parentCenterX, y: verticalLineY },
type: 'vertical'
})
})
})
setConnections(newConnections)
if (childPositions.length > 1) {
const leftmostX = Math.min(...childPositions.map(p => p.centerX))
const rightmostX = Math.max(...childPositions.map(p => p.centerX))
newConnections.push({
from: { x: leftmostX, y: verticalLineY },
to: { x: rightmostX, y: verticalLineY },
type: 'horizontal'
})
}
childPositions.forEach(child => {
newConnections.push({
from: { x: child.centerX, y: verticalLineY },
to: { x: child.centerX, y: child.topY },
type: 'vertical'
})
})
})
setConnections(newConnections)
})
}, [getMember, collapsedNodes, isNodeHiddenByCollapse, scale])
// 折叠状态变化时重新计算连线
useEffect(() => {
// 多次延迟计算,等待 DOM 完全更新
const timers: NodeJS.Timeout[] = []
timers.push(setTimeout(calculateConnections, 50))
timers.push(setTimeout(calculateConnections, 150))
timers.push(setTimeout(calculateConnections, 300))
timers.push(setTimeout(calculateConnections, 500))
timers.push(setTimeout(calculateConnections, 1000))
return () => timers.forEach(timer => clearTimeout(timer))
}, [collapsedNodes, calculateConnections])
// 缩放变化时重新计算连线
useEffect(() => {
// 使用 useLayoutEffect 在 DOM 更新后立即计算连线(替代 7 个 setTimeout
useLayoutEffect(() => {
calculateConnections()
}, [scale, calculateConnections])
// 双 rAF 确保浏览器完成布局后再计算
const raf2 = requestAnimationFrame(() => calculateConnections())
return () => cancelAnimationFrame(raf2)
}, [calculateConnections, treeData, collapsedNodes])
// 节点注册变化时重新计算连线(防抖
// 使用 ResizeObserver 监听容器尺寸变化(替代 window.resize
useEffect(() => {
if (nodeRegisteredCount === 0) return
const timer = setTimeout(calculateConnections, 100)
return () => clearTimeout(timer)
}, [nodeRegisteredCount, calculateConnections])
const container = containerRef.current
if (!container) return
const observer = new ResizeObserver(() => calculateConnections())
observer.observe(container)
return () => observer.disconnect()
}, [calculateConnections])
// 清理 rAF
useEffect(() => {
// 多次尝试计算,确保 DOM 完全渲染
// 对于大型族谱(100+人),需要更长的延迟
const timers: NodeJS.Timeout[] = []
timers.push(setTimeout(calculateConnections, 100))
timers.push(setTimeout(calculateConnections, 300))
timers.push(setTimeout(calculateConnections, 500))
timers.push(setTimeout(calculateConnections, 1000))
timers.push(setTimeout(calculateConnections, 2000))
timers.push(setTimeout(calculateConnections, 3000))
timers.push(setTimeout(calculateConnections, 5000))
// 监听窗口大小变化
window.addEventListener('resize', calculateConnections)
return () => {
timers.forEach(timer => clearTimeout(timer))
window.removeEventListener('resize', calculateConnections)
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current)
}
}
}, [calculateConnections, treeData])
}, [])
// 预计算排序后的子节点映射,避免每次渲染时重复计算
@@ -310,45 +272,6 @@ export function TreeLayout({
if (!root) return null
// 姓名分解函数:将全名分解为姓和名
const parseName = (fullName: string): { surname: string; givenName: string } => {
const name = fullName.trim()
if (!name) return { surname: '', givenName: '' }
// 常见复姓列表
const compoundSurnames = [
'欧阳', '太史', '端木', '上官', '司马', '东方', '独孤', '南宫', '万俟', '闻人',
'夏侯', '诸葛', '尉迟', '公羊', '赫连', '澹台', '皇甫', '宗政', '濮阳', '公冶',
'太叔', '申屠', '公孙', '慕容', '仲孙', '钟离', '长孙', '宇文', '司徒', '鲜于',
'司空', '闾丘', '子车', '亓官', '司寇', '巫马', '公西', '颛孙', '壤驷', '公良',
'漆雕', '乐正', '宰父', '谷梁', '拓跋', '夹谷', '轩辕', '令狐', '段干', '百里',
'呼延', '东郭', '南门', '羊舌', '微生', '公户', '公玉', '公仪', '梁丘', '公仲',
'公上', '公门', '公山', '公坚', '左丘', '公伯', '西门', '公祖', '第五', '公乘',
'贯丘', '公皙', '南荣', '东里', '东宫', '仲长', '子书', '子桑', '即墨', '达奚', '褚师'
]
// 检查是否是复姓
for (const compound of compoundSurnames) {
if (name.startsWith(compound) && name.length > compound.length) {
return {
surname: compound,
givenName: name.slice(compound.length)
}
}
}
// 单姓处理
if (name.length >= 2) {
return {
surname: name.charAt(0),
givenName: name.slice(1)
}
}
// 只有一个字,作为姓
return { surname: name, givenName: '' }
}
// 递归渲染树节点 - 使用 memo 优化
const handleCreateNewRelation = (prefillName: string, relationType: RelationType) => {
if (!relationDialog) return
@@ -423,36 +346,27 @@ export function TreeLayout({
return (
<div className="relative">
<div ref={containerRef} className="p-12 min-w-fit relative" style={{ minHeight: '100%' }}>
{/* 连线层 - 使用 div 绘制 */}
<div className="absolute inset-0 pointer-events-none" style={{ zIndex: 1 }}>
{/* 连线层 - 使用 SVG path 绘制(清晰且支持动画) */}
<svg
className="absolute inset-0 pointer-events-none"
style={{ zIndex: 1, width: '100%', height: '100%', overflow: 'visible' }}
>
{connections.map((conn, index) => {
const isHorizontal = conn.type === 'horizontal'
// 使用 1px 但通过 transform scale 缩小来实现细线
const width = isHorizontal ? Math.abs(conn.to.x - conn.from.x) : 1
const height = isHorizontal ? 1 : Math.abs(conn.to.y - conn.from.y)
const left = Math.min(conn.from.x, conn.to.x)
const top = Math.min(conn.from.y, conn.to.y)
// 跳过无效的连线(宽度或高度为0)
if (width <= 0 || height <= 0) return null
const d = `M ${conn.from.x} ${conn.from.y} L ${conn.to.x} ${conn.to.y}`
return (
<div
<path
key={index}
className="absolute"
d={d}
stroke="rgba(180, 83, 9, 0.7)"
strokeWidth={1}
fill="none"
style={{
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
height: `${height}px`,
backgroundColor: 'rgba(180, 83, 9, 0.7)',
transform: isHorizontal ? 'scaleY(0.5)' : 'scaleX(0.5)',
transformOrigin: 'top left',
transition: 'stroke-dashoffset 0.3s ease-out',
}}
/>
)
})}
</div>
</svg>
{/* 树节点层 */}
<div className="relative" style={{ zIndex: 10 }} key="tree-root">