preloader
面試

Frontend Interview Questions: Render Performance, Touch Gestures, Scroll Optimization, and SVG | 前端面試題:渲染效能、觸控手勢、捲動優化、SVG

Frontend Interview Questions: Render Performance, Touch Gestures, Scroll Optimization, and SVG | 前端面試題:渲染效能、觸控手勢、捲動優化、SVG

初中階

1. requestAnimationFrame 和 setTimeout 的差異?

// setTimeout:固定時間後執行,不管瀏覽器在不在渲染
setTimeout(() => updateDOM(), 16); // 希望模擬 60fps
// rAF:在瀏覽器下一次渲染前執行
requestAnimationFrame(() => updateDOM());
setTimeout requestAnimationFrame
執行時機 指定毫秒後(不精確) 下一次螢幕重繪前
與螢幕同步 否,可能在兩幀之間執行,畫面撕裂 是,保證每幀執行一次
背景分頁 繼續執行,浪費資源 自動暫停,省電
頻率 自己控制(不準確) 自動匹配螢幕刷新率(60/120Hz)

關鍵差異:setTimeout 不知道螢幕什麼時候重繪,可能一幀內執行 0 次或 2 次。rAF 保證每幀恰好 1 次,動畫最流暢。


2. 什麼是 GPU 合成層?

瀏覽器渲染分四步:

Style → Layout → Paint → Composite
                          ↑ GPU 在這裡
  • Paint:CPU 把每一層畫成點陣圖(bitmap)
  • Composite:GPU 把多張點陣圖疊在一起,輸出到螢幕 當元素被提升為獨立的**合成層(compositing layer)**時:
/* 這些屬性會觸發獨立合成層 */
transform: translate3d(0, 0, 0);
will-change: transform;
opacity: 0.5; /* 動畫中 */

該元素有自己的 GPU 紋理。移動或縮放它時,GPU 只需移動紋理位置,不需要 CPU 重新 Paint

  • 好處:transform/opacity 動畫可以完全在 GPU 端完成,不阻塞主執行緒
  • 代價:每個合成層佔用 GPU 記憶體(一個 500x500 元素 ≈ 1MB VRAM) 用 Chrome DevTools → Layers 面板可以看到所有合成層。

3. SVG 和 Canvas 的差別?什麼時候用哪個?

SVG Canvas
渲染方式 向量(DOM 節點) 點陣(像素繪圖指令)
放大後畫質 永遠清晰 需要重繪才清晰
事件處理 瀏覽器自動(click/hover 直接綁在圖形上) 手動反算座標 + 命中判定
效能瓶頸 DOM 節點數量(>1000 會卡) 重繪複雜度
適用場景 圖表、icon、少量互動圖形 遊戲、大量粒子、即時繪圖
<!-- SVG:宣告一個圓,瀏覽器負責渲染和事件 -->
<circle cx="100" cy="100" r="50" (click)="onClick()" />
// Canvas:命令式畫一個圓,事件全部自己處理
ctx.beginPath();
ctx.arc(100, 100, 50, 0, Math.PI * 2);
ctx.fill();

4. SVG 的 viewBox 是什麼?怎麼用?

viewBox 定義 SVG 的內部座標系(世界座標),瀏覽器自動映射到元素的實際尺寸。

<svg width="400" height="300" viewBox="0 0 1000 750">
  <circle cx="500" cy="375" r="50" />
</svg>
  • viewBox="0 0 1000 750" → 世界座標範圍是 0~1000(寬)、0~750(高)
  • width="400" → 螢幕上實際寬 400px
  • 自動縮放比:400 / 1000 = 0.4
  • 圓心 (500, 375) 在螢幕上的位置 = (200px, 150px) 類比:viewBox 就像相機的取景框,決定你看到世界的哪一塊區域。改變 viewBox 就是移動相機或調整焦距。

高階

5. 如何實現流暢的 60fps 動畫?(compositor-only properties)

60fps = 每幀 16.6ms。一幀內要完成:JS → Style → Layout → Paint → Composite。
核心原則:只動 compositor-only properties

/* 好:只觸發 Composite,跳過 Layout 和 Paint */
transform: translateX(100px);
opacity: 0.5;
/* 差:觸發 Layout → Paint → Composite,全部重來 */
left: 100px;
width: 200px;

