〈誰被觀察?量到的尺寸又怎麼記?〉用到的完整程式碼。文章裡只貼每一處改動的片段,這裡把它們組回可以直接執行的檔案。

怎麼跑

  1. 用 Vite 建立 React + TypeScript 專案:npm create vite@latest day-24 -- --template react-ts
  2. 把 src/ 換成下面的五個檔案。
  3. 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.tsx

import { 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