iT邦幫忙

2022 iThome 鐵人賽

DAY 17
1
Modern Web

一次打破 React 常見的學習門檻與觀念誤解系列 第 17

[Day 17] Immutable update 的 nested reference clone 誤解

  • 分享至 

  • xImage
  •  

在前兩篇的章節中,我們已經了解到在 React 開發中 immutable update 的必要性以及基本的操作方法了。然而有趣的是,在我擔任前端面試官多年也面試過不少前端工程師的經驗中,發現有許多對於 immutable update 掌握不足的人都會不約而同的犯同一種錯誤。讓我們來看看以下範例:

function App() {
  const [cart, setCart] = useState([
    { productId: 'foo', quantity: 1 },
    { productId: 'bar', quantity: 2 }
  ]);

  const handleQuantityChange = (index, quantity) => {
    // 更新 cart state 的陣列中位於 index 的物件中的 quantity 屬性
  }
}

在類似以上的這種情境需求中,你會發現有很多人會嘗試這樣做:

// ❌ 注意,以下是不正確的 immmutable update 寫法

const handleQuantityChange = (index, quantity) => {
  // 更新 cart state 的陣列中位於 index 的物件中的 quantity 屬性
  const newCart = [...cart];
  newCart[index].quantity = quantity;
}

而會這樣做的理由大多數人都是因為覺得既然我們不能修改舊有的 state,那麼只要我們先從舊的 state「複製」出一份新的 state,然後再修改這個複製體,應該就可以滿足不修改到舊 state 的要求了吧?

不過實際上這段邏輯是會 mutate 到原有的 state 資料的。以下就讓我們深入來探討看看是為什麼。


Nested reference 資料的 immutable update

在上面的範例中,嘗試先以 spread 在最外層複製一次的寫法,實際上仍然會 mutate 原有的資料。這是因為雖然你有先複製出新陣列,但是陣列中所存的項目是物件,而物件也是以 reference 存在的型別,因此複製出來的陣列中的每一個物件都與舊陣列是同一批物件 reference,從新陣列 mutate 它們的同時其實也是在 mutate 原有陣列中的那些物件。 雖然因為最外層的陣列有先產新陣列所以 setState 的檢查判斷能正常運作,但是實際上裡面的物件資料是有被 mutate 的, 而這是我們在 React 中應該要避免的事情。 

其實這就是為什麼在上一章節中並沒有推薦這樣複製最外層陣列 / 物件然後直接 mutate 複製體的寫法,或是那些會 mutate 原有資料的方法,而是推薦你使用 mapfilterslice ,以及 spread、rest 等本來就是 immutable 的資料操作方法。當遇到多層級的 reference 資料 (像這個例子就是陣列中有物件) 時,我們很容易不小心就 mutate 到原有資料。我知道用這些本身就是 immutable 的操作方法寫起來比較囉嗦一點,但是它們在開發上更能降低我們犯錯的可能性,並且如果有多層級的 reference 資料需要複製時這樣寫可能反而會更直覺一點。

因此當我們在更新陣列包物件,或物件包陣列的這種多層的 reference 資料時,不僅是最外層需要複製,裡面每一層想要更新的物件或陣列也都需要先複製 (如果沒有要更新的部分就不用),才能保證整包資料的更新都是 immutable 的。

最後另外補充一下,上一章節中有提到 sortreverse 可以用複製陣列後呼叫的原因是,它只會 mutate 陣列項目在陣列中的排列順序而不會去 mutate 各項目的內容,所以複製出新陣列後對新陣列呼叫 sortreverse 方法是安全的。


Spread 複製的是物件屬性的值還是參考?

我們很常會使用 ES6 的 spread 語法來進行物件屬性的複製,然而我們複製出來的到底是值還是參考?

const oldObj = { a: 1, b:2, c:3 };
const newObj = { ...oldObj, d: 100 };
console.log(newObj);  // { a: 1, b: 2, c: 3, d: 100 }

在上面這個例子中,newObja, b, c 屬性都是由 oldObj 複製過來的,而且他們都是數字這種原始型別,還記得我們在前文中說過原始型別本身就是 immutable 嗎? 原始型別都是直接以值的方式存在的,因此他們被複製之後會真的複製出值而不會是參考,所以 newObj 中的 a, b, c 都已經與 oldObj 中的 a, b, c 無關,是獨立的值而不會互相影響了!當然,新加入的 d 就更沒有關係了。

