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/innerHeightorientationchange 觸發的當下往往還是舊值。經典的錯誤寫法:

// ❌ 讀到的是轉向前的尺寸
window.addEventListener('orientationchange', () => {
  relayout(window.innerWidth, window.innerHeight)
})

常見的「解法」是加 setTimeout(…, 300),但那只是賭裝置夠快——低階機或動畫較長的系統仍會失敗,而快的裝置則白白多等 300ms 看到跑版畫面。

ResizeObserver 從根本上避開這個問題:它的回呼發生在版面已重新計算之後contentRect 必然是新值。所以正確的做法是完全不監聽 orientationchangeresize,只留 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/HeightclientTop/Left/Width/HeightscrollTop/Left/Width/HeightgetComputedStyle()getBoundingClientRect()scrollIntoView() 等。

在 resize 流程中,最容易犯的是「在 ResizeObserver 回呼裡再呼叫 getBoundingClientRect()」。entry 已經帶著你要的尺寸了,再讀一次不但多餘,還可能因為你剛剛改過樣式而觸發同步重算。

resize 之後必須重建的東西

relayout() 不只是改尺寸,它會讓一整串狀態失效。漏掉任何一項都是一種典型 bug:

需要重建的東西漏掉的症狀
canvas backing store(widthheight模糊或畫面被拉伸
context 狀態(setTransformfillStylefont、clip)轉向後全部畫在左上角一小塊
離屏 canvas / 預算圖層快取的資產仍是舊解析度,放大後糊
輸入座標換算用的矩形快取點擊位置整體偏移(見筆記 07)
worldW/worldH 計算的鏡頭邊界expand 模式下看到地圖外的空白
依尺寸配置的粒子/格子數量記憶體洩漏或畫面破洞
CSS 自訂屬性(--sai-*--scaleHUD 位置沒跟上

建議把重建流程集中成一個函式並發事件,讓各子系統自己訂閱:

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.resizeorientationchange 量尺寸
  • 觀察根容器,不觀察會被自己改尺寸的元素
  • 回呼裡用 rAF 合併,不在回呼裡讀 getBoundingClientRect()
  • relayout() 有把 context 狀態、離屏快取、座標快取全部重建
  • console 沒有 ResizeObserver loop 警告
  • 實測:桌機拖曳視窗、手機轉向兩次、切換到分割畫面/小視窗模式

下一篇比較兩種把畫面變大的路線,以及它們對輸入座標的影響 → CSS transform 縮放 vs 直接 resize