浮動層(對話框、下拉選單、浮動視窗)為什麼幾乎都用 ?它搬走了什麼、留下了什麼?
拖曳這種每秒上百次事件的互動,狀態該怎麼設計才不會拖垮渲染?
如何讓拖曳不需要 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、 是浮動視窗的基本禮儀;模態對話框是規格更嚴的另一個元件。