• 浮動層(對話框、下拉選單、浮動視窗)為什麼幾乎都用 ?它搬走了什麼、留下了什麼?

  • 拖曳這種每秒上百次事件的互動,狀態該怎麼設計才不會拖垮渲染?

  • 如何讓拖曳不需要 effect?什麼時候仍然該用 effect 版?

  • 多個視窗的疊層順序誰說了算?「點一下置頂」怎麼做才不弄髒渲染的純粹性?

前面章節的元件,工作大多是「資料進、畫面出」;浮動視窗是另一種生物—— 它要跟瀏覽器的座標系統、指標事件、疊層順序打交道,還得在高頻互動下保持流暢。 這類元件最容易寫成「能動就好」的一團事件處理器,也因此最能檢驗你對 React 模型的掌握。 這一章從需求開始,完整打造一個 : 可拖曳、可縮放、可堆疊、支援鍵盤與觸控。 每一步都不是新魔法,而是前七章觀念的落地。 建議打開導讀提到的實驗專案跟著做——這章的程式碼全部可以直接執行。

需求與介面:先設計「用起來的樣子」

需求清單,來自浮動視窗在真實產品裡的典型用途 (樓層平面圖、迷你播放器、客服對話、監控面板):

  • 內容任意——它是個容器,裝什麼由使用端決定。

  • 標題列可拖曳移動;右下角可拖曳縮放;有關閉鍵。

  • 可以同時開好幾個;點到哪個,哪個浮到最上面。

  • 拖不出視口太遠(至少留一角抓得回來);按 ESC 可關閉;觸控裝置也能拖。

第 5 章的順序在這裡直接套用:先寫使用端想像圖,再回頭實作。

// 使用端想像圖:先寫這段,再實作 FloatingWindow
function ReportPage() {
  const [showPlan, setShowPlan] = useState(false);
  return (
    <>
      <button onClick={() => setShowPlan(true)}>樓層平面圖</button>
      {showPlan && (
        <FloatingWindow
          title="樓層平面圖"
          defaultPosition={{ x: 120, y: 96 }}
          onClose={() => setShowPlan(false)}
        >
          <FloorPlan floor={3} />
        </FloatingWindow>
      )}
    </>
  );
}

從想像圖反推出 props 介面:

export interface Point { x: number; y: number }
export interface Size  { width: number; height: number }

export interface FloatingWindowProps {
  title: string;
  onClose: () => void;          // 關閉的決定權在外面:視窗只回報意圖
  defaultPosition?: Point;      // 非受控:位置由視窗自己管
  defaultSize?: Size;
  minSize?: Size;               // 縮放的下限
  children: React.ReactNode;    // 內容是插槽(第 5 章)
}

這份介面裡藏著兩個第 5 章「受控/非受控」的決策,值得攤開講:

視窗的「存在」是受控的。 沒有 這種內部開關—— 開著與否由父層的條件渲染決定,視窗只透過 回報「使用者想關」。 理由:開關幾乎必然要跟外部聯動(按鈕、路由、快捷鍵),主導權該在外面; 而且「關閉=卸載」讓內部狀態自然歸零(第 3 章的協調規則), 下次打開回到初始位置——「記住上次位置」是之後的擴充,8.10 節再談。

位置與尺寸是非受控的。 多數使用端根本不想指揮它們,給個 起步就好。 刻意取名 而不是 , 是替未來的受控介面(+)留下名字空間—— 成熟元件庫 / 雙介面的慣例(第 5 章)。

Portal:DOM 出走,資料流留下

第一個實作決策跟畫面無關,跟「住址」有關。 在 React 樹裡自然住在擁有它的功能底下 (上例的 ),資料才拿得順手。 但它的 DOM 節點若也住在 的版面裡,三件麻煩會找上門:

  • 祖先的 會把拖出去的視窗裁掉。

  • 祖先建立的 stacking context 會困住 —— 它只在同一個 stacking context 內比大小,內層寫 9999 也翻不出去。

  • 冷知識:祖先有 (或 )時, 會改以那個祖先為定位基準—— 「fixed 突然不 fixed 了」的元兇多半是它。

