〈誰被觀察?量到的尺寸又怎麼記?〉用到的完整程式碼。文章裡只貼每一處改動的片段,這裡把它們組回可以直接執行的檔案。
npm create vite@latest day-24 -- --template react-tssrc/ 換成下面的五個檔案。npm run dev,打開瀏覽器的主控台。網址不帶 hash 是 List,帶 #container 是 ContainerList。文中的數字(一行字的列 50px、第 3 列 102px)取決於字型與寬度。要對上文章的輸出,index.html 的 <body> 請用 PingFang TC、16px,並把分頁留在前景:分頁在背景時,ResizeObserver 不會回呼。
measured-list.ts建立清單、計算位置與要畫的範圍,尺寸存在從外面傳進來的 sizes。
type Sizes = {
get(index: number): number | undefined;
set(index: number, size: number): void;
readonly size: number;
};
type Range = { start: number; end: number };
type MeasuredListOptions = {
count: number;
estimateSize: number;
overscan: number;
sizes: Sizes;
};
export function createMeasuredList({
count,
estimateSize,
overscan,
sizes,
}: MeasuredListOptions) {
const sizeOf = (index: number): number => sizes.get(index) ?? estimateSize;
const offsetOf = (index: number): number => {
let offset = 0;
for (let i = 0; i < index; i += 1) offset += sizeOf(i);
return offset;
};
const range = (scrollTop: number, viewportHeight: number): Range => {
let first = 0;
while (first < count - 1 && offsetOf(first + 1) <= scrollTop) first += 1;
let last = first;
while (last < count && offsetOf(last) < scrollTop + viewportHeight)
last += 1;
return {
start: Math.max(0, first - overscan),
end: Math.min(count, last + overscan),
};
};
return {
range,
sizeOf,
offsetOf,
totalSize: (): number => offsetOf(count),
measure: (index: number, size: number): void => {
sizes.set(index, size);
},
};
}
List.tsx每一列各自被 ResizeObserver 看著。
import { useCallback, useReducer, useState } from "react";
import { createMeasuredList } from "./measured-list";
const VIEWPORT_HEIGHT = 200;
const rowStyle = { padding: "12px 16px", lineHeight: "26px" };
const rows = Array.from({ length: 20 }, (_, i) =>
i === 3
? "第 3 列:這一列的文字長了許多,在三百二十像素寬的清單裡,放不進一行,只好折成好幾行來顯示。"
: `第 ${i} 列`,
);
const sizes = new Map<number, number>();
const list = createMeasuredList({
count: rows.length,
estimateSize: 40,
overscan: 1,
sizes,
});
export const List = () => {
const [scrollTop, setScrollTop] = useState(0);
const [, rerender] = useReducer((n: number) => n + 1, 0);
const [observer] = useState(
() =>
new ResizeObserver((entries) => {
for (const entry of entries) {
const row = entry.target as HTMLElement;
const index = Number(row.dataset.index);
const size = entry.borderBoxSize[0]?.blockSize;
if (size === undefined) continue;
console.log(`量到第 ${index} 列:${size}px`);
list.measure(index, size);
}
rerender();
}),
);
const observe = useCallback(
(row: HTMLDivElement) => {
observer.observe(row, { box: "border-box" });
return () => observer.unobserve(row);
},
[observer],
);
const { start, end } = list.range(scrollTop, VIEWPORT_HEIGHT);
console.log(`畫出第 ${start}–${end - 1} 列,記下 ${sizes.size} 筆`);
return (
<div
style={{ width: 320, height: VIEWPORT_HEIGHT, overflowY: "auto" }}
onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}
>
<div style={{ position: "relative", height: list.totalSize() }}>
{rows.slice(start, end).map((text, i) => {
const index = start + i;
return (
<div
key={index}
ref={observe}
data-index={index}
style={{
...rowStyle,
position: "absolute",
top: list.offsetOf(index),
left: 0,
right: 0,
}}
>
{text}
</div>
);
})}
</div>
</div>
);
};
ContainerList.tsx只觀察裝著列的容器,走訪子元素讀 offsetHeight,與 data-known-size 比對。
import { useCallback, useReducer, useState } from "react";
import { createMeasuredList } from "./measured-list";
const VIEWPORT_HEIGHT = 200;
const rowStyle = { padding: "12px 16px", lineHeight: "26px" };
const rows = Array.from({ length: 20 }, (_, i) =>
i === 3
? "第 3 列:這一列的文字長了許多,在三百二十像素寬的清單裡,放不進一行,只好折成好幾行來顯示。"
: `第 ${i} 列`,
);
const sizes = new Map<number, number>();
const list = createMeasuredList({
count: rows.length,
estimateSize: 40,
overscan: 1,
sizes,
});
export const ContainerList = () => {
const [scrollTop, setScrollTop] = useState(0);
const [, rerender] = useReducer((n: number) => n + 1, 0);
const [observer] = useState(
() =>
new ResizeObserver((entries) => {
const container = entries[0]?.target;
if (!(container instanceof HTMLElement)) return;
let reported = 0;
for (const child of container.children) {
const row = child as HTMLElement;
const size = row.offsetHeight;
if (size === Number(row.dataset.knownSize)) continue;
list.measure(Number(row.dataset.index), size);
reported += 1;
}
console.log(
`讀了 ${container.children.length} 列,回報 ${reported} 列`,
);
if (reported > 0) rerender();
}),
);
const observe = useCallback(
(container: HTMLDivElement) => {
observer.observe(container, { box: "border-box" });
return () => observer.unobserve(container);
},
[observer],
);
const { start, end } = list.range(scrollTop, VIEWPORT_HEIGHT);
console.log(`畫出第 ${start}–${end - 1} 列,記下 ${sizes.size} 筆`);
return (
<div
style={{ width: 320, height: VIEWPORT_HEIGHT, overflowY: "auto" }}
onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}
>
<div style={{ height: list.totalSize() }}>
<div ref={observe} style={{ paddingTop: list.offsetOf(start) }}>
{rows.slice(start, end).map((text, i) => {
const index = start + i;
return (
<div
key={index}
data-index={index}
data-known-size={list.sizeOf(index)}
style={rowStyle}
>
{text}
</div>
);
})}
</div>
</div>
</div>
);
};
main.tsximport { createRoot } from "react-dom/client";
import { ContainerList } from "./ContainerList";
import { List } from "./List";
createRoot(document.getElementById("root")!).render(
location.hash === "#container" ? <ContainerList /> : <List />,
);
range-sizes.ts