- Published on
React效能優化篇(二) immer 介紹與使用
- Authors
- Name
- Eddy Chang
前言: immutable 該怎麼操作
JavaScript 的物件天生就不是 immutable(不可變的),即便在現在有了新的、更容易進行拷貝的展開運算符,它仍然是一種淺拷貝的運算,也就是只會拷貝一層的作法,我們先討論一些常看到的解決方式。
- 展開運算符等等: 物件的淺拷貝語法等等 API
- Object.freeze: 內建 API,針對物件,功能是會凍結物件的屬性,變成無法修改、新增、刪除,僅能讀取,它是淺層地(shallowly)作用在一層而已。
- Object.seal: 內建 API,密封一個物件的屬性,使之無法新增新的屬性,或移除原有屬性,但可以修改、讀取原有屬性。(相當於
configurable
為false
但writable
是true
) - Object.preventExtensions: 內建 API,使一個物件的屬性無法再新增新的屬性(稱為擴展)
- Object.defineProperty: 內建 API,針對物件屬性層次,可以直接定義屬性的
writable
與configurable
為false
來讓屬性變為只讀不能寫。
註: 這篇討論中有個簡單的對照表,可以看到 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 物件的處理已經有一些經驗,相信可以很容易理解教學的內容:
優點與代價
優點
- Immer 可以大副度的簡化 immutable 的更新邏輯
- Immer 可以有效率地評量對 immutable state 意外的更動
代價
- Immer 會增加整個 app 的打包後的大小。約為 8K(min 壓縮後), 3.3K(min+gz)
- Immer 會增加一些執行時的效能消耗
- Immer 使用了 Proxy,這會在瀏覽器中除錯不方便,難以在除錯時觀察 state 變數,另外在不支援的環境(ES5/React Native)中會減低效能
- 教學和理解方面,因為它看起來像是在直接改變不可變值
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 statedraftState
是可以安全地直接修改的,它對原本的 baseState 來說是一個 proxy,一般最好取名為draft
或draftState
官網的一個圖示說明了它的工作原理,以下圖片來自這裡,開發者實際上是在 draft 中修改物件中的資料,而非原本的 state 中:
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 巧妙的使用了兩種作法:
Copy-on-write: 寫入時複製,如果呼叫者沒有修改該資源,就不會有完整的拷貝副本被建立,因此多個呼叫者只是讀取操作時可以共享同一份資源。
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 這套函式庫,因此可以見到在官網的範例中,只要使用createSlice
或createReducer
方法,可以直接更動 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,目前來說它是必要的