logo
Published on

React效能優化篇(一) Immutability、pure function與render機制

Authors
  • avatar
    Name
    Eddy Chang
    Twitter

什麼是 Immutability

"Mutable(可變的)"代表是可以被改變的、修改的,而"Immutable(不可變的)"則是它的反義詞,代表不可以被改變或修改的。

在物件導向與函數式程式開發中,Immutable object(或 unchangeable object)代表的是,一旦建立出來的即無法再修改的物件,不可變物件在一些場合下閱讀性較高可以提高執行效率,而且提供較高的安全性,它也有天然的執行緒安全(thread-safe)相較於可變物件。

JavaScript 並非是純粹的函數式程式語言,在純函數式程式語言中,所有的物件都是不可變的。在 JavaScript 中的設計是所有基本資料類型如 Undefined, Null, Boolean, Number, BigInt, String, Symbol 是不可變的,但物件的通常是可變的。例如在 Array 中的 API,有些不可變的作法(意思是會回傳新的陣列,而非直接修改原陣列)。

為什麼一定要使用不可變的物件

這與 react 元件的內部設計有關,在 react(或 redux)中,一定要保持你在 state(狀態)或 props(屬性)上的各種處理都是純函式的原則,在官網上有許多相關說法。

出自:Updating the Rendered Element

React elements are immutable. Once you create an element, you can’t change its children or attributes... (中譯: React 元素是不可變的(immutable)。當你建立好一個元素,你不可以再更動它的子元素或屬性。…)

出自:state

Never mutate this.state directly, as calling setState() afterwards may replace the mutation you made. Treat this.state as if it were immutable. (中譯: 絕對不要直接地更動 this.state,因為在接下來後面呼叫 setState(),有可能會替換掉你前面作的更動。把 this.state 看作是不可變的(immutable))

出自: Props are Read-Only

All React components must act like pure functions with respect to their props. (中譯: 所有的 React 元件都必須像 pure functions(純函式) 一樣考量保護到它的 props(屬性))

純函式原則

純函式必須滿足以下的兩個原則:

  1. 給定相同的輸入(傳入參數值)會回傳相同的輸出(回傳值)
  2. 沒有包含副作用(side effect)

註: 簡單規則是"Output depends only on input"(ODI)與"No side effects"(NSE)

進一步延伸以下的原則:

  • 不會依賴任何外部的(或隱藏的)值,"只"依賴輸入(傳入參數值)
  • 不會修改任何外部的(或隱藏的)值
  • 不會修改它的傳入參數值(註: 傳入參數值也算是外部值)

副作用常見的包含以下幾種:

  • HTTP request(fetch/ajax)
  • 資料庫/檔案/IO 讀寫
  • 印出到螢幕上或 console
  • DOM 查詢/處理
  • 隨機數(Math.random())
  • 計時器

因此當你傳入一個物件值到純函式時,要回傳一個修改其中的資料的物件時,該怎麼寫這個函式,例如以下的範例,想要把city屬性的值更動為Taipei,該怎麼作?

const user = {
  id: '1',
  name: 'Eddy',
  address: {
    country: {
      city: 'New Taipei City',
    },
  },
}

像下面這樣寫?當然不是,這更動到傳入參數值中,傳入參數值對純函式來說也是外部狀態值,不可修改的值。

function changeCity(user) {
  //!!錯誤語法,直接修改到原物件(傳入參數值)
  user.address.country.city = 'Taipei'

  return user
}

正確要像下面這樣寫才行,由於展開運算符只能作物件的淺拷貝之用,所以必需展開再展開,每層的物件值都需要是個全新的物件才行,你不能在有參照到的原物件的子物件中,作任何更動的操作。純函式需要回傳一個全新的,與原本的傳入的 user 物件完全不同的物件值才行。

function changeCity(user) {
  const newUser = {
    ...user,
    address: {
      ...user.address,
      country: {
        ...user.address.country,
        city: 'Taipei',
      },
    },
  }

  return newUser
}

假設這個user物件是 react 的 state 值,當你要傳入setState時,得要像newUser的物件這樣才行。

const [state, setState] = useState(user)