但是如果是下面這個例子,物件之中有屬性的內容也是一個物件或陣列的話:

const oldObj = { a: 1, b:2, c: { foo: 8, bar: 9 } };
const newObj = { ...oldObj, d: 100 };

// { a: 1, b: 2, c: { foo: 8, bar: 9 }, d: 100 }
console.log(newObj);

console.log(Object.is(oldObj.c, newObj.c)); // true

當我們用 spread 語法從 oldObj 複製屬性到 newObj 中時,其中原始型別的屬性就如同上面範例所說的,但如果是陣列或物件那些以參考形式存在的型別,則被複製的只是參考在記憶體中的地址,而不是裡面的內容。因此這個時候 oldObjcnewObj 中的 c 其實指向的還是同一個物件的參考,所以當我們去 mutate newObj.c 這個物件時,其實也會 mutate 到 oldObj.c 物件 :

const oldObj = { a: 1, b:2, c: { foo: 8, bar: 9 } };
const newObj = { ...oldObj, d: 100 };

console.log(Object.is(oldObj.c, newObj.c)); // true

newObj.c.foo = 800;
console.log(oldObj.c.foo); // 800
newObj.c.buzz = 1000;
console.log(oldObj.c.buzz); // 1000

因此當我們想要 immutable 的更新 oldObj.c 的內容時,就應該在新的 newObj 中在 c 屬性指定成一個新物件,然後從 oldObj.c 複製出屬性並蓋上新屬性:

const oldObj = { a: 1, b:2, c: { foo: 8, bar: 9 } };
const newObj = {
  ...oldObj,
  c: {
    ...oldObj.c,
    foo: 800,
    buzz: 1000
  },
  d: 100,
};

console.log(oldObj); // { a: 1, b:2, c: { foo: 8, bar: 9 } }
console.log(newObj);
// { a: 1, b:2, c: { foo: 800, bar: 9, buzz: 1000 } }

console.log(Object.is(oldObj.c, newObj.c)); // false

可以看到這樣處理就不會 mutate 到原有資料,因為 oldObj.cnewObj.c 根本已經是不同物件了。而物件中有陣列,或陣列中有物件,或陣列中有陣列也通通都同理。所以重點是 JavaScript 中陣列或物件資料中每一層參考都是獨立的,像 spread 這種語法只會做「shallow 的 clone」,而並不是在最外層複製一下就會完整地做 deep clone。

需要特別注意的是,如果是你沒有需要修改的部分的話就可以沿用舊的 reference 而不用複製額外處理:

const oldObj = { a: 1, b:2, c: { foo: 8 }, d: { bar: 9 } };
const newObj = {
  ...oldObj,
  d: {
   ...oldObj.d,
   buzz: 10
  },
};

console.log(Object.is(oldObj.c, newObj.c)); // true
console.log(Object.is(oldObj.d, newObj.d)); // false

上面的範例中,我們只想要 immutable 的去新增 oldObj.d.buzz 屬性,因此我們需要產生新的 newObj 以及新的 newObj.d,並覆蓋上新的 buzz 屬性但是 oldObj.c 我們並沒有修改的需求,所以這個屬性可以直接在 newObj 裡面繼續沿用,不需要另外產生新的物件當作 newObj.c,所以你會看到 Object.is(oldObj.c, newObj.c) 會是 true,代表他們是同一個 reference。 

因此 immutable 的重點並不在整包資料的每一個角落都完整的複製或獨立,而是只要保持原有的資料永遠都可以對應當時的歷史狀態就好,重點在於歷史狀態不可以被改動。內容中實際真的有改動的部分才會需要複製出新物件或陣列,沒有變動需求的參考則是都可以繼續沿用。

因此你可以想像如果我想要 immutable 的去修改一個很多層的物件或陣列中的一個很深的值,就會寫超級多層的 spread 複製,非常難操作。因此實務上我們會盡量讓陣列或物件 state 的內容結構層數不要過深,並且用一些第三方的套件能夠以更簡潔的語法來做 immutable 資料操作,例如 ramda 就是其中的佼佼者。不過,我們仍然推薦初學者先從最基本的概念以及操作學起,先以自己動手去寫前文中推薦的那些用 JS 內建方法就可以做到的寫法。