「元件樹上住在邏輯歸屬處」與「DOM 上浮在整頁頂層」是兩個矛盾的需求, 正是解這個矛盾的工具:

import { createPortal } from 'react-dom';

function FloatingWindow(props: FloatingWindowProps) {
  return createPortal(
    <div className="floating-window">...</div>,   // 畫面,接下來逐步填入
    document.body,                                // DOM 的落點
  );
}
  • 狀態、props、context 全部照 React 樹走: 照常讀得到 之上的任何 Provider。

  • 事件也照 React 樹冒泡:視窗內的點擊會冒泡到 的處理器——即使 DOM 上兩者毫無祖孫關係。

  • 所以 portal 元件寫起來與一般元件沒有任何差別; 差別只發生在瀏覽器的排版、裁切與疊層。 這也是元件庫的對話框、下拉選單、tooltip 全家都 portal 的原因。

(1)SSR 專案裡, 在伺服器端不存在, 直接寫 會在伺服器渲染時炸掉。 慣用解法是「掛載後才渲染 portal」:用一個 effect 把 設為 true, 之前先回傳 。單頁應用沒有這個問題。 (2)許多專案不直接掛 ,而是掛到自己準備的 : 方便統一控制浮動層的順序、樣式作用域與測試時的清理。機制完全相同。

位置狀態與第一版拖曳:同步模型的推導

位置與尺寸會直接反映在畫面上——第 4 章的判準(值變了,畫面要跟著變嗎?) 答案是「要」,所以是 state:

function FloatingWindow({
  title, onClose, children,
  defaultPosition = { x: 80, y: 80 },
  defaultSize = { width: 360, height: 260 },
}: FloatingWindowProps) {
  // 初始值已能代表完整型別,不需要手動標註(第 7 章)
  const [position, setPosition] = useState(defaultPosition);
  const [size, setSize] = useState(defaultSize);

  return createPortal(
    <div
      className="floating-window"   // 陰影、圓角等外觀交給 CSS,這裡只放機制
      style={{
        position: 'fixed', left: 0, top: 0,
        transform: `translate(${position.x}px, ${position.y}px)`,
        width: size.width, height: size.height,
      }}
    >
      <header className="fw-titlebar">{title}</header>
      <main className="fw-content">{children}</main>
    </div>,
    document.body,
  );
}

兩個容易被忽略的決定:

  • 用 移動,而不是改 /。 後者參與排版,每次改動都觸發瀏覽器重新 layout; 只動合成層。拖曳每秒改上百次位置,這筆帳值得算。 狀態()是畫面事實, 只是呈現手段——兩者分離。

  • 的初始值只在首次渲染被讀取, 之後父層再改這個 prop 也不會跟著動——這正是「非受控」在程式碼上的意思。

接著讓標題列動起來。拖曳是三段式的手勢: 按下(記住「抓住點離視窗左上角多遠」)→ 移動(指標座標減抓點偏移=視窗新位置)→ 放開。 按下發生在標題列上,這沒問題;但移動呢? 指標一拖快就會甩出標題列的範圍,掛在標題列上的 就收不到事件了。 第一直覺:監聽整個 。 於是需求變成「拖曳中這段期間, 上要存在一組監聽器;結束就要消失」—— 「讓外部系統跟上狀態」,這是第 4 章 effect 同步模型的標準劇本:

const [dragging, setDragging] = useState(false);
const grabOffset = useRef<Point>({ x: 0, y: 0 });  // 抓住點離視窗左上角多遠

function onTitlePointerDown(e: React.PointerEvent) {
  grabOffset.current = { x: e.clientX - position.x, y: e.clientY - position.y };
  setDragging(true);
}

