logo
Published on

React效能優化篇(二) immer 介紹與使用

Authors
  • avatar
    Name
    Eddy Chang
    Twitter

前言: immutable 該怎麼操作

JavaScript 的物件天生就不是 immutable(不可變的),即便在現在有了新的、更容易進行拷貝的展開運算符,它仍然是一種淺拷貝的運算,也就是只會拷貝一層的作法,我們先討論一些常看到的解決方式。

  • 展開運算符等等: 物件的淺拷貝語法等等 API
  • Object.freeze: 內建 API,針對物件,功能是會凍結物件的屬性,變成無法修改、新增、刪除,僅能讀取,它是淺層地(shallowly)作用在一層而已。
  • Object.seal: 內建 API,密封一個物件的屬性,使之無法新增新的屬性,或移除原有屬性,但可以修改、讀取原有屬性。(相當於configurablefalsewritabletrue)
  • Object.preventExtensions: 內建 API,使一個物件的屬性無法再新增新的屬性(稱為擴展)
  • Object.defineProperty: 內建 API,針對物件屬性層次,可以直接定義屬性的writableconfigurablefalse來讓屬性變為只讀不能寫。

註: 這篇討論中有個簡單的對照表,可以看到 freeze, seal, preventExtensions 三個方法的應用後的情況。

為了要解決各內建的 API 都只會作用在淺層地(shallowly),也有一些其它的函式庫或方式可以作深拷貝:

  • 迴圈結構加上淺拷貝語法寫
  • JSON.parse/stringify: 利用 JSON 先字串化又剖析來作深拷貝,這是一個很古早時代就有的語法,有許多人不建議這個語法。因為它有很多缺點,例如 JSON 中缺少了一大堆 JS 中的資料類型(Date, Map, Set, undefined...)
  • structuredClone: 非常新的深拷貝方法,而且是 JS 引擎內建的方法,但它只在新版本的瀏覽器/JS 引擎中能使用(Chrome98, Firefox 94, Safari 15.4, Node 17)
  • Lodash 的 cloneDeep: 額外函式庫深拷貝方法
  • jQuery.extend: 額外函式庫深拷貝方法

註: 以上該視情況使用,如果你的物件層級都是單純的數值、物件、陣列而已,或許JSON.parse/stringify是較合適,它除了與各新舊瀏覽器版本都相容外,另外 JSON 在 Chrome 的 JS 引擎上還有特別效能優化過,所以並不會慢,反而在很多測試上都是最快的。

註: structuredClone 可以參考這篇來自 Google web.dev 開發部落格中的說明,它是非常新的東西。

要作深層地(deeply) freeze 或 seal,可以透過上述的內建 API,遞回地來作,也有現成的小型函式庫或程式碼可以參考,這就不另外列出。

因此,我們要真正將 JS 中複雜的物件值,轉變成為 immutable,需要加入兩種作法,也就是深拷貝深凍結,才能完全地實作出這兩種作法,但這兩種作法對於整體的應用的執行效能,都會帶來大幅度降低,這也是為什麼有許多針對 immutable 結構的應用程式,要另外搭配專門處理的函式庫的主因,不過在這些函式庫的實作方式中,這些函式庫都不是真正將深拷貝深凍結作到完全徹底的那種死作法,而是採取各種折衷方案,權衡效能與使用上的必需性等等,才能達成需求。以下是兩套最常見函式庫與它們簡單說明:

  • immutable: Facebook 出品的老牌的函式庫,有著高人氣和高下載量,就是要針對 React 中的 immutable 結構應用而設計的函式庫,目前仍然是最多下載量與 Github 星數的函式庫。
  • immer: 後起之秀,由 Mobx 的創作者所開發,在 Redux Toolkit 內建與 Redux 官方推薦使用後聲勢高漲,語法簡單易於使用是它的最大優點。

immer 介紹

immer(在德語中是 always 之意),是一套讓你用簡便的方式,來處理不可變狀態(immutable state)的輕量函式庫。

immer 官網提供了豐富的教學與介紹,如果你對 immutable 物件的處理已經有一些經驗,相信可以很容易理解教學的內容:

優點與代價

優點

  1. Immer 可以大副度的簡化 immutable 的更新邏輯
  2. Immer 可以有效率地評量對 immutable state 意外的更動

代價

  1. Immer 會增加整個 app 的打包後的大小。約為 8K(min 壓縮後), 3.3K(min+gz)
  2. Immer 會增加一些執行時的效能消耗
  3. Immer 使用了 Proxy,這會在瀏覽器中除錯不方便,難以在除錯時觀察 state 變數,另外在不支援的環境(ES5/React Native)中會減低效能
  4. 教學和理解方面,因為它看起來像是在直接改變不可變值

