Skip to content

Commit 39ae280

Browse files
committed
feat: optimize the table for large data
1 parent 8e5453e commit 39ae280

File tree

5 files changed

+410
-42
lines changed

5 files changed

+410
-42
lines changed
Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
import React, {
2+
useState,
3+
useCallback,
4+
ReactElement,
5+
useRef,
6+
useEffect,
7+
useMemo,
8+
} from 'react';
9+
import styles from './styles.module.css';
10+
11+
interface OptimizeTableProps {
12+
data: unknown[][];
13+
headers: { name: string; initialSize: number; resizable?: boolean }[];
14+
renderCell: (y: number, x: number) => ReactElement;
15+
rowHeight: number;
16+
renderAhead: number;
17+
}
18+
19+
function ResizeHandler({
20+
idx,
21+
onResize,
22+
}: {
23+
idx: number;
24+
onResize: (idx: number, newSize: number) => void;
25+
}) {
26+
const handlerRef = useRef<HTMLDivElement>(null);
27+
const [resizing, setResizing] = useState(false);
28+
29+
useEffect(() => {
30+
if (handlerRef.current && resizing) {
31+
const table = handlerRef.current?.parentNode?.parentNode?.parentNode
32+
?.parentNode as HTMLTableElement;
33+
34+
if (table) {
35+
const onMouseMove = (e: MouseEvent) =>
36+
requestAnimationFrame(() => {
37+
const scrollOffset = table.scrollLeft;
38+
const width =
39+
scrollOffset +
40+
e.clientX -
41+
((
42+
handlerRef.current?.parentNode as HTMLTableCellElement
43+
).getBoundingClientRect().x || 0);
44+
45+
onResize(idx, width);
46+
47+
if (table) {
48+
const columns = table.style.gridTemplateColumns.split(' ');
49+
columns[idx] = width + 'px';
50+
table.style.gridTemplateColumns = columns.join(' ');
51+
}
52+
});
53+
54+
const onMouseUp = () => {
55+
setResizing(false);
56+
};
57+
58+
table.addEventListener('mousemove', onMouseMove);
59+
table.addEventListener('mouseup', onMouseUp);
60+
61+
return () => {
62+
table.removeEventListener('mousemove', onMouseMove);
63+
table.removeEventListener('mouseup', onMouseUp);
64+
};
65+
}
66+
}
67+
}, [handlerRef, idx, resizing, setResizing, onResize]);
68+
69+
return (
70+
<div
71+
className={styles.resizer}
72+
ref={handlerRef}
73+
onMouseDown={() => setResizing(true)}
74+
></div>
75+
);
76+
}
77+
78+
export default function OptimizeTable({
79+
data,
80+
headers,
81+
renderCell,
82+
rowHeight,
83+
renderAhead,
84+
}: OptimizeTableProps) {
85+
const containerRef = useRef<HTMLDivElement>(null);
86+
87+
const [visibleDebounce, setVisibleDebounce] = useState<{
88+
rowStart: number;
89+
rowEnd: number;
90+
colStart: number;
91+
colEnd: number;
92+
}>({
93+
rowStart: 0,
94+
rowEnd: 0,
95+
colStart: 0,
96+
colEnd: 0,
97+
});
98+
99+
const [headerSizes] = useState(() => {
100+
return headers.map((header) => header.initialSize);
101+
});
102+
103+
const recalculateVisible = useCallback(
104+
(e: HTMLDivElement) => {
105+
const currentRowStart = Math.max(
106+
0,
107+
Math.floor(e.scrollTop / rowHeight) - 1 - renderAhead
108+
);
109+
const currentRowEnd = Math.min(
110+
data.length,
111+
currentRowStart +
112+
Math.ceil(e.getBoundingClientRect().height / rowHeight) +
113+
renderAhead
114+
);
115+
116+
let currentColStart = -1;
117+
let currentColAccumulateSize = 0;
118+
let currentColEnd = -1;
119+
120+
const visibleXStart = e.scrollLeft;
121+
const visibleXEnd = visibleXStart + e.getBoundingClientRect().width;
122+
123+
for (let i = 0; i < headerSizes.length; i++) {
124+
if (currentColAccumulateSize >= visibleXStart && currentColStart < 0) {
125+
currentColStart = i - 1;
126+
}
127+
128+
currentColAccumulateSize += headerSizes[i];
129+
130+
if (currentColAccumulateSize >= visibleXEnd && currentColEnd < 0) {
131+
currentColEnd = i;
132+
break;
133+
}
134+
}
135+
136+
if (currentColEnd < 0) currentColEnd = headerSizes.length - 1;
137+
if (currentColStart < 0) currentColStart = 0;
138+
if (currentColEnd >= headerSizes.length)
139+
currentColEnd = headerSizes.length - 1;
140+
141+
setVisibleDebounce({
142+
rowEnd: currentRowEnd,
143+
rowStart: currentRowStart,
144+
colStart: currentColStart,
145+
colEnd: currentColEnd,
146+
});
147+
},
148+
[setVisibleDebounce, data, rowHeight, renderAhead, headerSizes]
149+
);
150+
151+
const onHeaderResize = useCallback(
152+
(idx: number, newWidth: number) => {
153+
if (containerRef.current) {
154+
headerSizes[idx] = newWidth;
155+
recalculateVisible(containerRef.current);
156+
}
157+
},
158+
[headerSizes, recalculateVisible, containerRef]
159+
);
160+
161+
const onScroll = useCallback(
162+
(e: React.UIEvent<HTMLDivElement, UIEvent>) => {
163+
recalculateVisible(e.currentTarget);
164+
165+
e.preventDefault();
166+
e.stopPropagation();
167+
},
168+
[recalculateVisible]
169+
);
170+
171+
useEffect(() => {
172+
if (containerRef.current) {
173+
recalculateVisible(containerRef.current);
174+
}
175+
}, [containerRef, recalculateVisible]);
176+
177+
useEffect(() => {
178+
if (containerRef.current) {
179+
const resizeObserver = new ResizeObserver((entries) => {
180+
for (const entry of entries) {
181+
recalculateVisible(entry.target as HTMLDivElement);
182+
}
183+
});
184+
185+
resizeObserver.observe(containerRef.current);
186+
return () => resizeObserver.disconnect();
187+
}
188+
}, [containerRef, recalculateVisible]);
189+
190+
return useMemo(() => {
191+
const paddingTop = visibleDebounce.rowStart * rowHeight;
192+
const paddingBottom = (data.length - visibleDebounce.rowEnd) * rowHeight;
193+
194+
const windowArray = new Array(
195+
visibleDebounce.rowEnd - visibleDebounce.rowStart
196+
)
197+
.fill(false)
198+
.map(() => new Array(data[0].length).fill(false));
199+
200+
const cells = windowArray.map((row, rowIndex) => {
201+
return (
202+
<tr key={rowIndex + visibleDebounce.rowStart}>
203+
{visibleDebounce.colStart > 0 && (
204+
<td
205+
style={{
206+
gridColumn: `span ${visibleDebounce.colStart}`,
207+
}}
208+
/>
209+
)}
210+
{row
211+
.slice(visibleDebounce.colStart, visibleDebounce.colEnd + 1)
212+
.map((_, cellIndex) => (
213+
<td key={cellIndex + visibleDebounce.colStart}>
214+
<div className={styles.tableCellContent}>
215+
{renderCell(
216+
rowIndex + visibleDebounce.rowStart,
217+
cellIndex + visibleDebounce.colStart
218+
)}
219+
</div>
220+
</td>
221+
))}
222+
{visibleDebounce.colEnd < headerSizes.length - 1 && (
223+
<td
224+
style={{
225+
gridColumn: `span ${
226+
headerSizes.length - 1 - visibleDebounce.colEnd
227+
}`,
228+
}}
229+
/>
230+
)}
231+
</tr>
232+
);
233+
});
234+
235+
return (
236+
<div
237+
ref={containerRef}
238+
className={styles.tableContainer}
239+
onScroll={onScroll}
240+
>
241+
<div
242+
style={{
243+
height: (data.length + 1) * rowHeight + 10,
244+
overflow: 'hidden',
245+
}}
246+
>
247+
<table
248+
style={{
249+
gridTemplateColumns: headerSizes
250+
.map((header) => header + 'px')
251+
.join(' '),
252+
}}
253+
>
254+
{/* This is table header */}
255+
<thead>
256+
<tr>
257+
{headers.map((header, idx) => (
258+
<th key={header.name}>
259+
<div className={styles.tableCellContent}>{header.name}</div>
260+
{header.resizable && (
261+
<ResizeHandler idx={idx} onResize={onHeaderResize} />
262+
)}
263+
</th>
264+
))}
265+
</tr>
266+
</thead>
267+
268+
<tbody>
269+
{/* Add enough top padding to replace the row that we don't render */}
270+
{!!paddingTop && (
271+
<tr key="padding-top">
272+
<td
273+
style={{
274+
height: paddingTop,
275+
gridColumn: `span ${headers.length}`,
276+
}}
277+
/>
278+
</tr>
279+
)}
280+
281+
{cells}
282+
283+
{/* Add enough bottom padding to replace the row that we don't render */}
284+
{!!paddingBottom && (
285+
<tr key="padding-bottom">
286+
<td
287+
style={{
288+
height: paddingBottom,
289+
gridColumn: `span ${headers.length}`,
290+
}}
291+
></td>
292+
</tr>
293+
)}
294+
</tbody>
295+
</table>
296+
</div>
297+
</div>
298+
);
299+
}, [
300+
visibleDebounce.rowEnd,
301+
visibleDebounce.rowStart,
302+
visibleDebounce.colEnd,
303+
visibleDebounce.colStart,
304+
data,
305+
renderCell,
306+
headerSizes,
307+
onScroll,
308+
rowHeight,
309+
headers,
310+
onHeaderResize,
311+
]);
312+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
.tableContainer {
2+
width: 100%;
3+
height: 100%;
4+
overflow: auto;
5+
position: relative;
6+
}
7+
8+
.tableContainer table {
9+
position: absolute;
10+
display: grid;
11+
border-collapse: collapse;
12+
left: 0;
13+
top: 0;
14+
box-sizing: border-box;
15+
table-layout: fixed;
16+
}
17+
18+
.tableContainer tr,
19+
.tableContainer thead,
20+
.tableContainer tbody {
21+
display: contents;
22+
}
23+
24+
.tableContainer td,
25+
.tableContainer th {
26+
border-bottom: 1px solid var(--color-table-grid);
27+
border-right: 1px solid var(--color-table-grid);
28+
overflow: hidden;
29+
}
30+
31+
.tableCellContent {
32+
overflow: hidden;
33+
white-space: nowrap;
34+
}
35+
36+
.tableContainer th {
37+
position: sticky;
38+
top: 0;
39+
background: #fff;
40+
user-select: none;
41+
padding: 0px 10px;
42+
height: 35px;
43+
line-height: 35px;
44+
}
45+
46+
.resizer {
47+
position: absolute;
48+
right: 0;
49+
top: 0;
50+
bottom: 0;
51+
width: 3px;
52+
cursor: col-resize;
53+
background: #800;
54+
opacity: 0;
55+
}
56+
57+
.resizer:hover {
58+
opacity: 0.5;
59+
}

0 commit comments

Comments
 (0)