完整做法:

  1. 動畫只用 transformopacity — 唯二只觸發 Composite 的屬性
  2. will-change 預建合成層 — 避免首幀卡頓
  3. requestAnimationFrame — 與螢幕同步
  4. 避免 layout thrashing — 不要在同一幀內交替讀寫 DOM
// 壞:強制同步 layout(layout thrashing)
element.style.width = '100px';    // 寫
const h = element.offsetHeight;    // 讀 → 瀏覽器被迫立即 layout
element.style.height = h + 'px';   // 又寫
// 好:先批次讀,再批次寫
const h = element.offsetHeight;    // 讀
requestAnimationFrame(() => {
  element.style.width = '100px';   // 寫
  element.style.height = h + 'px'; // 寫
});

6. touch-action 各值的行為差異?手勢衝突怎麼解?

原生捲動 原生 pinch-zoom 原生 double-tap-zoom 適用場景
auto 允許 允許 允許 預設,普通網頁
none 禁用 禁用 禁用 JS 完全接管手勢(遊戲、畫布)
pan-x 僅水平 禁用 禁用 水平輪播
pan-y 僅垂直 禁用 禁用 垂直列表內的手勢元素
pan-x pan-y 允許 禁用 禁用 需要捲動但 JS 處理縮放
manipulation 允許 允許 禁用 最常用,消除 300ms 點擊延遲
pinch-zoom 禁用 允許 禁用 少見

手勢衝突解法三步驟

Step 1:用 touch-action 劃分管轄權

touch-action: pan-x pan-y; /* 瀏覽器管捲動,JS 管縮放 */

Step 2:動態切換

// 未放大:瀏覽器管捲動
element.style.touchAction = 'manipulation';
// 放大後:JS 管一切
element.style.touchAction = 'none';

Step 3:HammerJS 辨識器優先級

tap.requireFailure(pinch);   // pinch 進行中不觸發 tap
pinch.recognizeWith(pan);    // pinch 和 pan 可同時辨識

7. will-change 的用途和濫用的代價?

用途:提前告訴瀏覽器「這個屬性即將改變」

.element {
  will-change: transform; /* 預建 GPU 合成層 */
}

濫用的代價

/* 千萬不要這樣做 */
* { will-change: transform, opacity; }
問題 原因
GPU 記憶體爆炸 每個元素都建立獨立紋理
反而更慢 GPU 要管理太多層,合成本身變慢
手機閃退 行動裝置 GPU 記憶體有限(通常 < 1GB)
優化失效 瀏覽器無法從 will-change 獲得有用資訊
正確用法
// 動畫開始前加
element.style.willChange = 'transform';
// 動畫結束後移除
element.addEventListener('transitionend', () => {
  element.style.willChange = 'auto';
});

8. CSS transform 不影響 layout 代表什麼?有什麼實際影響?

Layout(排版)決定每個元素在頁面中的位置和大小。transform 在 layout 之後才生效:

<div style="display: flex; gap: 10px;">
  <div>A</div>
  <div style="transform: scale(2);">B</div>
  <div>C</div>
</div>
Layout 計算結果:  [A][B][C]     ← B 佔的空間不變
實際螢幕顯示:     [A][BBBB][C]  ← B 視覺上變大,蓋住 A 和 C
現象 原因
放大的元素會蓋住相鄰元素 layout 不知道它變大了
offsetWidth 不變 這是 layout 尺寸,不受 transform 影響
捲動範圍不變 父容器的 scrollable area 由 layout 決定
點擊偵測可能偏移 某些情況下 hit test 用的是 layout 位置

這就是 scroll 策略需要 margin 的根本原因

// transform scale(2) 不會撐大父容器的捲動範圍,必須用 margin 手動補上
child.style.marginRight = `${width * (scale - 1)}px`;
child.style.marginBottom = `${height * (scale - 1)}px`;

9. SVG 的三種縮放方式和差異?

<!-- 方法 1:改 viewBox — 向量重算,永遠清晰 -->
<svg viewBox="250 187 500 375">  <!-- 放大 2 倍,看中心區域 -->
<!-- 方法 2:SVG 內部 transform — 向量重算,清晰 -->
<g transform="scale(2) translate(-125, -93)">
  <circle cx="500" cy="375" r="50" />
