[React技術內幕] key帶來了什麼

請叫我王磊同學發表於2017-09-03

  首先歡迎大家關注我的掘金賬號和Github部落格,也算是對我的一點鼓勵,畢竟寫東西沒法獲得變現,能堅持下去也是靠的是自己的熱情和大家的鼓勵。

  大家在使用React的過程中,當元件的子元素是一系列型別相同元素時,就必須新增一個屬性key,否則React將給出一個warning:
  

  所以我們需要了解一下key值在React中起到了什麼作用,在這之前我們先出一個小題目:   

import React from 'react'
import ReactDOM from 'react-dom'

function App() {
    return (
        <ul>
            {
                [1,1,2,2].map((val)=><li key={val}>{val}</li>)
            }
        </ul>
    )
}

ReactDOM.render(<App/>,document.getElementById('root'))複製程式碼

  現在要提問了,上面的例子顯示的是: 1,1,2,2還是1,2呢。事實上顯示的只有1和2,所以我們不禁要問為什麼?   

一致性處理(Reconciliation)  

  我們知道每當元件的propsstate傳送改變時,React都會呼叫render去重新渲染UI,實質上render函式作用就是返回最新的元素樹。這裡我們要明確一個點: 什麼是元件?什麼是元素?
  
  React元素是用來描述UI物件的,JSX的實質就是React.createElement的語法糖,作用就是生成React元素。而React元件是一個方法或者類(Class),其目的就是接受輸入並返回一個ReactElement,當然呼叫React元件一般採用的也是通過JSX的方法,其本質也是通過React.createElement方式去呼叫元件的。
  
  我們之前說過,元件stateprops的改變會引起render函式的呼叫,而render函式會返回新的元素樹。我們知道React使得我們並不需要關心更改的內容,只需要將精力集中於資料的變化,React會負責前後UI更新。這時候React就面臨一個問題,如果對比當前的元素樹與之前的元素樹,從而找到最優的方法(或者說是步驟最少的方法)將一顆樹轉化成另一棵樹,從而去更新真實的DOM元素。目前存在大量的方法可以將一棵樹轉化成另一棵樹,但它們的時間複雜度基本都是O(n3),這麼龐大的時間數量級我們是不能接受的,試想如果我們的元件返回的元素樹中含有100個元素,那麼一次一致性比較就要達到1000000的數量級,這顯然是低效的,不可接受的。這時React就採用了啟發式的演算法。   

啟發式演算法

  瞭解一下什麼是啟發式演算法:

啟發式演算法指人在解決問題時所採取的一種根據經驗規則進行發現的方法。其特點是在解決問題時,利用過去的經驗,選擇已經行之有效的方法,而不是系統地、以確定的步驟去尋求答案。

  React啟發式演算法就是採用一系列前提假設,使得比較前後元素樹的時間複雜度由O(n3)降低為O(n),React啟發式演算法的前提條件主要包括兩點:

  1. 不同的兩個元素會產生不同的樹
  2. 可以使用key屬性來表明不同的渲染中哪些元素是相同的

元素型別的比較

  函式React.createElement的第一個引數就是type,表示的就是元素的型別。React比較兩棵元素樹的過程是同步的,當React比較到元素樹中同一位置的元素節點時,如果前後元素的型別不同時,不論該元素是元件型別還是DOM型別的,那麼以這個節點(React元素)為子樹的所有節點都會被銷燬並重新構建。舉個例子:   

//old tree
<div>
  <Counter />
</div>

//new tree
<span>
  <Counter />
</span>複製程式碼

  上面表示前後兩個render函式返回的元素樹,由於Counter元素的父元素由div變成了span,那麼那就導致Counter的解除安裝(unmount)和重新安裝(mount)。這看起來沒有什麼問題,但是在某些情況下問題就會凸顯出來,比如狀態的丟失。下面我們再看一個例子:   

