window.addEventListener('resize', …) 對滿版遊戲而言是錯的工具,理由有三個:它只在視窗尺寸變化時觸發(容器因為 flex/grid 或兄弟元素而改變尺寸時完全不觸發)、它在行動裝置轉向時可能早於版面穩定(讀到舊尺寸)、而且它沒有節流,快速拖曳視窗會在一秒內觸發上百次昂貴的 canvas 重建。
正確的工具是 ResizeObserver:它觀察的是元素而非視窗,回呼在版面計算完成後、繪製之前執行,並且瀏覽器保證同一幀內只送一次。
基準實作
let pending = null
const ro = new ResizeObserver(entries => {
const e = entries[entries.length - 1] // 只在意最後一筆
const box = e.devicePixelContentBoxSize?.[0]
const next = box
? { w: box.inlineSize, h: box.blockSize, unit: 'device' }
: { w: e.contentRect.width, h: e.contentRect.height, unit: 'css' }
// 合併到下一幀處理:一幀內多次觸發只做一次重建
if (pending) return
pending = requestAnimationFrame(() => {
pending = null
relayout(next)
})
})
try { ro.observe(root, { box: 'device-pixel-content-box' }) }
catch { ro.observe(root) }三個設計決定:
觀察根容器而非 canvas。 若觀察 canvas 本身,而 relayout() 又會改變 canvas 的 CSS 尺寸,就會形成觀察→改尺寸→再觀察的迴圈。瀏覽器會偵測到並在 console 印出 ResizeObserver loop completed with undelivered notifications,然後丟棄那次通知——結果是某些 resize 靜默失效。觀察一個不會被你改動的父容器就沒有這個問題。
用 requestAnimationFrame 合併。 ResizeObserver 在拖曳視窗時仍會每幀觸發,而 relayout() 包含配置新 backing store(可能是十幾 MB 的記憶體)。合併到 rAF 讓多次通知只做一次,而且執行時機正好在繪製前。
只取最後一筆 entry。 中間狀態的尺寸沒有意義。
轉向:為什麼會「要轉兩次才對」
行動裝置轉向時,事件的順序在各瀏覽器並不一致,而且 window.innerWidth/innerHeight 在 orientationchange 觸發的當下往往還是舊值。經典的錯誤寫法:
// ❌ 讀到的是轉向前的尺寸
window.addEventListener('orientationchange', () => {
relayout(window.innerWidth, window.innerHeight)
})常見的「解法」是加 setTimeout(…, 300),但那只是賭裝置夠快——低階機或動畫較長的系統仍會失敗,而快的裝置則白白多等 300ms 看到跑版畫面。
ResizeObserver 從根本上避開這個問題:它的回呼發生在版面已重新計算之後,contentRect 必然是新值。所以正確的做法是完全不監聽 orientationchange 與 resize,只留 ResizeObserver。
若你確實需要知道「方向變了」這件事(例如要換一套 HUD 佈局),用 Screen Orientation API 或 media query,而不是靠尺寸推測:
const portrait = matchMedia('(orientation: portrait)')
portrait.addEventListener('change', e => setLayoutMode(e.matches ? 'portrait' : 'landscape'))(orientation: portrait) 比對的是視口的長寬(height >= width),不是實體裝置方向。這正是你要的——桌機把視窗拉窄成直的,也該切成直向佈局。
layout thrash:讀寫交錯的代價
瀏覽器會把版面計算延遲到必要時才做。當你「寫入樣式 → 讀取幾何 → 再寫入樣式 → 再讀取」,每一次讀取都強迫瀏覽器同步重算版面(forced synchronous layout),N 個元素就是 N 次完整 reflow。
// ❌ 每圈都強迫一次 reflow
for (const el of hudItems) {
el.style.width = '100px'
console.log(el.offsetHeight) // 觸發同步版面計算
}
// ✅ 先全部讀,再全部寫
const heights = hudItems.map(el => el.offsetHeight) // 讀取階段
hudItems.forEach((el, i) => { el.style.width = heights[i] + 'px' }) // 寫入階段會觸發強制版面計算的屬性包括 offsetTop/Left/Width/Height、clientTop/Left/Width/Height、scrollTop/Left/Width/Height、getComputedStyle()、getBoundingClientRect()、scrollIntoView() 等。
在 resize 流程中,最容易犯的是「在 ResizeObserver 回呼裡再呼叫 getBoundingClientRect()」。entry 已經帶著你要的尺寸了,再讀一次不但多餘,還可能因為你剛剛改過樣式而觸發同步重算。
resize 之後必須重建的東西
relayout() 不只是改尺寸,它會讓一整串狀態失效。漏掉任何一項都是一種典型 bug:
| 需要重建的東西 | 漏掉的症狀 |
|---|---|
canvas backing store(width/height) | 模糊或畫面被拉伸 |
context 狀態(setTransform、fillStyle、font、clip) | 轉向後全部畫在左上角一小塊 |
| 離屏 canvas / 預算圖層 | 快取的資產仍是舊解析度,放大後糊 |
| 輸入座標換算用的矩形快取 | 點擊位置整體偏移(見筆記 07) |
依 worldW/worldH 計算的鏡頭邊界 | expand 模式下看到地圖外的空白 |
| 依尺寸配置的粒子/格子數量 | 記憶體洩漏或畫面破洞 |
CSS 自訂屬性(--sai-*、--scale) | HUD 位置沒跟上 |
建議把重建流程集中成一個函式並發事件,讓各子系統自己訂閱:
function relayout(size) {
computeView(size) // 算 scale / worldW / offset
resizeBackingStore() // 設 canvas.width/height
applyContextDefaults() // font / fillStyle / setTransform
root.style.setProperty('--scale', view.scale)
bus.emit('viewchange', view) // 粒子、鏡頭、UI 各自重建
}什麼時候該 debounce,什麼時候不該
桌機拖曳視窗:使用者期待即時回饋,用 rAF 合併(≈16ms)即可,不要 debounce 到 200ms——那會讓拖曳時畫面明顯落後。
重建成本極高的資產(大型離屏快取、WebGL framebuffer、重新載入不同解析度的貼圖):這些可以兩段式處理——立刻用 CSS transform 拉伸現有畫面(免費、GPU 合成),200ms 沒有新的 resize 才真正重建。使用者看到的是即時但暫時較糊的畫面,然後變清晰。
let idleTimer
function onResize(size) {
cheapRelayout(size) // 立刻:改 CSS 尺寸 + transform
clearTimeout(idleTimer)
idleTimer = setTimeout(() => expensiveRebuild(size), 200) // 穩定後:重建資產
}行動裝置轉向:轉向動畫期間尺寸會連續變化,此時 rAF 合併已足夠;但若你的重建成本高,可以在 matchMedia('(orientation: …)') 的 change 事件裡先把畫面淡出,避免使用者看到中間的變形狀態。
檢查清單
- 用
ResizeObserver,不用window.resize或orientationchange量尺寸 - 觀察根容器,不觀察會被自己改尺寸的元素
- 回呼裡用 rAF 合併,不在回呼裡讀
getBoundingClientRect() -
relayout()有把 context 狀態、離屏快取、座標快取全部重建 - console 沒有
ResizeObserver loop警告 - 實測:桌機拖曳視窗、手機轉向兩次、切換到分割畫面/小視窗模式
下一篇比較兩種把畫面變大的路線,以及它們對輸入座標的影響 → CSS transform 縮放 vs 直接 resize