perf(supply-chain): 产品全量档案表体行虚拟化(视口窗口渲染+占位行)
This commit is contained in:
parent
6363ed87b7
commit
685f3cc756
@ -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;
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user