Ant Design-使用 Button 時可能會碰到的地雷

埋藏玄機的地雷。

簡述

這是我自己在工作上踩到的一個地雷,原先在 debug 的過程中以為是 Antd 設計上的問題,一直到最後找出答案時才發現這似乎跟 React 的渲染機制有點相關,總之讓我們來重現一下當時的情境吧。

這邊要做的是這樣的功能:

situation

簡單來說就是一個能切換編輯和閱讀的表單而已,然後在編輯時會出現「Reset」這個按鈕。

實作的原始碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import React, { useState } from 'react'
import 'antd/dist/antd.css'
import './index.css'
import { Button, Input, Form } from 'antd'

const App = () => {
const [isEdit, setIsEdit] = useState(false)
return (
<Form>
<div className='demo-form-options'>
{isEdit ? (
<>
<Button htmlType='reset'>Reset</Button>
<Button onClick={() => setIsEdit(false)}>Cancel</Button>
<Button htmlType='submit'>Save</Button>
</>
) : (
<Button onClick={() => setIsEdit(true)}>Edit</Button>
)}
</div>
<Form.Item name='username' label='Username'>
<Input disabled={!isEdit} />
</Form.Item>
</Form>
)
}
export default App

附註:CodeSandbox

發現問題

剛剛的範例如果仔細使用的話會發現一個問題,那就是「每當我按下 Edit 時內容會自動被 reset」:

problem

看起來就像是自動被執行了 reset 一樣?為了找出問題我們可以在 <Form> 加上 onReset 來檢查看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 加上 log 來確認是否被執行
<Form onReset={() => console.log('onReset')}>
<div className='demo-form-options'>
{isEdit ? (
<>
<Button htmlType='reset'>Reset</Button>
<Button onClick={() => setIsEdit(false)}>Cancel</Button>
<Button htmlType='submit'>Save</Button>
</>
) : (
<Button onClick={() => setIsEdit(true)}>Edit</Button>
)}
</div>
<Form.Item name='username' label='Username'>
<Input disabled={!isEdit} />
</Form.Item>
</Form>

果然不出所料,每當我們按下 Edit 按鈕時 onReset 都會印出訊息,也就是說每次按下 Edit 時都會重新 reset 內容

不過這是為什麼呢?當時我第一個懷疑的點是 reset 這個按鈕

1
<Button htmlType='reset'>Reset</Button>

所以我試著把按鈕拿掉來測試看看,果真沒有出現再出現 reset 的情形。

雖然問題解決了,但我還是覺得很奇怪,為什麼只是切換而已就會觸發 reset?於是我後來又多做了幾項測試,才發現了一個關鍵點,這是從 Antd 的按鈕中發現的。

我們先來看段範例,再來解釋一下我發現了什麼。

底下是一個切換按鈕的範例,簡單來說就是根據 state 來顯示不同的按鈕而已,非常簡單:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function App() {
const [isEdit, setIsEdit] = useState(false)

return (
<div className='wrapper'>
{isEdit ? (
<Button onClick={() => setIsEdit(false)}>Cancel</Button>
) : (
<Button onClick={() => setIsEdit(true)}>Edit</Button>
)}
</div>
)
}

export default App

用看的就大概知道會發生什麼了對吧?不過請仔細看看下面的演示,看能不能從中發現什麼:

button-active-state

想好的話再往下看吧。

我是防雷線…

我是防雷線…

我是防雷線…

我是防雷線…

我是防雷線…

按鈕確實有依照我們想的去做切換,不過你有沒有發現到 按鈕的 active 狀態(樣式) 也被保留下來了呢?這個就是關鍵點。

當我們把按鈕從 Edit 切換成 Cancel 時,照理來說應該要是一個「全新的按鈕」,換句話說就是沒有被點擊過,所以不該有 active 的狀態。

不過 React 為了節省一些開銷,所以在重新渲染時的實際流程是這樣:

  1. 把新的跟舊的按鈕做比對
  2. 把真正需要更新的內容給替換掉

這樣子的機制就有機率出現像上面這種「按鈕被更新了,但卻保留了原本的內容(active 狀態)」的問題。

附註:在 Vue 裡面其實也會出現類似的問題,可以參考這個範例

總之最後繞了一大圈才發現原來整個問題的原因跟 React 渲染機制有關,真的是恍然大悟啊~

解決問題

既然知道問題是出在渲染上的話,那解法自然也很清楚了。

要避免 React 重用原本的 DOM 元素的話,只要在該元素上加上 key 就行了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function App() {
const [isEdit, setIsEdit] = useState(false)

return (
<div className='wrapper'>
{isEdit ? (
// 加上 key
<Button key='cancel' onClick={() => setIsEdit(false)}>
Cancel
</Button>
) : (
// 加上 key
<Button key='edit' onClick={() => setIsEdit(true)}>
Edit
</Button>
)}
</div>
)
}

export default App

這樣子就會強制讓 React 不要用「更改內容」的方式來重新渲染,而是直接「重新渲染這個 DOM 元素」,換句話說就是「我每一次都要重新產生這個按鈕,就算有其他類似的按鈕我也不要拿來用,我就是要完完全全是全新的一個啦(任性?)」。

修改完後再測一次,就不會再出現 active 的樣式了:

solution

因為每一次都是一個全新的按鈕,所以不會在這個按鈕身上看到任何其他按鈕的影子,他永遠會是他自己而已。

回歸到最開始的範例

複習一下最原本的範例,之所以會發生那樣的問題就是因為「按鈕被重複使用」的關係,複習一下這張圖:

situation

有注意到 Edit 按下以後切換到新的按鈕時,Reset 也保留了 active 的樣式嗎?這樣代表 Reset 就是拿 Edit 來改的意思。

這個時候呢,因為 Reset 按鈕有 htmlType="reset" 這個 props,所以就會觸發「把表單重置」的預設行為,所以表單就被重置了。

所以如果你把 ResetSave 的位置交換的話,就會發現變成觸發 htmlType="submit" 的預設行為:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const App = () => {
const [isEdit, setIsEdit] = useState(false)
return (
<Form onReset={() => console.log('reset')} onFinish={() => console.log('submit')}>
<div className='demo-form-options'>
{isEdit ? (
<>
{/* 把 Save 放到最前面 */}
<Button htmlType='submit'>Save</Button>
<Button onClick={() => setIsEdit(false)}>Cancel</Button>
<Button htmlType='reset' onClick={() => console.log('click')}>
Reset
</Button>
</>
) : (
<Button onClick={() => setIsEdit(true)}>Edit</Button>
)}
</div>
<Form.Item name='username' label='Username'>
<Input disabled={!isEdit} />
</Form.Item>
</Form>
)
}

export default App

default-action

所以就跟前面說的一樣,只要把該重新渲染的元素加上 key 就可以解決了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const App = () => {
const [isEdit, setIsEdit] = useState(false)
return (
<Form onReset={() => console.log('reset')} onFinish={() => console.log('submit')}>
<div className='demo-form-options'>
{isEdit ? (
<>
{/* 加上 key */}
<Button key='submit' htmlType='submit'>
Save
</Button>
<Button onClick={() => setIsEdit(false)}>Cancel</Button>
<Button htmlType='reset' onClick={() => console.log('click')}>
Reset
</Button>
</>
) : (
<Button onClick={() => setIsEdit(true)}>Edit</Button>
)}
</div>
<Form.Item name='username' label='Username'>
<Input disabled={!isEdit} />
</Form.Item>
</Form>
)
}

export default App

add-key

附註:CodeSandbox

Ant Design-Form.Item Ant Design-Layout
Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×