在 reduce 使用點點點 spread operator 是效能上的 anti-pattern
隨著 functional programing 在 JavaScript 領域的盛行,跟 ECMAScript 標準的演進,JS 開發者在處理資料結構轉換上有了更多可以符合 immutable 的做法。於是下面的 pattern 越來越常在 array 轉成 object 裡面出現。
users.reduce((acc, item) => {
return { ...acc, [item.key]: item.value };
}, {});
但實際上我們來做個小實驗來比較下面這兩個例子的效能:
let users = Array.from({ length: 1000 }, (item, i) => ({ key: i, value: i }));
console.time();
let result = {};
users.forEach((acc, item) => {
result[item.key] = item.value;
});
console.timeEnd();
上面的算出 result js 執行時間是 0.1259 ms。
let users = Array.from({ length: 1000 }, (item, i) => ({ key: i, value: i }));
console.time();
let result = users.reduce((acc, item) => {
return { ...acc, [item.key]: item.value };
}, {});
console.timeEnd();
上面的算出 result js 執行時間是 81.2827 ms,整整比上面 forEach 的版本慢了 600 多倍。
為何 spread Operator 是慢的 #
其實 spread Operator 幫我們做的事情只是把一個 object 的 enumerable property 複製到另外一個 object,可以看作是在 reduce loop 裡面再跑一個 loop 遍歷所有的 object key 進行複製,計算出來的複雜度約略為 O(n^2)
,而單純 forEach 只有跑一個 loop, 複雜度是 O(n)
,這根本上的差異,造成 spread Operator in reduce 的效能上會隨著 n 的數值越大,差距會越來越明顯。
替代方案 #
的確 immutable 的手法讓我們能夠更肯定的數據的流向,不用擔心數據的意外操作,即使實務上我們願意犧牲一些效能來換取可讀性,但我們也不需要太過於矯枉過正,直接忽略到隱藏於其中的一個指數級別的效能問題。為了改善關鍵性能數量級讓數據可以被修改,我認爲是必須要做的事情。除了 forEach 以外我們也可以用下面做法來取代:
方法一: mutable accumulator in reduce
let users = Array.from({ length: 1000 }, (item, i) => ({ key: i, value: i }));
console.time();
let result = users.reduce((acc, item) => {
acc[item.key] = item.value;
return acc;
}, {});
console.timeEnd();
上面的算出 result js 執行時間是 0.1252 ms
方法二: object.fromEntries
let users = Array.from({ length: 1000 }, (item, i) => ({ key: i, value: i }));
console.time();
let result = Object.fromEntries(users.map((item) => [item.key, item.value]));
console.timeEnd();
上面的算出 result js 執行時間是 0.2631 ms
結語 #
這篇文章並非鼓吹我們為了效能應該全面放棄 immutable 的做法,事實上擁抱 immutable 的概念的確降低了很多閱讀維護程式碼的心智負擔,但 mutable 並非你想的那麼壞,有時候在關鍵效能上是很有用的。如果複雜度有數量級以上的差別,即使你的資料量不大,你仍應該要以複雜度低許多的演算法為優先,也比較不會因為習慣而導致你的程式上其他地方有嚴重的性能問題。如果複雜度沒有一個數量級以上的差別,我們就不需要在小地方糾結效能上有沒有改善。懂得權衡利弊挑選適當的工具處理問題,而並非一股腦地隨波逐流,也才是工程師的價值之所在。