// 同步:「拖曳中」時 window 上存在監聽器;結束時消失
useEffect(() => {
  if (!dragging) return;
  function onMove(e: PointerEvent) {
    setPosition({                      // 絕對式計算:不讀舊 position
      x: e.clientX - grabOffset.current.x,
      y: e.clientY - grabOffset.current.y,
    });
  }
  function onUp() { setDragging(false); }
  window.addEventListener('pointermove', onMove);
  window.addEventListener('pointerup', onUp);
  return () => {                       // 依賴變了或卸載:拆掉這一輪的監聽器
    window.removeEventListener('pointermove', onMove);
    window.removeEventListener('pointerup', onUp);
  };
}, [dragging]);

// JSX:<header onPointerDown={onTitlePointerDown} ...>

順帶一提,用 pointer 系列事件而不是 mouse 系列, 是因為它把滑鼠、觸控、觸控筆統一成同一組事件——一套程式碼三種輸入。 逐項對帳這段程式碼的決策:

  • 是機制資料:拖曳全程都用它換算, 但畫面上沒有任何東西顯示它——判準答案是「不用跟著變」,所以住 ref(第 4 章)。 放 state 的話,每次按下標題列都會白白多渲染一次。

  • 依賴陣列誠實嗎?effect 讀到的 reactive 值只有 。 是 ref(非 reactive)、 引用天生穩定; 最關鍵的是 沒有讀 —— 新位置由指標座標減抓點偏移「絕對式」算出,不在舊位置上累加。誠實。

把 寫成累加式,是浮動視窗最經典的 bug:

變 true 的那次渲染建立了這個閉包, 它捕捉的 是那一刻的快照(第 3、4 章); 之後每次 move 都在同一個舊值上疊加,視窗動一步就卡住或不斷跳回原地。 修法:函數式更新 , 或像上面那樣改用絕對式計算,從根本不讀 。 把 加進依賴陣列也「對」, 但代價是每動一格就拆掛一次 window 監聽器——誠實但粗糙。

setPointerCapture:讓事件回到元素身上

effect 版能用,也是通用解,但指標事件有一個為拖曳而生的 API: 元素的 。 把事件的 交給它之後,這顆指標後續的 move/up 一律派送給該元素—— 無論指標飄到頁面哪裡。「事件甩出元素」的問題直接消失, window 監聽器與那顆 effect 也就沒有存在的理由:

const grab = useRef<Point | null>(null);   // null = 沒在拖;有值 = 抓點偏移

function onTitlePointerDown(e: React.PointerEvent<HTMLElement>) {
  grab.current = { x: e.clientX - position.x, y: e.clientY - position.y };
  e.currentTarget.setPointerCapture(e.pointerId);  // 這顆指標之後的事件都送回來
}
function onTitlePointerMove(e: React.PointerEvent<HTMLElement>) {
  if (!grab.current) return;                       // 沒抓住,只是滑過標題列
  setPosition(keepReachable(
    { x: e.clientX - grab.current.x, y: e.clientY - grab.current.y },
    size,
  ));
}
function onTitlePointerUp() { grab.current = null; }  // capture 會自動釋放

// JSX:
// <header onPointerDown={onTitlePointerDown}
//         onPointerMove={onTitlePointerMove}
//         onPointerUp={onTitlePointerUp}>

注意 這個 state 整個消失了: 「是否拖曳中」不再影響任何畫面輸出,它退化成「 是否為 null」—— ref 就夠了(判準始終是同一條)。 之後若想在拖曳中換游標樣式或加陰影,再把它升級回 state 即可。

上面順手加了 :把位置夾在「至少留一角抓得回來」的範圍內。 它是純函式,跟 React 無關,可以單獨測試:

const clamp = (v: number, lo: number, hi: number) =>
  Math.min(Math.max(v, lo), hi);

const EDGE = 48;   // 拖出視口時,至少留這麼多可以抓回來

function keepReachable(pos: Point, size: Size): Point {
  return {
    x: clamp(pos.x, EDGE - size.width, window.innerWidth - EDGE),
    y: clamp(pos.y, 0, window.innerHeight - EDGE),  // 標題列不准穿出上緣
  };
}

