# 行動裝置按鈕卡頓:原因與改善
# 為什麼第一次 tap 會卡?
手機的 tap 事件處理比桌面滑鼠複雜很多,有三個層面的問題同時發生。
# 問題一:300ms 延遲
瀏覽器為了判斷使用者是「單擊」還是「雙擊縮放」,預設會等待 300ms 才觸發 click 事件。
使用者手指離開螢幕
↓
瀏覽器等待 300ms...(確認是否為雙擊)
↓
觸發 click 事件
這 300ms 是肉眼可感受到的延遲。低階手機 JS 執行本來就慢,再加上 300ms,體感上就像「卡了一下」。
解法: 加上 touch-action: manipulation
.btn { | |
touch-action: manipulation; | |
} |
告訴瀏覽器「這個元素不支援雙擊縮放」,瀏覽器就不需要等待,直接觸發 click,延遲從 300ms 降到接近 0。
# 問題二:行動裝置的 hover 陷阱
桌面的 hover 是滑鼠移過去觸發,手機沒有游標,瀏覽器的處理方式是:
第一次 tap → 觸發 :hover(停在 hover 狀態)
第二次 tap → 觸發 click,離開 hover 狀態
也就是說,如果 CSS 有 :hover 效果(例如位移、變色、放大),使用者第一次 tap 只會看到 hover 動畫,要第二次 tap 才真正觸發點擊。
原本的程式碼:
/* 這段在手機上很有問題 */ | |
.menu-item-card:not(.sold-out):hover { | |
transform: translateY(-6px); /* 第一次 tap 只會看到這個 */ | |
box-shadow: 0 8px 15px rgba(0, 0, 0, 0.15); | |
} |
解法: 用 @media (hover: hover) 只在「有真正滑鼠的裝置」啟用 hover 效果
/* 只有真實滑鼠的裝置才套用 hover */ | |
@media (hover: hover) { | |
.menu-item-card:not(.sold-out):hover { | |
transform: translateY(-6px); | |
box-shadow: 0 8px 15px rgba(0, 0, 0, 0.15); | |
} | |
} | |
/* 行動裝置改用 :active 給即時的視覺回饋 */ | |
.menu-item-card:not(.sold-out):active { | |
transform: scale(0.97); | |
opacity: 0.85; | |
} |
hover: hover 的媒體查詢意思是:「主要輸入裝置支援 hover(有懸停狀態)」,手機觸控不符合,桌面滑鼠符合。
# 問題三:CSS transition 觸發昂貴的重繪
不同的 CSS 屬性改變,瀏覽器的處理成本差異很大。
# 渲染流水線
JavaScript → Style → Layout → Paint → Composite
| 步驟 | 說明 | 成本 |
|---|---|---|
| Layout | 重新計算元素的位置與尺寸 | 最高(影響整個頁面) |
| Paint | 重新繪製像素 | 高 |
| Composite | 合成 GPU layer | 最低 |
# 哪些屬性便宜,哪些貴?
| 屬性 | 觸發階段 | 效能 |
|---|---|---|
transform |
Composite only | 最快,GPU 處理 |
opacity |
Composite only | 最快,GPU 處理 |
background-color |
Paint | 中等 |
box-shadow |
Paint | 中等偏慢 |
width , height , margin |
Layout + Paint | 最慢 |
border-radius |
Paint | 中等 |
transform 和 opacity 之所以快,是因為它們在 Composite 階段處理,不需要重新 Layout 或 Paint,直接在 GPU 上操作已有的 layer。
# 原本的問題
/* 壞寫法:all 會監聽所有屬性變化 */ | |
transition: all 0.3s ease; |
當任何 CSS 屬性改變時(包括 box-shadow 、 color 、甚至 border ),都會觸發 transition 計算。 box-shadow 每一幀都要重新 Paint,低階手機很吃力。
/* 好寫法:只指定 GPU 可加速的屬性 */ | |
transition: | |
transform 0.2s ease, | |
opacity 0.2s ease; |
# 問題四: -webkit-tap-highlight-color
行動裝置 tap 元素時,瀏覽器(尤其是 iOS Safari)會閃一個藍 / 灰色的半透明背景作為點擊回饋,這個閃爍也是一種「視覺卡頓感」。
.btn { | |
-webkit-tap-highlight-color: transparent; | |
} |
設成 transparent 後,閃爍消失,改由 :active 的自訂樣式負責給回饋。
# 問題五: will-change 預先建立 GPU Layer
.menu-item-card { | |
will-change: transform; | |
} |
瀏覽器看到 will-change: transform ,會在元素靜止時就預先把它提升到獨立的 GPU layer(composite layer)。
沒有這個提示時,動畫第一幀要臨時建立 layer,這個建立過程就是「第一次動比較卡」的原因之一。
注意: will-change 不能亂用,每個加了的元素都會佔用 GPU 記憶體。只加在真的有動畫的關鍵元素上。
# 改動對照表
# MenuItemCard.vue
| 改動前 | 改動後 | 原因 |
|---|---|---|
transition: all 0.3s ease |
transition: transform 0.2s, opacity 0.2s |
避免觸發 Paint |
:hover 直接寫 |
@media (hover: hover) 包起來 |
行動裝置 hover 陷阱 |
:hover 做位移動畫 |
:active 做縮放動畫 |
行動裝置沒有 hover |
沒有 touch-action |
touch-action: manipulation |
消除 300ms 延遲 |
沒有 will-change |
will-change: transform |
預先建立 GPU layer |
# MenuView.vue(底部按鈕、菜單切換按鈕)
| 改動前 | 改動後 | 原因 |
|---|---|---|
沒有 touch-action |
touch-action: manipulation |
消除 300ms 延遲 |
沒有 -webkit-tap-highlight-color |
transparent |
消除 iOS 點擊閃爍 |
:active 沿用長 transition |
:active 用 0.08s |
讓回饋更即時 |
# 效果預期
| 問題 | 改善前 | 改善後 |
|---|---|---|
| 第一次 tap 延遲 | ~300ms | ~0ms |
| 第一次 tap 只觸發 hover | 是 | 否(行動裝置不再有 hover) |
| 動畫 GPU 成本 | Paint 級別 | Composite 級別 |
| 點擊視覺回饋 | 延遲且依賴 hover | 即時 :active 縮放 |
# 延伸:如何自己判斷效能問題
Chrome DevTools → Performance 面板 → 錄製一次 tap 互動
觀察:
- Long Tasks(超過 50ms 的任務):紅色標記,代表 JS 阻塞主執行緒
- Layout Shift:綠色標記,代表 Layout 被觸發
- Paint:綠色區塊,Paint 面積越大越慢
或用 Rendering 面板開啟「Paint flashing」,頁面上會以綠色高亮顯示每次 Paint 的範圍,可以直接看到哪些元素在動畫時造成 Paint。