在「互動行為」的三天裡,函式庫替我們拿走了不少決定:焦點在誰身上、方向鍵往哪裡走、合約怎麼接到元素上。拿走的多寡不同,但 DOM 最後長什麼樣子,大致上還是由呼叫端決定。
但是,這些決定有沒有可能是我們非知道不可的?
今天進入最後一個設計問題:真實 DOM 的複雜度 —— 畫出來之後,你算的位置還算數嗎?
要探索這個設計問題,我們先從昨天的問題往前一步,看看讓程式事先去計算元素在瀏覽器版面上的位置,會發生什麼事?
元素的位置固然是可以先計算好,但元素真正多高,可能要等瀏覽器排版之後才知道。這兩個答案如果對不上,那會怎麼樣?
遠端世界 ✓ · 共享狀態 ✓ · 使用者輸入 ✓ · 瀏覽器 API ✓ · 互動行為 ✓ · 真實 DOM ✍️
假設我們要用程式去畫一個二十列的清單,每一列一行字,高 50px;外面包一個高 200px 的捲動容器,一次看得到四列。
既然一次只看得到四列,那另外十幾列能不能先不畫?要做到這件事,我們得在還沒畫之前,就先回答兩個問題:捲到某個位置時,該畫哪幾列;以及每一列該擺在哪裡。
沒畫出來的列,也不能讓捲軸跟著縮短。因此,我們還需要一個代表整份清單高度的空殼,撐住原本的捲動範圍。
如果每一列都一樣高,這兩個問題只需要乘法與除法。第 index 列的上緣就是 index * rowHeight;捲動了 scrollTop 之後,視窗上緣落在第 scrollTop / rowHeight 列附近,這裡我們用向下取整數的方式:
type Range = { start: number; end: number };
type FixedListOptions = {
count: number;
rowHeight: number;
overscan: number;
};
export function createFixedList({
count,
rowHeight,
overscan,
}: FixedListOptions) {
const range = (scrollTop: number, viewportHeight: number): Range => {
const first = Math.floor(scrollTop / rowHeight);
const last = Math.ceil((scrollTop + viewportHeight) / rowHeight);
return {
start: Math.max(0, first - overscan),
end: Math.min(count, last + overscan),
};
};
return {
range,
offsetOf: (index: number): number => index * rowHeight,
totalSize: (): number => count * rowHeight,
};
}
range 交回要畫的列:start 含、end 不含。overscan 是視窗上下各多畫幾列,讓捲動時邊緣不會先露出空白。totalSize 是整份清單假設的總高度,用來撐開捲動容器,讓捲軸的長度與二十列相符。
接著把它接到 React 上。容器捲動時記下 scrollTop,每次渲染只畫 range 交回的那幾列,再用絕對定位把每一列擺到 offsetOf 算出來的位置。我們在渲染時印出這次畫了哪幾列:
import { useState } from "react";
import { createFixedList } from "./fixed-list";
const ROW_HEIGHT = 50;
const VIEWPORT_HEIGHT = 200;
const rowStyle = { padding: "12px 16px", lineHeight: "26px" };
const rows = Array.from({ length: 20 }, (_, i) => `第 ${i} 列`);
const list = createFixedList({
count: rows.length,
rowHeight: ROW_HEIGHT,
overscan: 1,
});
export const List = () => {
const [scrollTop, setScrollTop] = useState(0);
const { start, end } = list.range(scrollTop, VIEWPORT_HEIGHT);
console.log(`畫出第 ${start}–${end - 1} 列`);
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}
style={{
...rowStyle,
position: "absolute",
top: list.offsetOf(index),
left: 0,
right: 0,
}}
>
{text}
</div>
);
})}
</div>
</div>
);
};
每一列沒有指定高度。上下各 12px 的內距,加上 26px 的行高,一行字剛好是 50px,與 ROW_HEIGHT 相符。
一開始畫出第 0–4 列,共 5 列;捲到 120px 時,繪製範圍變成第 1–7 列,共 7 列。
此時視窗上下邊緣都切在列的中間,所以共有 5 列碰到視窗,再加上上下各一列的過掃描 (overscan),總共畫出 7 列。
畫出第 0–4 列
畫出第 1–7 列
捲軸的長度是二十列的長度,往下捲,該出現的列都會出現在該出現的位置。目前,這份清單似乎能夠正常運作。
💡 這份
createFixedList是為了看清楚「位置從哪裡來」的最小實驗,不是 production-ready 的實作。如果是生產環境的實作,可能還要考慮捲動事件的頻率、橫向與多欄的排列、鍵盤與螢幕閱讀器如何在只畫了一部分的清單裡移動等問題。這裡刻意保持簡單。
不過,在現實情境中,反而較常見的是每列長度不一的情況,所以我們現在先改動一個地方,把第 3 列的文字變長,長到必須換行的程度。
const rows = Array.from({ length: 20 }, (_, i) =>
i === 3
? "第 3 列:這一列的文字長了許多,在三百二十像素寬的清單裡,放不進一行,只好折成好幾行來顯示。"
: `第 ${i} 列`,
);
在這次實驗的字型與字級設定下,這段文字折成三行。加上上下內距,第 3 列的高度變成 3 × 26 + 24 = 102px。從 150px 一路延伸到 252px。
可是第 4 列還是擺在 200px。第 3 列尚未結束,第 4 列就已經開始,兩列的內容因此重疊。
這是不是因為「只畫幾列」這個作法太取巧了?二十列並不多,那我們直接把二十列全部畫出來看看:
export const PlainList = () => (
<div style={{ width: 320, height: VIEWPORT_HEIGHT, overflowY: "auto" }}>
{rows.map((text, index) => (
<div key={index} style={rowStyle}>
{text}
</div>
))}
</div>
);
同樣的二十列、同樣的長文字。第 3 列一樣折成三行、長到 102px,但第 4 列這次接在它的正下方,沒有重疊。可以捲動的總高度,也跟著多了 52px。
兩份清單的差別,不在畫了幾列。
PlainList 從頭到尾沒有算過任何位置。它把二十列放進正常的文件流,瀏覽器在排版時,會依第 3 列的實際高度,把第 4 列接在後面。
List 則使用絕對定位。第 4 列的 top 已經被指定為 4 × 50,第 3 列變高,也不會把它往下推。
位置是畫之前算好的,高度卻是畫之後才知道的。
我們用二十列就足以讓問題出現:createFixedList 算的是每列 50px 的清單,瀏覽器排出來的卻不是。
第 3 列變高後,需要修正的不只第 4 列的位置。後面每一列的位置、整份清單的總高度,以及某個捲動位置應該顯示哪些列,都可能跟著改變。
只畫視窗附近的幾列、其餘的位置用算的,這種作法叫做虛擬化 (virtualization)。清單一長,它可以省下大量的 DOM 節點。但它省下的方式,正是在還沒畫之前,就先替每一列回答「你在哪裡」。
createFixedList 的答案,都建立在一個假設上:每一列都是 rowHeight 那麼高。
回頭看這份實作,它從瀏覽器那裡拿到的只有 scrollTop。第 3 列實際畫成多高,瀏覽器知道,createFixedList 卻沒有任何地方可以收下這個數字。所以假設不成立的時候,它也無從得知,只會繼續把第 4 列擺在 200px。
而讓假設失效的,也不只是第一次畫出來的那一刻。容器變窄,同一段文字可能多折一行;網頁字型晚一點載入,每一行的寬度可能跟著改變;某一列的內容被編輯、圖片載入完成,那一列的高度也可能在幾秒之後才變。每一次,算好的位置都可能悄悄地變成錯的,而清單本身沒有收到任何通知。
一列多高,往往要等它畫出來之後,瀏覽器才知道。
要讓位置繼續算數,一個方法是請知道答案的人來告訴它。有些設計選擇直接把這個問題留在呼叫端。
但即使是呼叫端,多數時候也是畫出來之後才知道。這份清單的位置要算數,它就得自己去問瀏覽器:每一列真正多高。
問到之後,還得把這個高度送回計算位置的那一側。
先用估計的高度算位置,畫出幾列;量到實際高度後,再修正位置。可是位置一改,視窗裡需要畫的列也可能跟著改,接著又有新的高度要量。
量測不只是補上一個數字。它讓「算位置」與「畫出元素」開始互相影響。
這個循環會停下來嗎?
本文的實驗跑在
react19.3.0 與react-dom19.3.0,查核於 2026-10-08。