</g>
<!-- 方法 3:CSS transform — 可能模糊 -->
<svg style="transform: scale(2);">
方法 畫質 觸發重排版 事件座標 適用場景
改 viewBox 清晰(向量重算) 瀏覽器自動換算 地圖、資料視覺化
<g> transform 清晰(SVG 內部變換) 部分 瀏覽器自動換算 局部放大
CSS transform 可能模糊(光柵化後縮放) 可能偏移 簡單動畫

為什麼 CSS transform 作用在 SVG 上可能模糊?
瀏覽器可能先將 SVG 光柵化(rasterize)成點陣圖,再用 GPU 縮放點陣圖。這跟放大一張 JPG 一樣會模糊。而改 viewBox 或用 SVG 內部 transform,瀏覽器會用新的尺寸重新計算向量路徑,所以永遠清晰。


10. SVG transform 和 CSS transform 的差異?

<!-- SVG transform:作用在 SVG 座標系內 -->
<g transform="translate(100, 50) scale(2) rotate(45)">
<!-- CSS transform:作用在 CSS 視覺層 -->
<g style="transform: translate(100px, 50px) scale(2) rotate(45deg);">
SVG transform CSS transform
座標單位 SVG 使用者單位(無單位數字) CSS 單位(px, em, deg)
作用時機 SVG 渲染管線內 CSS 渲染管線(layout 之後)
影響 layout 是(改變 SVG 內部座標) 否(只改視覺呈現)
事件命中 自動正確 可能偏移
動畫效能 需要重繪 SVG 可走 GPU 合成層
旋轉語法 rotate(45) rotate(45deg)

實際影響:如果要做互動式 SVG(如拖曳、點擊圖形),用 SVG transform。如果只是做進場動畫,用 CSS transform + GPU 加速。


11. SVG 的 preserveAspectRatio 是什麼?

當 viewBox 的寬高比和 SVG 元素的寬高比不同時,preserveAspectRatio 決定怎麼處理。

<!-- 預設:等比縮放,置中 -->
<svg viewBox="0 0 100 100" width="200" height="100"
     preserveAspectRatio="xMidYMid meet">
<!-- 填滿,可能裁切 -->
<svg viewBox="0 0 100 100" width="200" height="100"
     preserveAspectRatio="xMidYMid slice">
<!-- 不保持比例,拉伸填滿 -->
<svg viewBox="0 0 100 100" width="200" height="100"
     preserveAspectRatio="none">
行為 類比 CSS
xMidYMid meet 等比縮放,完整顯示,可能留白 object-fit: contain
xMidYMid slice 等比縮放,填滿容器,可能裁切 object-fit: cover
none 不保持比例,拉伸填滿 object-fit: fill

專業領域

12. Canvas 座標系轉換(地圖/繪圖)

Canvas 有自己的座標系,與螢幕座標獨立:

螢幕座標 (screen)          Canvas 世界座標 (world)
┌──────────┐              ┌─────────────────────┐
│ (0,0)    │              │ (-1000, -1000)       │
│   ┌────┐ │   scale=0.5  │                     │
│   │可見│ │  ←──────────  │    ┌────────┐       │
│   │區域│ │   offset      │    │ 可見區域│       │
│   └────┘ │              │    └────────┘       │
└──────────┘              │              (5000, 5000) │
                          └─────────────────────┘

核心公式

// 世界座標 → 螢幕座標(繪製時用)
screenX = worldX * scale + offsetX;
screenY = worldY * scale + offsetY;
// 螢幕座標 → 世界座標(點擊偵測用)
worldX = (screenX - offsetX) / scale;
worldY = (screenY - offsetY) / scale;

以某點為中心縮放(地圖 pinch-zoom 的核心算法):

function zoomAt(newScale, centerX, centerY) {
  const worldX = (centerX - offsetX) / scale;
  const worldY = (centerY - offsetY) / scale;
  offsetX = centerX - worldX * newScale;
  offsetY = centerY - worldY * newScale;
  scale = newScale;
}

13. 手勢衝突處理(行動端)

常見衝突場景與解法: 場景 1:Swiper 左右滑 vs 圖片 pan

onZoomChange(zoomed: boolean) {
  this.swiper.allowTouchMove = !zoomed;
}

場景 2:頁面捲動 vs 元素拖曳

element.addEventListener('touchmove', (e) => {
  if (isDragging) e.preventDefault();
}, { passive: false }); // 必須 non-passive 才能 preventDefault

場景 3:pinch vs tap(誤觸)

tap.requireFailure(pinch);

場景 4:瀏覽器手勢 vs JS 手勢