function handleChangeCity(newCity) {
  const newState = {
    ...state,
    address: {
      ...state.address,
      country: {
        ...state.address.city,
        city: newCity,
      },
    },
  }

  setState(newState)
}

setState 可以使用 updater(更新 state 用的 callback 函式) 作為傳入參數的另一種語法,像下面這樣寫,你可以比對上面的changeCity函式,根本上是一樣的內容,只是多了可以傳入要更動的 city 值而已。

const [state, setState] = useState(user)

function handleChangeCity(newCity) {
  setState((prevState) => {
    const newState = {
      ...prevState,
      address: {
        ...prevState.address,
        country: {
          ...prevState.address.city,
          city: newCity,
        },
      },
    }

    return newState
  })
}

關於 setState 的 update 可以參考官網這裡的文件,它可以得到更動當下元件的 state 與 props 值,其中有段提到:

state is a reference to the component state at the time the change is being applied. It should not be directly mutated. Instead, changes should be represented by building a new object based on the input from state and props

當更動正在被套用的當下時,state 是對應到元件狀態的一個參照,因此它不應該直接地被更動。相反地,任何更動都應該用一個基於從 state 和 props 的輸入值所建立的新的物件來表示。

常見錯誤: 意外地更動 state

回到上面的例子中,下面的寫法一樣是錯誤的,但 React 的 state 更新似乎也是正常執行:

// !!警告: 錯誤範例請勿使用
function handleChangeCity(newCity) {
  const newState = { ...state }

  newState.address.country.city = newCity

  setState(newState)
}

上面這仍然是錯誤的語法,在官網上的state說明如下:

絕對不要直接地更動 this.state,因為在接下來後面呼叫 setState(),有可能會替換掉你前面作的更動。把 this.state 看作是不可變的(immutable)

這寫法能夠正常執行,是因為在const newState = { ...state }這行程式碼執行時,的確產生了一個全新的物件值,但展開運算符只會作淺拷貝,也就是像更深層的addressaddress.country物件並沒有被完全拷貝出來,它們仍然指向原本state中的同樣記憶體位置。那接下來的setState呼叫時,接收到不同的物件傳入參數,自然會作 re-render 的動作。

但問題是會出在哪?

  1. setState方法是有經過特殊設計的(最佳化、有異步特性等等),所以它有可能會在newState.address.country.city = newCity執行後,用原本的、還沒更動前的 newState 覆蓋掉這行的執行結果。假設newState.address.country.city = newCitynewCity需要一段時間才能得到,例如來自伺服器等等,就會產生這個現象。這將會造成整個程式運作出現不預期的結果,類似多按幾下才會有反應或正常變動 state,或是有時候正常有時候失效這種怪現象。

  2. React 中有許多內建或提供給開發者,比較前後 state 的機制,通常是用在元件Did Update的生命周期中,大部份都是要用來作效能最佳化,或是避免掉不需要的更動、相依性陣列等等,如果像上面寫,會造成這些比較的機制失效或語法錯誤,簡單說就是原本的 state 被破壞了,所以完全比較不出來。

參照相等性(reference equality)

註: 也可以稱為參照一致性(reference identity)

這指的是對於物件或陣列(函式也一樣),比較的是它指向同一個物件才算相等,意思是參照到同一物件的記憶體位置,詳細內容可以參考 MDN 的Object.is,React 中的 state 使用這個比較機制。例如以下的例子:

const counter1 = { total: 1 }
const counter2 = { total: 1 } //counter2是獨立於counter1外的另一物件

const counter3 = counter1 //counter3參照到同一物件

console.log(counter1 === counter2) //false
console.log(counter1 === counter3) //true

淺比較(Shallow Compare)

注意: 這指的並非 render 時的對 state 或 props 時,React 一般元件作的比較。它是用於特定的情況,只有幾種情況會用到淺比較,PureCompoenent 與類似的 React.memo 設計,以及 shouldComponentUpdate 生命周期方法內使用的稱為 shallowCompare 的 addon 這三者和淺比較有關而已。

也可稱淺相等(Shallow Equal)比較,"淺"的意思是會比較第一層。

當 React 中在淺比較 props 或 state 更動時,會比較物件的第一層,當然也只會比較第一層的各鍵與值,而不會作之後更深層的比較,會有這個設計是因為以前是以類別型元件為主的時候,state 與 props 的設計必然是一種物件類型,為求方便起見使用了這個機制。

