iT邦幫忙

2021 iThome 鐵人賽

DAY 7
0
Modern Web

React 從 0.5 到 1系列 第 7

[鐵人賽 Day07] 為何不該使用 index 當作 Key 值 ?——React render 更新機制解釋

前言

你可能聽過以下這個錯誤案例(或者說 anti-pattern 的案例):在一個會不斷新增、排序、刪除的列表上,使用 index 作為 key 值。但你知道這麼做,究竟會造成什麼問題嗎?

todos.map((todo, index) => (
		// 只要有給 key 值就不會報錯了,那問題究竟在哪?
    <Todo {...todo} key={index} />
  ));
}

此篇文章會介紹 React 的 reconciliation algorithm,也就是在每一次的 render 之間,React 如何使用最有效率的方式去更新?如何去比較狀態更新之間的差異?寫 code 的時候要怎麼避免效能的浪費呢?

演算法基於兩個規則

使用 React 的時候,你可以把 render() 想像成一個建立出 React element tree 的方法,當 props 或者 state 更新之後,render() 會 return 一個新的 element tree。在這個過程中,React 面臨的議題是要有效率的去更新畫面,達到目前的 element tree 的樣子。

React 使用了啟發式演算法(heuristic algorithm) ,這套演算法是基於兩個假定的規則,而後來也在實踐中驗證了這套規則的可行性,是適用於大多數的 use cases 的。

規則一:在 render 更新的過程中,兩個不同 type 的元素會製造出相異的 element tree.

規則二:開發者會設定 key 值來暗示哪些子元素在 renders 之間是維持穩定不會改變的。

規則一的運作方式:不同的 type

比較兩個 element tree 的時候,React 會先去比較他們的根元素,依照 type 的相同或不同,接下來的行為也會有所不同。

何謂「有不同的 type」?例如 <a><img> 不同、<Article> 與 <Comment> 不同, <Button> 與 <div> 不同。

當兩個根元素有不同的 type 的時候,React 會把舊的 element tree 銷毀,然後建立新的。在銷毀舊的 element tree 的時候,連帶舊的 DOM nodes 也會被銷毀,Component instances 內會接受到 componentWillUnmount() ,建立起新的 element tree 的時候,新的 DOM node 也會被插入原先的 DOM 中,Component instances 內則會接受到 componentDidMount() ,同時,跟舊的 element tree 相關的 state 也會一起遺失。

銷毀的時候,所有在該元件底下的 root 都會一起 unmounted,例如:

// 舊的 element tree
<div><Counter /></div>
// 新的 element tree
<span><Counter /></span>

//// 在這個更新過程中,舊 <Counter> 也會一起被銷毀,並且 remount 一個新的。

那相同的 type 會發生什麼事情?

如果兩個根元素有相同的 type 呢?React 會去查看兩者的屬性,相同的會被留起來,而只會更新那些不同的屬性。例如:

// 舊的 element tree
<div className="before" title="stuff" />
// 新的 element tree
<div className="after" title="stuff" />

//// 在這個更新過程中,在底下的 DOM node 上,React 只會去更改 className 

那如果是 style 呢?

// 舊的 element tree
<div style={{color: 'red', fontWeight: 'bold'}} />
// 新的 element tree
<div style={{color: 'green', fontWeight: 'bold'}} />

//// 更新過程中,React 只會去更新 coloer,而不會去更新 fontWeight

處理完 DOM node 之後,接著是處理針對 children 的 recurses(遞迴)。

DOM node 之後,處理 recurses

當元件更新的時候, instance 會維持一樣,所以 state 可以被在 renders 之間保留起來。

但為了要與新的 element 一致,React 要更新底下元件 instance 的 props,在這個過程中 componentWillReceiveProps()componentWillUpdate()componentDidUpdate() 會在 instance 中被呼叫。接著,render() 方法會被呼叫,辨別差異的演算法(diff algorithm)會在前一個結果、以及後一個結果之間 recurses(遞迴)。

聽起來可能有點抽象,讓我們直接看案例吧。

// 從原本的 list
<ul>
	<li>first</li>
	<li>second</li>
</ul>

// 新增一個內容,變成新的 list
<ul>
	<li>first</li>
	<li>second</li>
	<li>third</li>
</ul>

React 會去對照,然後發現兩者的 <li>first</li><li>second</li> trees 對起來了,只有第三個 <li>second</li> 沒對到,然後就會去嵌入 <li>second</li> 這個 tree。

然而如果你加入新的 li 的方法,是從最前方加入...

// 從原本的 list
<ul>
	<li>first</li>
	<li>second</li>
</ul>

// 新增一個內容,變成新的 list
<ul>
	// 如果是加在前面呢?
	<li>third</li>
	<li>first</li>
	<li>second</li>
</ul>

React 就會不知道可以保留 <li>first</li><li>second</li> ,而是需要去 mutate 每一個 child,這種方式會很沒效率,所以才有接下來的 Key 值。

規則二的運作方式:用 key 值來做辨識

當這些 children 帶有 key 值的時候,React 就會用 key 值作為辨識,來辨別是否有新的 child 被加入或刪除,舉例來說:

// 從原本的 list
<ul>
	<li key="2015">Duke</li>
	<li key="2016">Villanova</li>
</ul>

// 新增一個內容,變成新的 list
<ul>
	// 並且是把新的元素加在前面
	<li key="2014">Connecticut</li>
	<li key="2015">Duke</li>
	<li key="2016">Villanova</li>
</ul>

在有 key 值的情況下,React 會知道擁有 2015 跟 2016 的 key 值的元素移動了,而 2014 key 值的元素是新的。所以獨特的 key 值會是重要的,而這個「獨特」只需要在他的同一層元素(siblings)中是不重複的就可以了,不需要是全域性的獨特。

因為上述類型的 component instances 的更新根據是 key,如果你使用 index 作為 key,而 list 物件又是會被刪減、被改變順序的,那就代表你的 key 值會不斷的改變,造成 uncontrolled inputs 這類東西的元件狀態被搞混、並且以預期之外的方式更新。

也不要使用不穩定的 key 值,例如 Math.random() ,容易讓你的 component instances 跟 DOM nodes 非必要的被重複建立。

https://reactjs.org/docs/reconciliation.html

https://robinpokorny.medium.com/index-as-a-key-is-an-anti-pattern-e0349aece318


上一篇
[鐵人賽 Day06] React 中如何攔截網站 Runtime 錯誤?- Error boundaries
下一篇
[鐵人賽 Day08] 如何使用 memoization 方法減少 useContext 非必要 re-render 的效能問題?
系列文
React 從 0.5 到 115
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言