刻意「允許推出去大半、留一角」而不是「完全不准出去」: 使用者常想把視窗推到旁邊只露一角。 的下限是 0,因為標題列一旦穿出上緣就再也抓不回來了。 另外注意它讀了 —— 在事件路徑裡讀外部環境是正常的;要純的是渲染(第 1 章的界線)。

  • capture 版:事件都能派送回元素的手勢(拖曳、縮放、畫布塗抹)首選。 少一個 state、少一顆 effect,事件的生命週期天然跟著元件卸載走。

  • effect+window 監聽版:當事件本來就不發生在自己身上—— 全域快捷鍵、點擊外部關閉、監聽 的 resize——同步模型仍是通用解。

  • 公司程式碼兩種都會遇到;能認出它們是「同一件事的兩種載體」,讀碼就不慌。 這也複習了第 4 章的判斷:事件能在元素上接完,就不需要 effect。

每秒上百次 setPosition:誰在重新渲染?

高刷新率螢幕加上高回報率滑鼠, 每秒可達一兩百次, 每次都 ,也就是每次都重新渲染 。該慌嗎?

第 3 章的答案:渲染的成本=函式重跑+子樹 diff,貴不貴要看子樹多大。 視窗的「殼」(標題列、外框、縮放把手)只有幾個節點,重算一百次也無感。 真正的問號是內容——如果 裝的是幾百個節點的平面圖呢?

好消息:內容根本不會重算。推理只用第 3 章的既有規則:

  • 這個元素物件是父層()渲染時建立的, 經 prop 交給視窗。

  • 拖曳觸發的是 自己的 setState; 父層沒有重新渲染, 的引用自然原封不動。

  • React 協調時發現「這個位置的元素跟上次是同一個物件」, 直接沿用整棵子樹——連 函式都不會被呼叫。 可以理解成免費版的 :引用相同這件事本身就是「沒變」的證明。

於是 8.1 節「內容做成插槽」的決定,在這裡兌現了第二次紅利: 第 5 章買到 API 的開放性,第 3 章買到「拖曳只付殼的錢」。 反過來說,若把內容寫死在 內部, 每秒上百次的整棵重算就會如實發生——元件邊界就是渲染邊界, 拆元件的第三個理由(狀態隔離,第 5 章)在這裡具象化了。

驗證方式一如第 3 章:打開 Profiler 錄一段拖曳, 內容元件不應該出現在任何一次 commit 的名單裡。先量測,再談優化。

如果量測顯示連「殼」的重算都有感(殼上掛了很重的東西), 還有最後一招:拖曳期間不進 React—— 裡直接 (用 ref 拿 DOM), 時才 一次,把最終位置提交回狀態。 中間位置沒有任何其他元件需要知道,不放進 state 並不違反單一事實來源; 但它放棄了「畫面=f(state)」的即時一致性,屬於量測到問題才動用的逃生口, 不是預設寫法。React 官方對 DOM 逃生口的態度一貫如此:門開著,但走之前先確認非走不可。

疊層:點一下,浮到最上面

多視窗需求:點到哪個,哪個到最前面。 「誰的 最大」是全頁共同的事實, 但為了它動用 context 或狀態提升,殺雞用牛刀。 浮動層的務實慣例是一個模組層計數器:

let topZ = 100;   // 模組層的「目前最高樓層」,同頁所有視窗共用

