最近在開發 圖片標注工具 ,用 React + Canvas 做的,需要在手機上支援「單指拖曳文字標注」和「滑動捲動網頁」兩種操作。在 Chrome DevTools 的手機模擬器上測試一切正常,結果部署到線上,拿真正的 iPhone 打開——點擊沒反應、拖曳沒反應、什麼都沒反應。
這篇文章把整個除錯過程踩過的每一個坑都記錄下來,希望能幫到同樣在 iOS Safari 上跟 Canvas 觸控事件搏鬥的開發者們。
需求場景
我們的圖片文字標注工具在 Canvas 上需要支援以下操作:
- 點擊空白處:新增文字標注
- 單指拖曳文字:移動標注位置,且不能觸發頁面捲動
- 手指在圖片空白處滑動:正常捲動網頁
在桌面版 Chrome 和 Chrome DevTools 手機模擬模式下全部正常運作。但 iPhone 上的 Safari 和 Chrome(iOS 版 Chrome 底層也是 WebKit)完全無法操作。
坑 1:touch-action: none 在 iOS Safari 上不可靠
第一個直覺是用 CSS touch-action: none 告訴瀏覽器「這個 Canvas 的觸控我自己處理」。在 Chrome 上這招完美運作,但 iOS Safari 的行為很不一致:
- iOS 12 以下根本不支援
touch-action: none,只認得auto和manipulation - iOS 13+ 號稱支援,但實測仍會出現瀏覽器搶走觸控事件的情況
- 斜向拖曳時 Safari 仍可能判定為頁面捲動
結論:不能單靠 touch-action: none,它只能當作防禦性的輔助手段,必須搭配 JavaScript 的 preventDefault() 才可靠。
坑 2:React 合成事件是 passive 的
React 17 以後,onTouchStart 和 onTouchMove 等合成事件預設是 passive 的。這代表在 React 的事件處理器中呼叫 e.preventDefault() 完全無效——瀏覽器會直接忽略,繼續執行預設行為(捲動頁面)。
// ❌ 這在 iOS Safari 上無效,React 合成事件是 passive 的
<canvas
onTouchStart={(e) => e.preventDefault()}
onTouchMove={(e) => e.preventDefault()}
/>
解法是改用原生 addEventListener 並明確指定 { passive: false }:
// ✅ 原生事件監聽器,passive: false 才能成功 preventDefault
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const onTouchMove = (e: TouchEvent) => {
if (shouldPreventScroll) e.preventDefault();
};
canvas.addEventListener("touchmove", onTouchMove, { passive: false });
return () => canvas.removeEventListener("touchmove", onTouchMove);
}, []);
坑 3:iOS Safari 的 pointercancel 會吃掉事件
在 iOS Safari 上使用 Pointer Events 時,如果沒有在 pointerdown 呼叫 e.preventDefault(),Safari 會在偵測到手指開始移動的瞬間發出 pointercancel,直接搶走觸控事件去做頁面捲動。之後的 pointermove 和 pointerup 都不會再觸發了。
但如果無條件在 pointerdown 呼叫 preventDefault(),整個頁面在 Canvas 區域就完全無法捲動。
解法是只在命中互動元素時才 preventDefault:
const onPointerDown = (e: PointerEvent) => {
if (!e.isPrimary) return;
const hit = getAnnotationAtPoint(e.clientX, e.clientY);
if (hit) {
// 命中文字標注:攔截觸控,開始拖曳
e.preventDefault();
canvas.setPointerCapture(e.pointerId);
startDrag(hit);
}
// 沒命中:什麼都不做,讓瀏覽器正常捲動
};
這裡的 setPointerCapture 也很重要——它確保後續的 pointermove 和 pointerup 都會持續送到 Canvas,即使手指移出了元素範圍也不會丟失事件。
坑 4:Canvas 的 onClick 在 iOS 不觸發
iOS Safari 有一套「可點擊性判斷」機制(clickability heuristic)——它不會對「看起來不像按鈕」的元素觸發 click 事件。Canvas 元素預設不被認為是可點擊的,所以綁在上面的 onClick 根本不會被觸發。
解法意外地簡單,加上 cursor: pointer CSS 就行了:
<canvas
style={{
cursor: "pointer", // iOS Safari 需要這個才會觸發 click
WebkitTouchCallout: "none", // 防止長按跳出選單
WebkitUserSelect: "none", // 防止文字被選取
userSelect: "none",
}}
/>
坑 5:crypto.randomUUID() 和 structuredClone() 的相容性
這個不是觸控事件的問題,但同樣會在 iOS 上造成「什麼都不動」的現象。crypto.randomUUID() 和 structuredClone() 都是 iOS 15.4 以後才支援的 API。如果使用者的 iPhone 系統版本較舊,呼叫這些 API 會直接拋出 ReferenceError,導致整個事件處理器靜默失敗——畫面上不會有任何錯誤提示,就是什麼都不會發生。
// ❌ iOS 15.4 以下不支援,整個 handler 會靜默失敗
const id = crypto.randomUUID();
const clone = structuredClone(data);
// ✅ 加上 fallback
function generateId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return Math.random().toString(36).slice(2) + Date.now().toString(36);
}
// structuredClone 改用 JSON 方式深拷貝
const clone = JSON.parse(JSON.stringify(data));
最終解法:條件式攔截 + 原生事件
經過多輪嘗試,最終能穩定運作的架構長這樣:
- 用原生 Pointer Events(
addEventListener)處理所有互動,不用 React 合成事件 pointerdown做碰撞偵測:命中標注才preventDefault+setPointerCapture,沒命中就放行讓瀏覽器捲動- 原生
touchmove搭配{ passive: false }:用一個 ref 追蹤「目前是否正在拖曳標注」,只在拖曳中才preventDefault pointerup判斷是點擊還是拖曳:累計移動距離沒有超過閾值(5px)就當作點擊,觸發新增文字或選取- 另外提供 UI 按鈕作為 fallback:在屬性面板放一個「新增文字到圖片」按鈕,不依賴 Canvas 觸控事件
核心程式碼大致如下:
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
// 用 ref 讓原生 touchmove 知道目前是否在拖曳
const isDraggingRef = useRef(false);
const onPointerDown = (e: PointerEvent) => {
if (!e.isPrimary) return;
const hit = getAnnotationAtPoint(e.clientX, e.clientY);
if (hit) {
e.preventDefault();
canvas.setPointerCapture(e.pointerId);
isDraggingRef.current = true;
// ... 開始拖曳邏輯
}
};
const onPointerUp = (e: PointerEvent) => {
isDraggingRef.current = false;
// ... 結束拖曳或處理點擊
};
// 只在拖曳標注時阻止頁面捲動
const onTouchMove = (e: TouchEvent) => {
if (isDraggingRef.current) e.preventDefault();
};
canvas.addEventListener("pointerdown", onPointerDown);
canvas.addEventListener("pointermove", onPointerMove);
canvas.addEventListener("pointerup", onPointerUp);
canvas.addEventListener("pointercancel", onPointerCancel);
canvas.addEventListener("touchmove", onTouchMove, { passive: false });
return () => { /* cleanup all listeners */ };
}, [imageSrc]);
這個方案的關鍵在於條件式攔截——只有在確定使用者正在拖曳文字標注時才阻止瀏覽器的預設行為,其他所有觸控操作都完全交給瀏覽器處理。
Chrome DevTools 模擬 vs 真機差異
這次踩坑最大的教訓是:Chrome DevTools 的手機模擬不等於真機測試。以下是模擬器與 iOS Safari 實際行為的對照:
| 行為 | Chrome DevTools 模擬 | iOS Safari 真機 |
|---|---|---|
touch-action: none | 完全有效 | 不可靠,仍可能捲動 |
| React onTouchStart 的 preventDefault | 有效 | 無效(passive 限制) |
| Canvas 上的 onClick | 正常觸發 | 需要 cursor: pointer 才會觸發 |
| Pointer Events 不 preventDefault | 事件正常繼續 | 觸發 pointercancel,後續事件全部消失 |
| crypto.randomUUID() | 支援 | iOS 15.4 以上才支援 |
結論與建議
如果正在開發需要觸控互動的 Canvas 應用,以下是從這次經驗中整理出來的建議:
- 不要用 React 合成事件處理觸控:改用原生
addEventListener+{ passive: false },這是唯一能在 iOS Safari 上可靠呼叫preventDefault()的方式 - 不要無條件
preventDefault:只在需要的時候(例如拖曳互動元素)才攔截,否則頁面捲動會壞掉 - 優先使用 Pointer Events:統一的 API 同時處理滑鼠和觸控,搭配
setPointerCapture確保事件序列不會中斷 - Canvas 記得加
cursor: pointer:否則 iOS Safari 不認為它是可互動元素,click 事件不會觸發 - 提供 UI 按鈕作為 fallback:不要把所有操作都綁在 Canvas 觸控上,HTML 按鈕在任何平台都能可靠運作
- 注意 API 相容性:
crypto.randomUUID()、structuredClone()這些較新的 API 在舊版 iOS 上會直接爆炸,記得加 fallback - 一定要用真機測試:Chrome DevTools 模擬跟 iOS Safari 實際行為的差異非常大,模擬器通過不代表真機能用