# 實現不可變數據
點擊關注本[公眾號](http://www.hmoore.net/book/dsh225/javascript_vue_css/edit#_118)獲取文檔最新更新,并可以領取配套于本指南的《**前端面試手冊**》以及**最標準的簡歷模板**.
[TOC]
## 前言
我們在學習 React 的過程中經常會碰到一個概念,那就是數據的不可變性(immutable),不可變數據是函數式編程里的重要概念,因為可變數據在提供方便的時候會帶了很多棘手的副作用,那么我們應該如何處理這些棘手的問題,如何實現不可變數據呢?
## 1.可變數據的副作用
我們應該都知道的基本知識,在JavaScript中分為原始類型和引用類型.
> JavaScript原始類型:Undefined、Null、Boolean、Number、String、Symbol JavaScript引用類型:Object
同時引用類型在使用過程中經常會產生副作用.
~~~
const person = {player: {name: 'Messi'}};
const person1 = person;
console.log(person, person1);
//[ { name: 'Messi' } ] [ { name: 'Messi' } ]
person.player.name = 'Kane';
console.log(person, person1);
//[ { name: 'Kane' } ] [ { name: 'Kane' } ]
~~~
我們看到,當修改了`person`中屬性后,`person1`的屬性值也隨之改變,因為這兩個變量的指針指向了同一塊內存,當一個變量被修改后,內存隨之變動,而另一個變量由于指向同一塊內存,自然也隨之變化了,這就是引用類型的副作用.
可是絕大多數情況下我們并不希望`person1`的屬性值也發生改變,我們應該如何解決這個問題?
## 2.不可變數據的解決方案
#### 2.1 淺復制
在ES6中我們可以用`Object.assign`或者`...`對引用類型進行淺復制.
~~~
const person = [{name: 'Messi'}];
const person1 = person.map(item =>
({...item, name: 'Kane'})
)
console.log(person, person1);
// [{name: 'Messi'}] [{name: 'Kane'}]
~~~
`person`的確被成功復制了,但是之所以我們稱它為淺復制,是因為這種復制只能復制一層,在多層嵌套的情況下依然會出現副作用.
~~~
const person = [{name: 'Messi', info: {age: 30}}];
const person1 = person.map(item =>
({...item, name: 'Kane'})
)
console.log(person[0].info === person1[0].info); // true
~~~
上述代碼表明當利用淺復制產生新的`person1`后其中嵌套的`info`屬性依然與原始的`person`的`info`屬性指向同一個堆內存對象,這種情況依然會產生副作用.
我們可以發現淺復制雖然可以解決淺層嵌套的問題,但是依然對多層嵌套的引用類型無能為力.
#### 2.2 深克隆
既然淺復制(克隆)無法解決這個問題,我們自然會想到利用深克隆的方法來實現多層嵌套復制的問題.
我們之前已經討論過如何實現一個深克隆,在此我們不做深究,深克隆毫無疑問可以解決引用類型產生的副作用.
> [面試官系列(1): 如何實現深克隆](https://juejin.im/post/5abb55ee6fb9a028e33b7e0a)
實現一個在生產環境中可以用的深克隆是非常繁瑣的事情,我們不僅要考慮到\_正則\_、*Symbol*、\_Date\_等特殊類型,還要考慮到\_原型鏈\_和\_循環引用\_的處理,當然我們可以選擇使用成熟的[開源庫](https://link.juejin.im/?target=https%3A%2F%2Fgithub.com%2Flodash%2Flodash%2Fblob%2Fmaster%2FcloneDeep.js)進行深克隆處理.
可是問題就在于我們實現一次深克隆的開銷太昂貴了,[如何實現深克隆](https://link.juejin.im/?target=https%3A%2F%2Fgithub.com%2Fxiaomuzhu%2FElemeFE-node-interview%2Fblob%2Fmaster%2FJavaScript%25E5%259F%25BA%25E7%25A1%2580%2Fjavascript%25E5%25AE%259E%25E7%258E%25B0%25E6%25B7%25B1%25E5%2585%258B%25E9%259A%2586.md)中我們展示了一個勉強可以使用的深克隆函數已經處理了相當多的邏輯,如果我們每使用一次深克隆就需要一次如此昂貴的開銷,程序的性能是會大打折扣.
~~~
const person = [{name: 'Messi', info: {age: 30}}];
for (let i=0; i< 100000;i++) {
person.push({name: 'Messi', info: {age: 30}});
}
console.time('clone');
const person1 = person.map(item =>
({...item, name: 'Kane'})
)
console.timeEnd('clone');
console.time('cloneDeep');
const person2 = lodash.cloneDeep(person)
console.timeEnd('cloneDeep');
// clone : 105.520ms
// cloneDeep : 372.839ms
~~~
我們可以看到深克隆的的性能相比于淺克隆大打折扣,但是淺克隆又不能從根本上杜絕引用類型的副作用,我們需要找到一個兼具性能和效果的方案.
#### 2.3 immutable.js
immutable.js是正是兼顧了使用效果和性能的解決方案
原理如下:**Immutable**實現的原理是**Persistent Data Structur**(持久化數據結構),對**Immutable**對象的任何修改或添加刪除操作都會返回一個新的**Immutable**對象, 同時使用舊數據創建新數據時,要保證舊數據同時可用且不變。
為了避免像`deepCopy`一樣 把所有節點都復制一遍帶來的性能損耗,**Immutable**使用了**Structural Sharing**(結構共享),即如果對象樹中一個節點發生變化,只修改這個節點和受它影響的父節點,其它節點則進行共享。請看下面動畫

我們看到動畫中右側的子節點由于發生變化,相關父節點進行了重建,但是左側樹沒有發生變化,最后形成的新的樹依然復用了左側樹的節點,看起來真的是無懈可擊.
immutable.js 的實現方法確實很高明,畢竟是花了 Facebook 工程師三年打造的全新數據結構,相比于深克隆,帶來的 cpu 消耗很低,同時內存占用也很小.
但是 immutable.js 就沒有弊端嗎?
在使用過程中,immutable.js也存在很多問題.
我目前碰到的坑有:
1. 由于實現了完整的不可變數據,immutable.js的體積過于龐大,尤其在移動端這個情況被凸顯出來.
2. 全新的api+不友好的文檔,immutable.js使用的是自己的一套api,因此我們對js原生數組、對象的操作統統需要拋棄重新學習,但是官方文檔不友好,很多情況下需要自己去試api.
3. 調試錯誤困難,immutable.js自成一體的數據結構,我們無法像讀原生js一樣讀它的數據結構,很多情況下需要`toJS()`轉化為原生數據結構再進行調試,這讓人很崩潰.
4. 極易引起濫用,immutable.js 在 react 項目中本來是可以大幅提高軟件性能,通過深度對比避免大量重復渲染的,但是很多開發者習慣在 react-redux 的 connect 函數中將 immutable.js 數據通過`toJS`轉化為正常的 js 數據結構,這個時候新舊 props 就永遠不會相等了,就導致了大量重復渲染,嚴重降低性能.
5. 版本更新卡殼,immutable.js 在4.0.0-rc.x 上大概卡了一年了,在3.x 版本中對 typescript 支持極差,而新版本一直卡殼
immutable.js在某種程度上來說,更適合于對數據可靠度要求頗高的大型前端應用(需要引入龐大的包、額外的學習成本甚至類型檢測工具對付immutable.js與原生js類似的api),中小型的項目引入immutable.js的代價有點高昂了,可是我們有時候不得不利用immutable的特性,那么如何保證性能和效果的情況下減少immutable相關庫的體積和提高api友好度呢?
## 3.實現更簡單的immutable
我們的原則已經提到了,要盡可能得減小體積,這就注定了我們不能像immutable.js那樣自己定義各種數據結構,而且要減小使用成本,所以要用原生js的方式,而不是自定義數據結構中的api.
*這個時候需要我們思考如何實現上述要求呢?*
我們要通過原生js的api來實現immutable,很顯然我們需要對引用對象的set、get、delete等一系列操作的特性進行修改,這就需要`defineProperty`或者`Proxy`進行元編程.
我們就以`Proxy`為例來進行編碼,當然,我們需要事先了解一下`Proxy`的[使用方法](https://link.juejin.im/?target=http%3A%2F%2Fes6.ruanyifeng.com%2F%23docs%2Fproxy%23Proxy-revocable).
我們先定義一個目標對象
~~~
const target = {name: 'Messi', age: 29};
~~~
我們如果想每訪問一次這個對象的`age`屬性,`age`屬性的值就增加`1`.
~~~
const target = {name: 'Messi', age: 29};
const handler = {
get: function(target, key, receiver) {
console.log(`getting ${key}!`);
if (key === 'age') {
const age = Reflect.get(target, key, receiver)
Reflect.set(target, key, age+1, receiver);
return age+1
}
return Reflect.get(target, key, receiver);
}
};
const a = new Proxy(target, handler);
console.log(a.age, a.age);
//getting age!
//getting age!
//30 31
~~~
是的`Proxy`就像一個代理器,當有人對目標對象進行處理(set、has、get等等操作)的時候它會攔截操作,并用我們提供的代碼進行處理,此時`Proxy`相當于一個中介或者叫代理人,當然`Proxy`的名字也說明了這一點,它經常被用于代理模式中,例如字段驗證、緩存代理、訪問控制等等。
我們的目的很簡單,就是利用`Proxy`的特性,在外部對目標對象進行修改的時候來進行額外操作保證數據的不可變。
在外部對目標對象進行修改的時候,我們可以將被修改的引用的那部分進行拷貝,這樣既能保證效率又能保證可靠性.
1. 那么如何判斷目標對象是否被修改過,最好的方法是維護一個狀態
~~~
function createState(target) {
this.modified = false; // 是否被修改
this.target = target; // 目標對象
this.copy = undefined; // 拷貝的對象
}
~~~
2. 此時我們就可以通過狀態判斷來進行不同的操作了
~~~
createState.prototype = {
// 對于get操作,如果目標對象沒有被修改直接返回原對象,否則返回拷貝對象
get: function(key) {
if (!this.modified) return this.target[key];
return this.copy[key];
},
// 對于set操作,如果目標對象沒被修改那么進行修改操作,否則修改拷貝對象
set: function(key, value) {
if (!this.modified) this.markChanged();
return (this.copy[key] = value);
},
// 標記狀態為已修改,并拷貝
markChanged: function() {
if (!this.modified) {
this.modified = true;
this.copy = shallowCopy(this.target);
}
},
};
// 拷貝函數
function shallowCopy(value) {
if (Array.isArray(value)) return value.slice();
if (value.__proto__ === undefined)
return Object.assign(Object.create(null), value);
return Object.assign({}, value);
}
~~~
3. 最后我們就可以利用構造函數`createState`接受目標對象`state`生成對象`store`,然后我們就可以用`Proxy`代理`store`,`producer`是外部傳進來的操作函數,當`producer`對代理對象進行操作的時候我們就可以通過事先設定好的`handler`進行代理操作了.
~~~
const PROXY_STATE = Symbol('proxy-state');
const handler = {
get(target, key) {
if (key === PROXY_STATE) return target;
return target.get(key);
},
set(target, key, value) {
return target.set(key, value);
},
};
// 接受一個目標對象和一個操作目標對象的函數
function produce(state, producer) {
const store = new createState(state);
const proxy = new Proxy(store, handler);
producer(proxy);
const newState = proxy[PROXY_STATE];
if (newState.modified) return newState.copy;
return newState.target;
}
~~~
4. 我們可以驗證一下,我們看到`producer`并沒有干擾到之前的目標函數.
~~~
const baseState = [
{
todo: 'Learn typescript',
done: true,
},
{
todo: 'Try immer',
done: false,
},
];
const nextState = produce(baseState, draftState => {
draftState.push({todo: 'Tweet about it', done: false});
draftState[1].done = true;
});
console.log(baseState, nextState);
/*
[ { todo: 'Learn typescript', done: true },
{ todo: 'Try immer', done: true } ]
[ { todo: 'Learn typescript', done: true ,
{ todo: 'Try immer', done: true },
{ todo: 'Tweet about it', done: false } ]
*/
~~~
沒問題,我們成功實現了輕量級的 immutable.js,在保證 api友好的同時,做到了比 immutable.js 更小的體積和不錯的性能.
## 總結
實際上這個實現就是不可變數據庫[immer](https://link.juejin.im/?target=https%3A%2F%2Fgithub.com%2Fmweststrate%2Fimmer)的迷你版,我們閹割了大量的代碼才縮小到了60行左右來實現這個基本功能,實際上除了`get/set`操作,這個庫本身有`has/getOwnPropertyDescriptor/deleteProperty`等一系列的實現,我們由于篇幅的原因很多代碼也十分粗糙,深入了解可以移步完整源碼.
在不可變數據的技術選型上,我查閱了很多資料,也進行過實踐,immutable.js 的確十分難用,盡管我用他開發過一個完整的項目,因為任何來源的數據都需要通過 fromJS()將他轉化為 Immutable 本身的結構,而我們在組件內用數據驅動視圖的時候,組件又不能直接用 Immutable 的數據結構,這個時候又需要進行數據轉換,只要你的項目沾染上了 Immutable.js 就不得不將整個項目全部的數據結構用Immutable.js 重構(否則就是到處可見的 fromjs 和 tojs 轉換,一方面影響性能一方面影響代碼可讀性),這個解決方案的侵入性極強,不建議大家輕易嘗試.
* * *
## 公眾號
想要實時關注筆者最新的文章和最新的文檔更新請關注公眾號**程序員面試官**,后續的文章會優先在公眾號更新.
**簡歷模板**:關注公眾號回復「模板」獲取
**《前端面試手冊》**:配套于本指南的突擊手冊,關注公眾號回復「fed」獲取

- 前言
- 指南使用手冊
- 為什么會有這個項目
- 面試技巧
- 面試官到底想看什么樣的簡歷?
- 面試回答問題的技巧
- 如何通過HR面
- 推薦
- 書籍/課程推薦
- 前端基礎
- HTML基礎
- CSS基礎
- JavaScript基礎
- 瀏覽器與新技術
- DOM
- 前端基礎筆試
- HTTP筆試部分
- JavaScript筆試部分
- 前端原理詳解
- JavaScript的『預解釋』與『變量提升』
- Event Loop詳解
- 實現不可變數據
- JavaScript內存管理
- 實現深克隆
- 如何實現一個Event
- JavaScript的運行機制
- 計算機基礎
- HTTP協議
- TCP面試題
- 進程與線程
- 數據結構與算法
- 算法面試題
- 字符串類面試題
- 前端框架
- 關于前端框架的面試須知
- Vue面試題
- React面試題
- 框架原理詳解
- 虛擬DOM原理
- Proxy比defineproperty優劣對比?
- setState到底是異步的還是同步的?
- 前端路由的實現
- redux原理全解
- React Fiber 架構解析
- React組件復用指南
- React-hooks 抽象組件
- 框架實戰技巧
- 如何搭建一個組件庫的開發環境
- 組件設計原則
- 實現輪播圖組件
- 性能優化
- 前端性能優化-加載篇
- 前端性能優化-執行篇
- 工程化
- webpack面試題
- 前端工程化
- Vite
- 安全
- 前端安全面試題
- npm
- 工程化原理
- 如何寫一個babel
- Webpack HMR 原理解析
- webpack插件編寫
- webpack 插件化設計
- Webpack 模塊機制
- webpack loader實現
- 如何開發Babel插件
- git
- 比較
- 查看遠程倉庫地址
- git flow
- 比較分支的不同并保存壓縮文件
- Tag
- 回退
- 前端項目經驗
- 確定用戶是否在當前頁面
- 前端下載文件
- 只能在微信中訪問
- 打開新頁面-被瀏覽器攔截
- textarea高度隨內容變化 vue版
- 去掉ios原始播放大按鈕
- nginx在MAC上的安裝、啟動、重啟和關閉
- 解析latex格式的數學公式
- 正則-格式化a鏈接
- 封裝的JQ插件庫
- 打包問題總結
- NPM UI插件
- 帶你入門前端工程
- webWorker+indexedDB性能優化
- 多個相鄰元素切換效果出現邊框重疊問題的解決方法
- 監聽前端storage變化