API 應用

produce 這個方法是它的核心 API,使用的方式相當簡單。以下的範例來自這裡:

import produce from 'immer'

const baseState = [
  {
    title: 'Learn TypeScript',
    done: true,
  },
  {
    title: 'Try Immer',
    done: false,
  },
]

const nextState = produce(baseState, (draftState) => {
  draftState.push({ title: 'Tweet about it' })
  draftState[1].done = true
})
  • baseState 代表著要傳入 produce 的 immutable state
  • draftState 是可以安全地直接修改的,它對原本的 baseState 來說是一個 proxy,一般最好取名為draftdraftState

官網的一個圖示說明了它的工作原理,以下圖片來自這裡,開發者實際上是在 draft 中修改物件中的資料,而非原本的 state 中:

immer

immer 也應用了結構上共享(Structural sharing)機制,並非所有的原本狀態與新狀態的分支結構都是完整拷貝的情況,沒有修改的部份仍然是相等的,例如上述的範例中,可以見到它們在baseState[0] === nextState[0]是相等的,也就是同樣的記憶體位置。

// structural sharing
console.log(baseState === nextState) // false
console.log(baseState[0] === nextState[0]) // true
console.log(baseState[1] === nextState[1]) // false

produce 也有另一種用法,目的是為了更縮短常見的柯里化寫法,可以把像下面的這樣的語法:

function toggleTodo(state, id) {
  return produce(state, (draft) => {
    const todo = draft.find((todo) => todo.id === id)
    todo.done = !todo.done
  })
}

const nextState = toggleTodo(baseState, 'Immer')

寫成像下面這樣,也就是說當produce只有第二個傳入參數 callback(稱為 recipe function)時,它會返回一個新的函式,可以用來套用基礎的 state 之用。

// curried producer:
const toggleTodo = produce((draft, id) => {
  const todo = draft.find((todo) => todo.id === id)
  todo.done = !todo.done
})

const nextState = toggleTodo(baseState, 'Immer')

immer 巧妙的使用了兩種作法:

  1. Copy-on-write: 寫入時複製,如果呼叫者沒有修改該資源,就不會有完整的拷貝副本被建立,因此多個呼叫者只是讀取操作時可以共享同一份資源。

  2. Proxies: Proxy 物件用於建立一個物件的代理,從而實現基本操作的攔截和自定義(如屬性查找、指定值、枚舉、函數呼叫等)

這些作法當然是為了更有效率與更聰明的來針對 immutable object 作修改。

當然,immer 中也提供了自動凍結(freeze)的機制,針對你有用到 produce 產生的修改部份而已,因為完全凍結整個大的物件是一個耗費極大的操作。

其它還有搭配的 API 與各種設定值,可以參考官網的文件

useImmer 與 useImmerReducer

另外的函式庫use-immer,搭配 immer 後可以取代 useState 與 useReducer,使用 immer 原本的 draftState 直接修改的語法,下面是簡單的範例,不過它的使用量並不高:

const [person, updatePerson] = useImmer({
  name: 'Michel',
  age: 33,
})

function updateName(name) {
  updatePerson((draft) => {
    draft.name = name
  })
}

function becomeOlder() {
  updatePerson((draft) => {
    draft.age++
  })
}
function reducer(draft, action) {
  switch (action.type) {
    case 'reset':
      return initialState
    case 'increment':
      return void draft.count++
    case 'decrement':
      return void draft.count--
  }
}

function Counter() {
  const [state, dispatch] = useImmerReducer(reducer, initialState)

  //...
}

redux 與 immer

Redux Toolkit 已內建 immer 這套函式庫,因此可以見到在官網的範例中,只要使用createSlicecreateReducer方法,可以直接更動 state 值。例如以下的範例:

const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    increment(state) {
      state.value++
    },
    decrement(state) {
      state.value--
    },
    incrementByAmount(state, action) {
      state.value += action.payload
    },
  },
})
const counterReducer = createReducer(0, {
  increment: (state, action) => state + action.payload,
  decrement: (state, action) => state - action.payload,
})

結論

  • 深複制與深凍結對於大結構的物件來說,都是消耗很大也會影響效能的操作,如 React 中並沒有實作這個機制(或只有對 props 實作淺層)
  • 使用外部函式庫如 immer 來處理 immutable 物件,有其優點與代價,這當然需要視需求決定,不過你如果是要使用 Redux Toolkit,目前來說它是必要的