import React, {Component} from 'react'
import ReactDOM from 'react-dom'

class Counter extends Component {

    constructor(props){
        super(props);
    }

    state = {
        value: 0
    }

    componentWillMount(){
        console.log('componentWillMount');
    }

    componentDidMount(){
        this.timer = setInterval(()=>{
            this.setState({
                value: this.state.value + 1
            })
        },1000)
    }

    componentWillUnmount(){
        clearInterval(this.timer);
        console.log('componentWillUnmount');
    }

    render(){
        return(
            <div>{this.state.value}</div>
        )
    }
}

function Demo(props) {
    return props.flag ? (<div><Counter/></div>) : (<span><Counter/></span>);
}

class App extends Component{
    constructor(props){
        super(props);
    }

    state = {
        flag: false
    }

    render(){
        return(
            <div>
                <Demo flag = {this.state.flag}/>
                <button
                    onClick={()=>{
                        this.setState({
                            flag: !this.state.flag
                        })
                    }}
                >
                    Click
                </button>
            </div>
        )
    }
}

ReactDOM.render(<App/>, document.getElementById('root'))複製程式碼

  
  上面的例子中,我們首先讓計數器Counter執行幾秒鐘,然後我們點選按鈕的話,我們會發現計數器的值會歸零為0,並且Counter分別呼叫componentWillUnmountcomponentWillMount並完成元件解除安裝與安裝的過程。需要注意的是,狀態(state)的丟失有時候會造成不可預知的問題,需要尤為注意。
  
  


  
  那如果比較前後元素型別是相同的情況下,情況就有所區別,如果該元素型別是DOM型別,比如:

<div className="before" title="stuff" />

<div className="after" title="stuff" />複製程式碼

那麼React包保持底層DOM元素不變,僅更新改變的DOM元素屬性,比如在上面的例子中,React僅會更新div標籤的className屬性。如果改變的是style屬性中的某一個屬性,也不會整個更改style,而僅僅是更新其中改變的專案。

  如果前後的比較元素是元件型別,那麼也會保持元件例項的不變,React會更新元件例項的屬性來匹配新的元素,並在元素例項上呼叫componentWillReceiveProps()componentWillUpdate()。   

key屬性

  在上面的前後元素樹比較過程中,如果某個元素的子元素是動態陣列型別的,那麼比較的過程可能就要有所區分,比如:   

//注意:
//li元素是陣列生成的,下面只是表示元素樹,並不代表實際程式碼
//old tree
<ul>
  <li>first</li>
  <li>second</li>
</ul>

//new tree
<ul>
  <li>first</li>
  <li>second</li>
  <li>third</li>
</ul>複製程式碼

  當React同時迭代比較前後兩棵元素樹的子元素列表時,效能相對不會太差,因為前兩個項都是相同的,新的元素樹中有第三個專案,那麼React會比較<li>first</li>樹與<li>second</li>樹之後,插入<li>third</li>樹,但是下面這個例子就不同的:   

//注意:
//li元素是陣列生成的,下面只是表示元素樹,並不代表實際程式碼
//old tree
<ul>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

//new tree
<ul>
  <li>Connecticut</li>
  <li>Duke</li>
  <li>Villanova</li>
</ul>複製程式碼

  React在比較第一個li就發現了差異(<li>Duke</li><li>Connecticut</li>),如果React將第一個li中的內容進行更新,那麼你會發現第二個li(<li>Villanova</li><li>Duke</li>)也需要將li中內容進行更新,並且第三個<li>需要安裝新的元素,但事實真的是如此嗎?其實不然,我們發現新的元素樹和舊的元素樹,只有第一項是不同的,後兩項其實並沒有發生改變,如果React懂得在舊的元素樹開始出插入<li>Connecticut</li>,那麼效能會極大的提高,關鍵問題是React如何進行這種判別,這時React就用到了key屬性
  
例如:

