push(),React 為什麼不行?上一篇認識 useState() 時,知道 React 需要透過更新函式改變 State:
const [todos, setTodos] = useState<Todo[]>([])
其中能看到出現了一個比較陌生點的寫法:
setTodos([...todos, newTodo])
過去在 Vue 專案中,可以直接使用 push() 新增資料:
// Vue
todos.value.push({
id: Date.now(),
text,
completed: false,
})
為什麼到了 React,不能直接寫成:
todos.push(newTodo)
而是要建立一個新陣列,再交給 setTodos()?
因為 React 會將 State 視為唯讀資料。更新陣列或物件時,不應該直接修改原本的內容,而是要建立一份新的資料,再透過更新函式交給 React。
JavaScript 的陣列本身可以被修改,例如:
const todos = []
todos.push(newTodo)
push() 會直接改變原本的 todos 陣列。這種直接改變原資料的操作,通常稱為 Mutation。
常見的陣列 Mutation 方法包括:
push()
pop()
splice()
sort()
reverse()
在一般 JavaScript 中,這些方法不是不能使用;但如果陣列被放在 React State 裡,就不應該直接修改它。
例如:
todos.push(newTodo)
setTodos(todos)
雖然最後呼叫了 setTodos(),但傳回去的仍然是原本那個陣列。
因為傳入的仍然是原本那個陣列,React 會將它判斷為相同的 State,因此可能略過這次畫面更新。直接修改原資料,也容易讓程式中的資料變化變得難以追蹤。
所以更適合的做法是:
不修改原本的 State,而是根據舊資料建立一份新資料,再交給更新函式。
目前專案新增 Todo 的程式碼是:
function addTodo(text: string) {
const newTodo: Todo = {
id: Date.now(),
text,
completed: false,
}
setTodos([...todos, newTodo])
}
這裡的:
[...todos, newTodo]
會建立一個新陣列。
...todos 會先將原本的 Todo 放進新陣列,再將 newTodo 放在最後面。
假設原本的資料是:
[todoA, todoB]
執行:
[...todos, newTodo]
就會得到:
[todoA, todoB, newTodo]
原本的 todos 沒有被直接修改,而是產生一個包含新項目的陣列,再交給 setTodos()。
filter()function deleteTodo(id: number) {
setTodos(todos.filter((todo) => todo.id !== id))
}
filter() 不會直接修改原陣列,而是根據條件建立一個新陣列。
這裡的條件:
todo.id !== id
代表只保留 id 不等於刪除目標的 Todo。
因此,這段程式碼可以理解成:
建立一個不包含指定 Todo 的新陣列,再使用它更新 State。
map()function toggleTodo(id: number) {
setTodos(
todos.map((todo) =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo,
),
)
}
map() 會建立一個新陣列。
如果目前的 Todo 是要修改的資料:
todo.id === id
就回傳一個新物件:
{ ...todo, completed: !todo.completed }
其中,...todo 會保留原本 Todo 的內容,再將 completed 改成相反的值。
如果不是要修改的 Todo,就直接回傳原本的資料:
todo
整段可以理解成:
找到指定 Todo → 建立一個更新後的新物件
其他 Todo → 保持原本內容
最後,map() 將結果組成新陣列,再交給 setTodos()。
只建立新陣列,還不一定代表裡面的物件也是新的。
例如:
const nextTodos = [...todos]
nextTodos[0].completed = true
雖然 nextTodos 是一個新陣列,但裡面的 Todo 物件仍然和原本陣列指向同一份資料。
因此:
nextTodos[0].completed = true
還是會修改到原本 State 裡的 Todo。
這也是專案在修改 Todo 時,同時使用兩種寫法的原因:
todos.map(...)
負責建立新陣列,而:
{ ...todo, completed: !todo.completed }
則負責建立更新後的新 Todo 物件。
可以先將原則記成:
從實際要修改的那一層開始,建立新的資料。
React 官方文件將 State 描述成某一次 Render 的快照。
例如:
setText('新的文字')
console.log(text)
呼叫 setText() 後,不會立刻改變目前這次程式執行中的 text。
setText() 比較像是在通知 React:
下一次 Render 請使用新的 State。
接著讀取 text 時,仍然可能取得這次 Render 的舊值。到了下一次 Render,Component 才會收到新的 State,並使用它更新畫面。
這也說明了為什麼 State 不像一般變數一樣,直接指定新的值就好,而是需要透過 React 提供的更新函式處理。
// Vue
todos.value.push(newTodo)
// React
setTodos([...todos, newTodo])
Vue 的響應式系統可以追蹤對陣列進行的修改,因此可以使用 push() 更新 ref() 裡的陣列。
React 則將 State 視為唯讀資料,需要建立新陣列或新物件,再透過更新函式設定新的 State。
兩者都是為了讓資料改變後更新畫面,只是採用的方式不同。
在 React State 中,可以先這樣整理:
| 操作 | 避免直接使用 | 建議使用 |
|---|---|---|
| 新增 | push() |
[...todos, newTodo] |
| 刪除 | splice() |
filter() |
| 修改 | 直接指定陣列項目 | map() |
| 排序 | 直接使用 sort() |
先複製陣列再排序 |
重點不是這些 JavaScript 方法突然不能使用,而是:
不要直接修改已經存在於 State 裡的資料。
filter()、map()
這篇算是上一篇的再深入延伸,下一篇預計往 React 的事件處理探索,下篇見~