很顯然的,當 state 或 props 中的對應的值中如果也有參照型的如物件或陣列,這個比較一樣只會回傳 false 而已,官網這裡是以shouldComponentUpdate這個範例來說明:

export class SampleComponent extends React.Component {
  shouldComponentUpdate(nextProps, nextState) {
    return shallowCompare(this, nextProps, nextState)
  }

  render() {
    return <div className={this.props.className}>foo</div>
  }
}

這個很久前就有的,但目前已經不建議使用的shallowCompare擴充會比較第一層的目前的 props 與 nextProps,以及 state 與 nextState,來決定shouldComponentUpdate的回傳值應該是 true 或 false。

因此在第一層如果是值的情況就是嚴格相等比較(===),但如果是物件的情況,會往下一層比較 key 與 value 的情況,對於物件(陣列、函式)仍然會回到上一節說的參照相等性的比較結果。

更多詳細內容可以參考討論How does shallow compare work in react或 react 中有關shallowEqual 實作。

React 是如何決定 render 元件的

解答或許比較你想像的簡單,react 預設就是:

當 state 被更動或接受到新的 props,就會觸發 render 元件

所謂的"更動",如果是一般的數值(string、number、boolean),用嚴格相等比較(===),物件值以即上面說參照相等性(reference equality)來比較。可想而知,當 state 是物件資料類型時,不論怎麼變化,只要一作 setState 都一定會觸發 re-render 機制。

繼續談到父母-子女(Parent-Child)元件的結構時,父母元件中只要有任何 state 或 props 更動,觸發自己的 re-render(重新渲染),它下面的子女元件就會因父母元件遞迴也會 re-render(重新渲染),也不論這個 state 是否有傳入子女元件中,子女元件本來就是父母元件 render 的一部份,這個機制的設計很合邏輯。

但上述說的 re-render 並不代表 react 會作移除又新增 DOM 元素,或者產生再次更動 DOM 元素的行為,它裡面有一套機制在比對真實 DOM 與虛擬 DOM 的差異處。所以這裡指的 re-render 是在 React 中 Virtual(虛擬 DOM) 的 re-render,真正會改變網頁真實 DOM 上的 rendering 行為稱為 re-paint(重畫)。

在小規模的應用程式中,這不會有什麼大問題,對效能的影響微乎其微。但在複雜的應用程式,或是重覆使用次數非常多的子元件時,或是執行會很耗資源又多節點的許多 DOM 節點時,就會產生效能的影響,因此需要進行對 re-render 加入限制情況與最佳化,這在後面的章節中會一一探討。

那 context(上下文)的情況呢?也是如此?

在 context 的 re-render 情況也是類似的,不過和剛說的 Parent-Child 元件關係不同,它用的是 Provider-Consumer(提供者-消費者) 的關係,當 Provider 中的值改變時,其中有包含在 Consumer 中的子元件(有使用 useContext.contextType)才會 re-render,而不是所有的包含在其中的子元件。

另外要提及的是, context 中有自己的一套檢查機制,它也是使用參照相等性來決定是否要 re-render,也就是說當 context 傳遞的是數值,仍然會視數值是否嚴格相等(===),但如果 context 傳遞的是一個物件,那就有可能會觸發不預期的 re-render。

結論

  • state 具有不可變動性(immutability),為了要更新不可變的 state,你需要先完整拷貝(尤其是陣列/物件)出來後,然後在拷貝出來的 state 上更動
  • 以 Parent-Children 關係來說,只要 Parent 的 state 一有更動,React 會依照值用嚴格相等比較(===),物件(陣列/函式)使用參照相等性比較,比較前一個 state 與目前的 state 後,來決定是否觸發 re-render。當 Parent 一旦 re-render,所有 Children 必會 re-render
  • 以 Provider-Consumers 關係來說,與上述類似,但變動是指 Prvider 與包含在其中的各 Consumers(有使用 useContext.contextType)才會觸發 re-render
  • 當使用 PureComponent, React.memo 時,才會使用到內建的淺比較(Shallow Compare)特性

參考資料