//注意:
//li元素是陣列生成的,下面只是表示元素樹,並不代表實際程式碼
//old tree
<ul>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

//new tree
<ul>
  <li key="2014">Connecticut</li>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>複製程式碼

  通過key值React比較<li key="2015">Duke</li><li key="2014">Connecticut</li>時,會發現key值是不同,表示<li key="2014">Connecticut</li>是新插入的項,因此會在開始出插入<li key="2014">Connecticut</li>,隨後分別比較<li key="2015">Duke</li><li key="2016">Villanova</li>,發現li項沒有發生改變,僅僅只是被移動而已。這種情況下,效能的提升是非常可觀的。因此,從上面看key值必須要穩定可預測的並且是唯一的。不穩定的key(類似於Math.random()函式的結果)可能會產生非常多的元件例項並且DOM節點也會非必要性的重新建立。這將會造成極大的效能損失和元件內state的丟失。
  
  回到剛開始的問題,如果存在兩個key值相同時,會發生什麼?比如:   

 <ul>
    {
        [1,1,2,2].map((val)=><li>{val}</li>)
    }
</ul>複製程式碼

  我們會發現如果存在前後兩個相同的key,React會認為這兩個元素其實是一個元素,後一個具有相同key值的元素會被忽略。為了驗證這個事實,我們可以看下一個例子:

import React, {Component} from 'react'
import ReactDOM from 'react-dom'

function Demo(props) {
    return (
        <div>{props.value}</div>
    )
}

class App extends Component {
    constructor(props) {
        super(props);
    }

    render() {
        return (
            <div>
                {
                    [1, 1, 2, 2].map((val, index) => {
                        return (
                            <Demo
                                key={val}
                                value={val + '-' + index}
                            />
                        )
                    })
                }
            </div>
        )
    }
}

ReactDOM.render(<App/>, document.getElementById('root'))複製程式碼

我們發現最後的顯示效果是這樣的:

  到這裡我們已經基本明白了key屬性在React中的作用,因為key是React內部使用的屬性,所以在元件內部是無法獲取到key值的,如果你真的需要這個值,就需要換個名字再傳一次了。
  
  其實還有一個現象不知道大家觀察到了沒有,比如:   

//case1
function App() {
    return (
        <ul>
            {
                [
                    <li key={1}>1</li>,
                    <li key={2}>2</li>
                ]
            }
        </ul>
    )
}
//case2
function App() {
    return (
        <ul>
            <li>1</li>
            <li>2</li>
        </ul>
    )
}複製程式碼

  我們會發現,第一種場景是需要傳入key值的,第二種就不需要傳入key,為什麼呢?其實我們可以看一下JSX編譯之後的程式碼:   

//case1
function App() {
    return React.createElement('ul',null,[
        React.createElement('li',{key: 1}, "1"),
        React.createElement('li',{key: 2}, "2")
    ])
}
//case2
function App() {
    return React.createElement('ul',
        null,
        React.createElement('li',{key: 1}, "1"),
        React.createElement('li',{key: 2}, "2")
    )
}複製程式碼

  我們發現第一個場景中,子元素的傳入以陣列的形式傳入第三個引數,但是在第二個場景中,子元素是以引數的形式依次傳入的。在第二種場景中,每個元素出現在固定的引數位置上,React就是通過這個位置作為天然的key值去判別的,所以你就不用傳入key值的,但是第一種場景下,以陣列的型別將全部子元素傳入,React就不能通過引數位置的方法去判別,所以就必須你手動地方式去傳入key值。
  
  React通過採用這種啟發式的演算法,來優化一致性的操作。但這都是React的內部實現方式,可能在React後序的版本中不斷細化啟發式演算法,甚至採用別的啟發式演算法。但是如果我們有時候能夠了解到內部演算法的實現細節的話,對於優化應用效能可以起到非常好的效果,對於共同學習的大家,以此共勉。

相關文章