perf(supply-chain): 产品全量档案表体行虚拟化(视口窗口渲染+占位行)

This commit is contained in:
WuYongJie 2026-10-08 08:50:19 +08:00
parent 6363ed87b7
commit 685f3cc756
4 changed files with 157 additions and 2 deletions

View File

@ -5,6 +5,7 @@ import type { ArchiveColumnGroup } from './columns'
import { ProductArchiveActions } from './ProductArchiveActions'
import { ProductArchiveMaterialImageCell } from './ProductArchiveMaterialImageCell'
import type { ProductArchivePageImageState } from './useProductArchivePageImages'
import { ARCHIVE_ROW_HEIGHT, type ArchiveRowWindow } from './useArchiveRowWindowing'
const IDLE_IMAGE_STATE: ProductArchivePageImageState = {
status: 'idle',
@ -15,6 +16,8 @@ type ProductArchiveTableBodyProps = {
rows: SupplyChainProductArchiveRow[]
columns: ArchiveColumnGroup[]
startIndex: number
/** 行虚拟化窗口:只渲染 [start, end) 的行,其余用占位行撑高。 */
rowWindow: ArchiveRowWindow
imageStates: Record<string, ProductArchivePageImageState>
onImageVisible: (archiveId: string) => void
onPreviewImage: (image: string, title: string) => void
@ -57,6 +60,8 @@ function areTableBodyPropsEqual(
return previous.rows === next.rows
&& previous.startIndex === next.startIndex
&& previous.rowWindow.start === next.rowWindow.start
&& previous.rowWindow.end === next.rowWindow.end
&& previous.onImageVisible === next.onImageVisible
&& previous.onPreviewImage === next.onPreviewImage
&& previous.onViewRow === next.onViewRow
@ -94,17 +99,29 @@ export const ProductArchiveTableBody = memo(function ProductArchiveTableBody({
rows,
columns,
startIndex,
rowWindow,
imageStates,
onImageVisible,
onPreviewImage,
onViewRow,
onOpenChangeLog,
}: ProductArchiveTableBodyProps) {
const columnCount = columns.reduce((count, group) => count + group.columns.length, 0)
const spacerColSpan = columnCount + 2
const visibleRows = rows.slice(rowWindow.start, rowWindow.end)
const topPadHeight = rowWindow.start * ARCHIVE_ROW_HEIGHT
const bottomPadHeight = Math.max(0, rows.length - rowWindow.end) * ARCHIVE_ROW_HEIGHT
return (
<tbody>
{rows.map((row, rowIndex) => (
{topPadHeight > 0 && (
<tr className="archive-tr-window-pad" aria-hidden="true" style={{ height: topPadHeight }}>
<td colSpan={spacerColSpan} />
</tr>
)}
{visibleRows.map((row, rowIndex) => (
<tr key={row.id}>
<td className="archive-td-index">{startIndex + rowIndex + 1}</td>
<td className="archive-td-index">{startIndex + rowWindow.start + rowIndex + 1}</td>
{columns.map((group) =>
group.columns.map((column) => {
const value = renderCell(
@ -139,12 +156,23 @@ export const ProductArchiveTableBody = memo(function ProductArchiveTableBody({
</td>
</tr>
))}
{bottomPadHeight > 0 && (
<tr className="archive-tr-window-pad" aria-hidden="true" style={{ height: bottomPadHeight }}>
<td colSpan={spacerColSpan} />
</tr>
)}
<style jsx>{`
.archive-tr-window-pad td {
height: 100%;
padding: 0;
border: 0;
}
.archive-td-index {
position: sticky;
left: 0;
z-index: 2;
height: 58px;
min-width: 60px;
padding: 8px;
border: 0;
@ -174,6 +202,7 @@ export const ProductArchiveTableBody = memo(function ProductArchiveTableBody({
position: sticky;
right: 0;
z-index: 3;
height: 58px;
min-width: 96px;
padding: 8px 10px;
overflow: visible;

View File

@ -11,6 +11,7 @@ import { ProductArchiveHeaderFilter } from './ProductArchiveHeaderFilter'
import { ProductArchiveImagePreview } from './ProductArchiveImagePreview'
import { ProductArchiveImageSearchDialog } from './ProductArchiveImageSearchDialog'
import { useArchiveColumnResize } from './useArchiveColumnResize'
import { useArchiveRowWindowing } from './useArchiveRowWindowing'
import { useArchiveScrollbarWheel } from './useArchiveScrollbarWheel'
import { parseProductArchiveFilters, useProductArchiveViewPreferences } from './useProductArchiveViewPreferences'
import { ProductArchiveDetailDialog } from './ProductArchiveDetailDialog'
@ -79,6 +80,8 @@ export function ProductArchiveView({ canDownload = false }: { canDownload?: bool
() => (loading || error ? [] : rows),
[error, loading, rows],
)
// 行虚拟化:表体只渲染滚动视口内的行,列设置/翻页不再整页重建 DOM。
const rowWindow = useArchiveRowWindowing(tableWrapRef, displayRows.length)
const imageColumnVisible = visibleColumns.some(({ column }) => column.prop === 'image')
const materialImageLookupEnabled = imageColumnVisible && Boolean(filters.spu?.length)
const { states: imageStates, markVisible: markImageVisible } = useProductArchivePageImages(
@ -271,6 +274,7 @@ export function ProductArchiveView({ canDownload = false }: { canDownload?: bool
rows={displayRows}
columns={effectiveColumns}
startIndex={(currentPage - 1) * pageSize}
rowWindow={rowWindow}
imageStates={imageStates}
onImageVisible={markImageVisible}
onPreviewImage={previewImage}

View File

@ -0,0 +1,47 @@
import {
ARCHIVE_ROW_HEIGHT,
ARCHIVE_ROW_OVERSCAN,
computeArchiveRowWindow,
} from './useArchiveRowWindowing'
describe('computeArchiveRowWindow', () => {
it('returns an empty window when there are no rows', () => {
expect(computeArchiveRowWindow({ scrollTop: 0, viewportHeight: 600, totalRows: 0 }))
.toEqual({ start: 0, end: 0 })
})
it('renders the first viewport plus overscan at the top', () => {
// 视口 580px = 58px × 10 行整,向上 overscan 被 0 截断。
const window = computeArchiveRowWindow({ scrollTop: 0, viewportHeight: 580, totalRows: 50 })
expect(window.start).toBe(0)
expect(window.end).toBe(10 + ARCHIVE_ROW_OVERSCAN)
})
it('shifts the window with overscan on both sides while scrolling', () => {
const scrollTop = 20 * ARCHIVE_ROW_HEIGHT
const window = computeArchiveRowWindow({ scrollTop, viewportHeight: 580, totalRows: 100 })
expect(window.start).toBe(20 - ARCHIVE_ROW_OVERSCAN)
expect(window.end).toBe(20 + 10 + ARCHIVE_ROW_OVERSCAN)
})
it('clamps the window to the last row', () => {
const window = computeArchiveRowWindow({
scrollTop: 49 * ARCHIVE_ROW_HEIGHT,
viewportHeight: 580,
totalRows: 50,
})
expect(window.end).toBe(50)
expect(window.start).toBeLessThan(window.end)
})
it('stays valid when scrollTop exceeds the content (rows just shrank)', () => {
const window = computeArchiveRowWindow({
scrollTop: 100 * ARCHIVE_ROW_HEIGHT,
viewportHeight: 580,
totalRows: 10,
})
expect(window.start).toBeLessThanOrEqual(10)
expect(window.end).toBeLessThanOrEqual(10)
expect(window.end).toBeGreaterThanOrEqual(window.start)
})
})

View File

@ -0,0 +1,75 @@
'use client'
import { useEffect, useState, type RefObject } from 'react'
/** 行高固定(.archive-td-field / .archive-td-index / .archive-td-actions 的 height)。 */
export const ARCHIVE_ROW_HEIGHT = 58
/** 视口上下各多渲染几行,快速滚动不露白。 */
export const ARCHIVE_ROW_OVERSCAN = 6
export type ArchiveRowWindow = { start: number; end: number }
/** 纯计算:给定滚动位置与视口高度算出要渲染的行区间。导出供单测。 */
export function computeArchiveRowWindow(input: {
scrollTop: number
viewportHeight: number
totalRows: number
rowHeight?: number
overscan?: number
}): ArchiveRowWindow {
const rowHeight = input.rowHeight ?? ARCHIVE_ROW_HEIGHT
const overscan = input.overscan ?? ARCHIVE_ROW_OVERSCAN
const total = Math.max(0, Math.floor(input.totalRows))
if (total === 0) return { start: 0, end: 0 }
const firstVisible = Math.floor(Math.max(0, input.scrollTop) / rowHeight)
const visibleCount = Math.max(1, Math.ceil(Math.max(0, input.viewportHeight) / rowHeight))
const start = Math.min(Math.max(0, firstVisible - overscan), total)
const end = Math.min(total, Math.max(start, firstVisible + visibleCount + overscan))
return { start, end }
}
/**
* 行虚拟化窗口:监听滚动容器的 scroll/resize(rAF 节流),
* 只把 [start, end) 区间交给表体渲染,其余行用占位行撑高。
*/
export function useArchiveRowWindowing(
containerRef: RefObject<HTMLElement | null>,
totalRows: number,
) {
const [rowWindow, setRowWindow] = useState<ArchiveRowWindow>(() =>
computeArchiveRowWindow({ scrollTop: 0, viewportHeight: 0, totalRows }))
useEffect(() => {
const container = containerRef.current
if (!container) return
let frame = 0
const update = () => {
frame = 0
const next = computeArchiveRowWindow({
scrollTop: container.scrollTop,
viewportHeight: container.clientHeight,
totalRows,
})
setRowWindow((current) => (current.start === next.start && current.end === next.end ? current : next))
}
const schedule = () => {
if (!frame) frame = requestAnimationFrame(update)
}
const resizeObserver = new ResizeObserver(schedule)
resizeObserver.observe(container)
container.addEventListener('scroll', schedule, { passive: true })
update()
return () => {
container.removeEventListener('scroll', schedule)
resizeObserver.disconnect()
if (frame) cancelAnimationFrame(frame)
}
// totalRows 入依赖:翻页/筛选改变行数时不等滚动事件就重算窗口。
}, [containerRef, totalRows])
return rowWindow
}