// setTimeout:固定時間後執行,不管瀏覽器在不在渲染
setTimeout(() => updateDOM(), 16); // 希望模擬 60fps
// rAF:在瀏覽器下一次渲染前執行
requestAnimationFrame(() => updateDOM());
| setTimeout | requestAnimationFrame | |
|---|---|---|
| 執行時機 | 指定毫秒後(不精確) | 下一次螢幕重繪前 |
| 與螢幕同步 | 否,可能在兩幀之間執行,畫面撕裂 | 是,保證每幀執行一次 |
| 背景分頁 | 繼續執行,浪費資源 | 自動暫停,省電 |
| 頻率 | 自己控制(不準確) | 自動匹配螢幕刷新率(60/120Hz) |
關鍵差異:setTimeout 不知道螢幕什麼時候重繪,可能一幀內執行 0 次或 2 次。rAF 保證每幀恰好 1 次,動畫最流暢。
瀏覽器渲染分四步:
Style → Layout → Paint → Composite
↑ GPU 在這裡
/* 這些屬性會觸發獨立合成層 */
transform: translate3d(0, 0, 0);
will-change: transform;
opacity: 0.5; /* 動畫中 */
該元素有自己的 GPU 紋理。移動或縮放它時,GPU 只需移動紋理位置,不需要 CPU 重新 Paint。
| 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();
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" → 螢幕上實際寬 400px60fps = 每幀 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;
完整做法:
transform 和 opacity — 唯二只觸發 Composite 的屬性will-change 預建合成層 — 避免首幀卡頓requestAnimationFrame — 與螢幕同步// 壞:強制同步 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'; // 寫
});
| 值 | 原生捲動 | 原生 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 可同時辨識
用途:提前告訴瀏覽器「這個屬性即將改變」
.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';
});
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`;
<!-- 方法 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,瀏覽器會用新的尺寸重新計算向量路徑,所以永遠清晰。
<!-- 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 加速。
當 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 |
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;
}
常見衝突場景與解法: 場景 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 做細粒度排序。
問題: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-window, @tanstack/react-virtual@angular/cdk/scrolling (Virtual Scroll)IntersectionObserver API問題: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 |
// 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 變了,命中區域跟著變) | 手動反算 |