touch-action: none; /* JS 全權處理 */

整體策略:先用 touch-action 做粗粒度分工,再用 HammerJS 辨識器的 requireFailure / recognizeWith 做細粒度排序。


14. 捲動效能與虛擬捲動(長列表/大型文件)

問題:10,000 筆資料 → 10,000 個 DOM 節點 → 瀏覽器卡死
虛擬捲動原理:只渲染可見區域的 DOM 節點

完整資料:[0] [1] [2] ... [9999]
螢幕可見區域(假設能顯示 20 筆):
┌──────────────┐
│ 只渲染這 20 筆 │  ← DOM 中只有 20 個節點
└──────────────┘
用一個空的 <div> 撐出完整高度:
<div style="height: 999900px">  ← 模擬 10000 筆的總高度
  <!-- 只渲染 item[50] ~ item[69] -->
</div>

關鍵計算

const itemHeight = 50;
const totalHeight = 10000 * 50;
const scrollTop = container.scrollTop;
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = startIndex + visibleCount;
// 只渲染 items[startIndex..endIndex]

捲動效能注意事項

做法 原因
scroll 事件用 passive: true 告訴瀏覽器不會 preventDefault,捲動不等 JS
避免在 scroll handler 中讀 layout 屬性 防止 forced synchronous layout
IntersectionObserver 取代 scroll 監聽 更省效能的可見性偵測
content-visibility: auto 瀏覽器自動跳過螢幕外元素的渲染

常用函式庫

  • React: react-window, @tanstack/react-virtual
  • Angular: @angular/cdk/scrolling (Virtual Scroll)
  • 通用: IntersectionObserver API

15. SVG 效能優化(大量圖形渲染)

問題:SVG 每個圖形都是 DOM 節點,數量多時效能下降。
解法 1:減少 DOM 節點

<!-- 差:1000 個 <circle> 節點 -->
<circle cx="10" cy="10" r="2" />
<circle cx="20" cy="15" r="2" />
<!-- ... 998 more -->
<!-- 好:一個 <path> 搞定 -->
<path d="M10,10 m-2,0 a2,2 0 1,0 4,0 a2,2 0 1,0 -4,0
         M20,15 m-2,0 a2,2 0 1,0 4,0 a2,2 0 1,0 -4,0" />

解法 2:SVG + Canvas 混合

互動層(SVG):選取框、hover 提示 ← 少量 DOM,事件免費
資料層(Canvas):大量資料點     ← 無 DOM 壓力

D3.js 的散佈圖就常用這種模式。

解法 3:虛擬化(只渲染可見區域的圖形)

// 類似虛擬捲動的概念
const visibleShapes = shapes.filter(shape =>
  shape.x >= viewBox.x &&
  shape.x <= viewBox.x + viewBox.width &&
  shape.y >= viewBox.y &&
  shape.y <= viewBox.y + viewBox.height
);
// 只將 visibleShapes 加入 DOM

效能臨界點

圖形數量 建議技術
< 300 純 SVG
300 ~ 3000 SVG + 虛擬化
> 3000 Canvas 或 WebGL

16. 用 SVG viewBox 實現地圖式縮放平移

// SVG 版本的 zoomAt(對比 Canvas 的 ctx.scale + ctx.translate)
function zoomAt(svg: SVGSVGElement, newScale: number, centerX: number, centerY: number) {
  const vb = svg.viewBox.baseVal;
  const currentScale = svg.clientWidth / vb.width;
  // 螢幕座標 → 世界座標
  const worldX = vb.x + centerX / currentScale;
  const worldY = vb.y + centerY / currentScale;
  // 新的 viewBox 尺寸
  const newWidth = svg.clientWidth / newScale;
  const newHeight = svg.clientHeight / newScale;
  // 讓 worldX/worldY 仍然對應 centerX/centerY
  vb.x = worldX - centerX / newScale;
  vb.y = worldY - centerY / newScale;
  vb.width = newWidth;
  vb.height = newHeight;
}

對比 Canvas

SVG (viewBox) Canvas (ctx)
縮放 改 viewBox 的 width/height ctx.scale()
平移 改 viewBox 的 x/y ctx.translate()
重繪 瀏覽器自動 手動 clearRect + 重繪
座標反算 vb.x + screenX / scale (screenX - offsetX) / scale
事件處理 自動正確(viewBox 變了,命中區域跟著變) 手動反算