iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0
Modern Web

再造輪子:30 天臨摹 React Hook 函式庫,探索背後的設計哲學系列 第 24 篇

【 Day 23 】為什麼我剛畫出來的東西,會讓我剛算好的位置變成錯的?|真實 DOM(一)

  • 分享至 

  • xImage
  •  

在「互動行為」的三天裡,函式庫替我們拿走了不少決定:焦點在誰身上、方向鍵往哪裡走、合約怎麼接到元素上。拿走的多寡不同,但 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。

而讓假設失效的,也不只是第一次畫出來的那一刻。容器變窄,同一段文字可能多折一行;網頁字型晚一點載入,每一行的寬度可能跟著改變;某一列的內容被編輯、圖片載入完成,那一列的高度也可能在幾秒之後才變。每一次,算好的位置都可能悄悄地變成錯的,而清單本身沒有收到任何通知。

一列多高,往往要等它畫出來之後,瀏覽器才知道。

要讓位置繼續算數,一個方法是請知道答案的人來告訴它。有些設計選擇直接把這個問題留在呼叫端。

但即使是呼叫端,多數時候也是畫出來之後才知道。這份清單的位置要算數,它就得自己去問瀏覽器:每一列真正多高。

問到之後,還得把這個高度送回計算位置的那一側。

先用估計的高度算位置,畫出幾列;量到實際高度後,再修正位置。可是位置一改,視窗裡需要畫的列也可能跟著改,接著又有新的高度要量。

量測不只是補上一個數字。它讓「算位置」與「畫出元素」開始互相影響。

這個循環會停下來嗎?

本文的實驗跑在 react 19.3.0 與 react-dom 19.3.0,查核於 2026-10-08。


上一篇
【 Day 22 】用了元件樹,為什麼還需要一個把它打洞的 API?|互動行為(三完)
系列文
再造輪子:30 天臨摹 React Hook 函式庫,探索背後的設計哲學 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言