推薦的 immutable update 輔助套件

  • ramda
    • 專門以 FP 風格所設計的 JavaScript 套件,幾乎包含了任何你想得到與想不到的資料或流程操作方法
    • 偏好以 FP 風格來操作 immutable update 時的首選
  • lodash/fp
    • 熱門資料操作套件 lodash 的 FP 版本,將所有方法都做 curried 並改成 immutable 的
    • 如果你用習慣 lodash 的話可以考慮這個方案
  • immer
    • 這兩三年非常熱門的 immutable update 專門套件,主打的就是讓開發者能用類似於 mutable 的操作體驗但同時做到 immutable update 的效果:

      import produce from 'immer';
      
      const nextState = produce(baseState, draft => {
        draft[1].done = true;
        draft.push({ title: 'Tweet about it' });
      });
      

      immer 的概念就像是基於原本的資料產生一份草稿,它會是原資料的代理資料,immer 會監聽你對這份代理的草稿進行的任何的 mutate 操作,然後再自動幫你轉換產生 immutable 版本的新資料:

      圖片來源:https://immerjs.github.io/immer/

      圖片來源:https://immerjs.github.io/immer/


2024/2 更新 - 實體書平裝版本預購

在經過快要一年的努力後,本系列文的實體書版本推出了~其中新增並補充了許多鐵人賽版本中沒有的脈絡與細節,並以全彩印刷拉滿視覺上的閱讀體驗,現正熱銷中!有興趣的話歡迎參考看看,也歡迎分享給其他有接觸前端的朋友們,非常感謝大家~

《React 思維進化:一次打破常見的觀念誤解,躍升專業前端開發者》

目前首刷的軟精裝版本各大通路已經幾乎都銷售一空,接下來會再刷推出新的平裝版本:

天瓏(平裝版預購):
https://www.tenlong.com.tw/products/9786263337695

博客來(平裝版):
https://www.books.com.tw/products/0010982322

momo(平裝版):
https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=12528845


上一篇
[Day 16] Immutable update 物件與陣列的基本功
下一篇
[Day 18] Function component & class component 你可能不知道的關鍵區別
系列文
一次打破 React 常見的學習門檻與觀念誤解30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

2 則留言

1
John_Huang
iT邦新手 5 級 ‧ 2022-10-15 01:50:48

你好,

最近在讀你的文章,收益良多,釐清了非常多的觀念
感謝你花時間撰寫這麼優質的內容
對了我發現你一開始的引導式提問好像沒有附上解答
不知道我下面寫的對不對呢?

const handleQuantityChange = (index, quantity) => {
    const newCart = cart.map((item, i) => {
        if (i !== index) return item
        return {
            ...item,
            quantity
        }
    })
    setCart(newCart)
}
Zet iT邦新手 2 級 ‧ 2022-11-08 18:40:22 檢舉

感謝支持,很高興這個系列文對你有所幫助
這個範例的 immutable update 你這樣處理是沒問題的哦,有正確的同時維持外層的陣列與內層的物件的 immutable

0
lunzaizai0223
iT邦新手 5 級 ‧ 2023-05-05 21:49:57

你好,感謝你的系列文章,我也學習到很多的觀念,謝謝!

我也想請問以下的方式是正確的嗎?另外再請教一下,在實務中樓上大大的方式才是比較推薦的方式嗎?

const handleQuantityChange = (index, quantity) => {
    const existedCartItem = cart[index];
    const updatedCartItem = { ...existedCartItem, quantity };
    const updatedCart = [...cart];
    updatedCart[index] = updatedCartItem;
    setCart(updatedCart);
  };

先感謝你的回覆及樓上大大的分享!

Zet iT邦新手 2 級 ‧ 2023-05-16 20:32:55 檢舉

你的寫法是有正確 immutable update 的,只是可讀性比較沒那麼理想而已。會比較推薦樓上那位的寫法,用 map 迭代的方式來區別需要覆蓋的項目以及不需要動到的項目,邏輯會比較一目了然

我明白了,感謝你的回覆!

我要留言

立即登入留言