做表單時,我很常看到一種讓人很煩的體驗:
剛點進 Input
↓
才打一個字
↓
紅框出現
↓
錯誤訊息:
「請輸入完整內容」
但問題是:
我根本還沒打完。
甚至有些欄位:
剛 focus
↓
還沒真的輸入
↓
錯誤就先出現
這種情況從程式角度看可能「驗證很即時」。
但從使用者角度看卻很像:
我還沒做完,你就先說我做錯了。
這篇想講的其實不只是一個事件。
而是:
Input 也有自己的操作生命週期。
onChange 只代表「值變了」最常見的表單寫法:
<input
value={value}
onChange={e => {
setValue(e.target.value)
}}
/>
onChange 可以先理解成:
Input Value 發生改變。
例如:
原本:
""
輸入:
A
↓ onChange
"A"
再輸入:
AB
又會:
onChange
所以:
onChange
很適合拿來:
更新 State
但不一定代表:
現在就是最適合顯示錯誤的時間。
例如密碼規則:
至少 8 個字元
使用者開始輸入:
a
如果立刻驗證:
只有 1 個字
↓
Error
↓
至少輸入 8 個字元
接著:
ab
↓
Error
abc
↓
Error
abcd
↓
Error
從程式角度:
規則判斷完全正確
但從 UX 角度:
使用者明明正在完成任務,畫面卻一路告訴他「你錯了」。
這就是「驗證正確」不代表「驗證時機正確」。
onBlur 是什麼?onBlur 可以先理解成:
這個欄位失去焦點了。
例如:
點進 Email Input
↓
開始輸入
↓
輸入完成
↓
點到別的地方
↓
onBlur
這時候通常可以合理推測:
使用者暫時完成這個欄位的輸入了。
所以很多表單會選擇:
onChange
→ 更新值
onBlur
→ 驗證
而不是:
onChange
→ 每打一個字就噴錯
例如:
const [email, setEmail] = useState("")
const [error, setError] = useState("")
<input
value={email}
onChange={e => {
setEmail(e.target.value)
}}
onBlur={() => {
if (!email.includes("@")) {
setError("Email 格式不正確")
}
}}
/>
資料流:
輸入中
↓
onChange
↓
更新 email
↓
先不要打擾
使用者離開欄位
↓
onBlur
↓
Validate
↓
真的不符合
↓
顯示錯誤
這種體驗通常會比:
打一個字
↓
立刻紅框
自然很多。
onBlur 也不是永遠最好例如:
使用者輸入:
abc@
其實已經明顯不完整。
但他可能停在這裡很久。
如果一定要等:
onBlur
才驗證,
也可能太晚。
所以實際表單常常會做成:
第一次錯誤
→ onBlur 後才顯示
已經顯示錯誤之後
→ onChange 即時檢查是否修正
這種體驗會比較自然。
可以把:
touched
理解成:
使用者真的操作過這個欄位。
例如:
一開始
touched = false
focus
↓
輸入
↓
blur
↓
touched = true
這樣 Error 顯示條件就可以從:
只要 invalid 就顯示
改成:
invalid
+
使用者真的操作過
例如概念上:
const showError =
touched && !isValid
這樣就不會一進頁面所有欄位全部紅掉。
onFocus 又是什麼?onFocus 是:
使用者進入這個欄位。
流程:
onFocus
↓
使用者開始操作
onChange
↓
內容正在改變
onBlur
↓
使用者離開欄位
所以一個 Input 可以先看成:
Focus
↓
Editing
↓
Blur
這已經是一個很小的 lifecycle。
如果是英文:
focus
↓
onChange
↓
onChange
↓
blur
但中文、日文、韓文輸入可能還有:
focus
↓
compositionStart
↓
組字中
↓
多次 value change
↓
compositionEnd
↓
blur
所以「Input Value 變了」這件事,
其實不一定代表:
使用者已經輸入完成。
假設使用者要輸入:
台北
輸入法組字期間可能經過:
ㄊ
台
台ㄅ
台北
如果每次:
onChange
都直接:
search(value)
就可能變成:
還在組字
↓
API 已經出發
所以我們會搭配:
onCompositionStart
和:
onCompositionEnd
去判斷:
現在是不是還在組字。
useRef 記住組字狀態例如:
const isComposingRef = useRef(false)
開始組字:
onCompositionStart={() => {
isComposingRef.current = true
}}
結束:
onCompositionEnd={e => {
isComposingRef.current = false
onSearch(e.target.value)
}}
一般輸入:
onChange={e => {
const value = e.target.value
if (isComposingRef.current) {
return
}
onSearch(value)
}}
Focus
↓
開始輸入
↓
onChange
↓
正在組字嗎?
├─ Yes
│ ↓
│ 等 compositionEnd
│
└─ No
↓
更新 / 搜尋
輸入完成
↓
Blur
↓
Validate
這樣比:
只要 value 改了
↓
全部事情一起做
更清楚。
因為 Error Message 本質上是在告訴使用者:
目前這個輸入不符合系統要求。
但如果使用者還在操作中,
這個資訊其實沒有幫助。
例如電話:
09
系統立刻顯示:
請輸入完整手機號碼
使用者心裡可能只會想:
我知道,我還在打。
所以更好的驗證時機通常要考慮:
使用者現在是否已經完成這一步?
而不是只考慮:
目前值是不是 valid?
以前我只會問:
規則是什麼?
例如:
必填
至少 8 碼
Email 格式
但實際做 UX 還要再問:
什麼時候檢查?
所以表單驗證其實有兩層:
Validation Rule
→ 什麼算錯?
Validation Timing
→ 什麼時候告訴使用者錯了?
這兩件事一樣重要。
輸入
↓
完全不提示
↓
Submit
↓
一次檢查
優點:
不打擾輸入
缺點:
可能到最後才發現很多錯
輸入
↓
onChange
↓
Validate
優點:
回饋很即時
缺點:
很容易太吵
輸入
↓
Blur
↓
Validate
通常比較接近:
完成一個欄位後再提醒。
我自己很喜歡這種思路:
第一次操作
↓
Blur
↓
發現錯誤
↓
顯示 Error
使用者回來修改
↓
onChange
↓
一旦修正
↓
Error 即時消失
因為:
不會太早罵使用者,但也不會讓已經修好的錯誤一直掛著。
以前做設計時可能會寫:
欄位錯誤時顯示紅框
但到了 Frontend 才會發現:
「錯誤時」到底是哪個時間點?
是:
一進頁面?
Focus?
第一個字?
Blur?
Submit?
這些都是實作時真的要決定的事情。
不只:
這個欄位規則是什麼?
還會問:
① 什麼時候開始驗證?
② 使用者還在輸入時,要不要顯示 Error?
③ Error 出現後,什麼時候讓它消失?
這三個問題,
其實直接影響表單好不好用。
onFocus
→ 開始操作
onChange
→ 值正在改變
compositionStart / End
→ 是否還在組字
onBlur
→ 離開欄位
Submit
→ 完成整份表單
它們不是一堆零散事件。
而是在描述:
使用者現在進行到哪一個階段。
所以不要只因為:
value 暫時不符合規則
就立刻告訴使用者:
你錯了。
有時候他不是輸錯。
只是:
還沒輸完而已。
下一篇可以正式離開 Input 細節,進入另一種工程判斷:
不要急著重做 Component,也許你只是還沒看懂它原本提供的參數。