埋藏玄機的地雷。
簡述
這是我自己在工作上踩到的一個地雷,原先在 debug 的過程中以為是 Antd 設計上的問題,一直到最後找出答案時才發現這似乎跟 React 的渲染機制有點相關,總之讓我們來重現一下當時的情境吧。
這邊要做的是這樣的功能:
簡單來說就是一個能切換編輯和閱讀的表單而已,然後在編輯時會出現「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」:
看起來就像是自動被執行了 reset 一樣?為了找出問題我們可以在 <Form>
加上 onReset
來檢查看看:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| <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
|
用看的就大概知道會發生什麼了對吧?不過請仔細看看下面的演示,看能不能從中發現什麼:
想好的話再往下看吧。
我是防雷線…
我是防雷線…
我是防雷線…
我是防雷線…
我是防雷線…
按鈕確實有依照我們想的去做切換,不過你有沒有發現到 按鈕的 active 狀態(樣式) 也被保留下來了呢?這個就是關鍵點。
當我們把按鈕從 Edit
切換成 Cancel
時,照理來說應該要是一個「全新的按鈕」,換句話說就是沒有被點擊過,所以不該有 active 的狀態。
不過 React 為了節省一些開銷,所以在重新渲染時的實際流程是這樣:
- 把新的跟舊的按鈕做比對
- 把真正需要更新的內容給替換掉
這樣子的機制就有機率出現像上面這種「按鈕被更新了,但卻保留了原本的內容(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 的樣式了:
因為每一次都是一個全新的按鈕,所以不會在這個按鈕身上看到任何其他按鈕的影子,他永遠會是他自己而已。
回歸到最開始的範例
複習一下最原本的範例,之所以會發生那樣的問題就是因為「按鈕被重複使用」的關係,複習一下這張圖:
有注意到 Edit
按下以後切換到新的按鈕時,Reset
也保留了 active 的樣式嗎?這樣代表 Reset
就是拿 Edit
來改的意思。
這個時候呢,因為 Reset 按鈕有 htmlType="reset"
這個 props,所以就會觸發「把表單重置」的預設行為,所以表單就被重置了。
所以如果你把 Reset
跟 Save
的位置交換的話,就會發現變成觸發 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
|
所以就跟前面說的一樣,只要把該重新渲染的元素加上 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
|
附註:CodeSandbox