function FloatingWindow(...) {
  const [zIndex, setZIndex] = useState(topZ);

  useEffect(() => { setZIndex(++topZ); }, []);   // 掛載:新視窗直接置頂

  function bringToFront() {
    if (zIndex !== topZ) setZIndex(++topZ);      // 已在頂端就不再疊高
  }

  return createPortal(
    <div onPointerDown={bringToFront} style={{ /* ... */ zIndex }}>
      ...

這一小段藏了三個值得對帳的決策:

  • 計數器只在事件與 effect 裡遞增,渲染中絕不碰。 渲染必須純(第 1 章):同一份狀態渲染兩次必須得到同一個畫面。 寫成 的話每次渲染都在改變世界, StrictMode 的雙跑立刻讓它穿幫。事件與 effect 才是雜質的合法住所—— 掛載 effect 在 StrictMode 下會多跳一號,但 只在乎相對大小,無妨。

  • 是道小守門:點擊已置頂的視窗時, 不徒增樓層、不多渲染一次(第 3 章:沒必要的 setState 就別發)。 注意不能寫成 —— updater 必須是純函式,React 可能重跑它;雜質留在事件本體。

  • 掛在根節點:點內容、點標題列都會冒泡上來, 整扇視窗任何地方按下去都置頂。

元件庫等級的做法會再進一步:一個視窗管理員 context 用有序陣列管所有視窗, 就是陣列索引,能實作「關掉最上面那個」「全部收合」這類群體行為, z 值也不會無限長高。代價是使用端要包 Provider。 計數器版本是九十分的解:夠用、零依賴; 等真的出現群體行為的需求,再把狀態升級到管理員手上(第 2 章:擁有者跟著需求走)。

縮放與 usePointerDrag:同一支舞,只跳一次

右下角的縮放把手,跳的是跟拖曳完全同一支舞: 按下記錨點、capture、移動算差值、放開收尾。 差別只有一個——差值套用到誰身上:拖曳改 ,縮放改 。 兩份幾乎一樣的「ref+三個處理器」,正是第 4 章說的訊號:重複的組合,抽自訂 hook。

設計 hook 介面時只問一件事:它不需要知道被拖的是位置還是尺寸—— 它只負責手勢,把「這次移動了多少」回報出去:

// usePointerDrag.ts —— 把「pointer 拖曳手勢」封裝成可復用的能力
import { useRef } from 'react';

export function usePointerDrag(onDelta: (dx: number, dy: number) => void) {
  const last = useRef<{ x: number; y: number } | null>(null);  // null = 沒在拖

  const stop = () => { last.current = null; };

  return {
    onPointerDown(e: React.PointerEvent<HTMLElement>) {
      last.current = { x: e.clientX, y: e.clientY };
      e.currentTarget.setPointerCapture(e.pointerId);
      e.preventDefault();               // 拖曳途中不要順手選取文字
    },
    onPointerMove(e: React.PointerEvent<HTMLElement>) {
      if (!last.current) return;        // 只是滑過,沒有抓住
      onDelta(e.clientX - last.current.x, e.clientY - last.current.y);
      last.current = { x: e.clientX, y: e.clientY };
    },
    onPointerUp: stop,
    onPointerCancel: stop,              // 來電、系統手勢打斷時也要收尾
  };
}

使用端把兩個互動各接一句話,然後展開到對應的元素上:

const dragHandlers = usePointerDrag((dx, dy) =>
  setPosition((p) => keepReachable({ x: p.x + dx, y: p.y + dy }, size)),
);
const resizeHandlers = usePointerDrag((dx, dy) =>
  setSize((s) => ({
    width:  Math.max(minSize.width,  s.width  + dx),
    height: Math.max(minSize.height, s.height + dy),
  })),
);

// <header {...dragHandlers}>...</header>
// <div className="fw-resize" {...resizeHandlers} />

幾個值得內化的觀察:

  • 增量回報+函數式更新是一對。hook 回報 dx/dy, 呼叫端用 把增量疊上去—— hook 完全不讀呼叫端的狀態,自然沒有依賴問題、沒有過期閉包, 呼叫端也不需要 。第 3、4 章的函數式更新在這裡變成慣性。

  • 回傳物件的欄位名就是 DOM 的事件 props, 展開即完成接線,型別也逐欄對得上。 順手把 收進去——抽象的另一個紅利: 邊角案例修一處,拖曳與縮放同時受惠。

  • 抽象不是免費的。增量式疊加配上 之後, 視窗被夾在邊界時「指標離抓住點多遠」這個資訊丟了—— 指標一回頭視窗立刻跟動,與桌面系統「回到抓住點才動」的手感不同。 絕對式(8.4 節)保真,但 hook 介面就得多傳東西。 取捨要自覺;想改回絕對式,自我檢測第 5 題請你動手。

鍵盤、觸控與可及性

浮動視窗不該只對滑鼠友善。三件基本禮儀:

const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => { rootRef.current?.focus(); }, []);  // 開窗即聚焦:ESC 立即可用

// 根節點:
<div
  ref={rootRef}
  role="dialog"                // 讓輔助技術知道這是一扇對話視窗
  aria-label={title}
  tabIndex={-1}                // 容器可聚焦,才接得到鍵盤事件
  onKeyDown={(e) => { if (e.key === 'Escape') onClose(); }}
>
  • 聚焦是 DOM 指令,不是狀態——ref+掛載 effect, 第 4 章列過的正當 DOM 逃生口。

  • ESC 掛在視窗上而不是 上: 鍵盤事件從焦點所在元素冒泡上來,焦點在哪扇視窗,ESC 就關哪扇—— 多視窗時這正是你要的語意;掛全域反而得自己判斷「誰該收」。

  • 誠實的邊界:這是「非模態」的浮動視窗。 模態對話框——遮罩、焦點圈禁、、關閉時把焦點還給觸發元素—— 是規格嚴得多的另一個元件;原生 與成熟元件庫已經把它做對, 不要拿浮動視窗冒充模態。

  • 標題列與縮放把手必須加 : 否則觸控裝置會把手勢解讀成捲動頁面, 根本不派送—— 「手機上拖不動」十之八九是漏了它。

  • 把手加 (搭配 的 ), 拖快時不會順手反白半個頁面。

  • / 給滑鼠使用者「這裡可以拖」的暗示。

  • 關閉鍵在 停止冒泡,免得「按關閉」被標題列當成「開始拖曳」。

  • 雙擊標題列收合/展開是桌面使用者的肌肉記憶, 一行接上(附錄有這題練習)。

完整組裝與決策對帳

把前面各節組裝起來。 沿用 8.7 節,這裡是視窗本體的完整程式碼:

// FloatingWindow.tsx —— 完整組裝。外觀交給 CSS,這裡只保留機制必需的樣式
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { usePointerDrag } from './usePointerDrag';

export interface Point { x: number; y: number }
export interface Size  { width: number; height: number }

export interface FloatingWindowProps {
  title: string;
  onClose: () => void;
  defaultPosition?: Point;
  defaultSize?: Size;
  minSize?: Size;
  children: React.ReactNode;
}

const clamp = (v: number, lo: number, hi: number) =>
  Math.min(Math.max(v, lo), hi);

const EDGE = 48;                       // 拖出視口時,至少留一角抓得回來
function keepReachable(pos: Point, size: Size): Point {
  return {
    x: clamp(pos.x, EDGE - size.width, window.innerWidth - EDGE),
    y: clamp(pos.y, 0, window.innerHeight - EDGE),
  };
}

let topZ = 100;                        // 模組層計數器:只在事件與 effect 中遞增

export function FloatingWindow({
  title,
  onClose,
  defaultPosition = { x: 80, y: 80 },
  defaultSize = { width: 360, height: 260 },
  minSize = { width: 220, height: 120 },
  children,
}: FloatingWindowProps) {
  const [position, setPosition] = useState(defaultPosition);
  const [size, setSize] = useState(defaultSize);
  const [zIndex, setZIndex] = useState(topZ);
  const rootRef = useRef<HTMLDivElement>(null);

  useEffect(() => {                    // 掛載:置頂並聚焦,ESC 立即可用
    setZIndex(++topZ);
    rootRef.current?.focus();
  }, []);

  const bringToFront = () => { if (zIndex !== topZ) setZIndex(++topZ); };

  const dragHandlers = usePointerDrag((dx, dy) =>
    setPosition((p) => keepReachable({ x: p.x + dx, y: p.y + dy }, size)));

  const resizeHandlers = usePointerDrag((dx, dy) =>
    setSize((s) => ({
      width:  Math.max(minSize.width,  s.width  + dx),
      height: Math.max(minSize.height, s.height + dy),
    })));

  return createPortal(
    <div
      ref={rootRef}
      className="floating-window"
      role="dialog"
      aria-label={title}
      tabIndex={-1}
      onPointerDown={bringToFront}
      onKeyDown={(e) => { if (e.key === 'Escape') onClose(); }}
      style={{
        position: 'fixed', left: 0, top: 0,
        transform: `translate(${position.x}px, ${position.y}px)`,
        width: size.width, height: size.height, zIndex,
        display: 'flex', flexDirection: 'column',
      }}
    >
      <header
        className="fw-titlebar"
        {...dragHandlers}
        style={{ touchAction: 'none', cursor: 'move', userSelect: 'none' }}
      >
        <span>{title}</span>
        <button
          aria-label="關閉"
          onClick={onClose}
          onPointerDown={(e) => e.stopPropagation()}  // 按關閉不是開始拖曳
        >×</button>
      </header>

      <main className="fw-content" style={{ flex: 1, overflow: 'auto' }}>
        {children}   {/* 拖曳時這棵子樹不會重算(8.5 節) */}
      </main>

      <div
        className="fw-resize"
        {...resizeHandlers}
        style={{
          position: 'absolute', right: 0, bottom: 0, width: 16, height: 16,
          touchAction: 'none', cursor: 'nwse-resize',
        }}
      />
    </div>,
    document.body,
  );
}

最後,把整章的設計決策與它們的依據對帳一次—— 這張表就是「讀懂一個互動元件」時該還原出來的推理:

程式碼裡的決策 它在回答什麼 依據
到 裁切與疊層發生在 DOM;搬 DOM、不搬資料流 8.2 節
內容經 插槽傳入 API 對內容開放;拖曳時內容子樹不重算 第 5 章、8.5 節
// 是 state;抓點與錨點是 ref 「值變了畫面要不要跟著變」逐一判定 第 4 章
增量回報+函數式更新 手勢 hook 不讀呼叫端狀態:無依賴、無過期閉包 第 3、4 章
只在事件與 effect 中遞增 渲染保持純函數,StrictMode 雙跑安全 第 1 章
守門 點擊已置頂視窗不多渲染一次 第 3 章
可選 props+解構預設值 最常見的用法寫起來最短;介面即文件 第 5、7 章

距離元件庫還有多遠

這個 已經能用在真實產品裡,但誠實列出它與元件庫成品的距離—— 每一項都能沿著本章的骨架長出來,差別只是需求,不是新原理:

  • 受控的位置與尺寸:補上 + 與 並存的雙介面(第 5 章慣例)。 做了它,「記住上次位置」就只是父層把位置存進 的事。

  • 視口變化的重新夾取:使用者縮小瀏覽器視窗後,浮動視窗可能整個留在視口外。 「位置」與「視口尺寸」的同步——監聽 的 effect, 第 4 章的模型再用一次。

  • 開窗時的層疊偏移(cascade)、吸附邊緣、最大化與收合動畫、 視窗管理員(8.6 節提過的群體行為)、SSR 掛載點(8.2 節)。

對照讀物:react-rnd(拖曳與縮放)與 Floating UI (錨定式浮動層:tooltip、下拉選單的定位引擎)。 讀完本章再看它們的文件,你應該能說出每個選項為什麼存在—— 這正是這份講義想給你的能力。

  • 浮動層的第一個決策是 :DOM 搬到 body 逃離裁切與 stacking context;狀態、context、事件冒泡仍走 React 樹。

  • 拖曳三段式:按下記抓點(ref)、移動算新位置(state)、放開清空。事件能用 在元素上接完就不需要 effect;接不完(全域、跨元素)才用「狀態驅動監聽器」的 effect 同步。

  • 高頻 setState 不可怕,子樹大才可怕:內容走 children 插槽,拖曳只重算視窗殼;量測到熱點才動用 ref 直寫 DOM 的逃生口。

  • 疊層用模組計數器,但只在事件與 effect 中遞增,渲染保持純;置頂前先檢查是否已在頂端,省掉無效渲染。

  • 拖曳與縮放是同一個手勢:抽成 ,增量回報+函數式更新,hook 與呼叫端互不讀取彼此的狀態。

  • 、、ESC、 是浮動視窗的基本禮儀;模態對話框是規格更嚴的另一個元件。