1. 程式人生 > >Immutable 詳解及 React 中實踐

Immutable 詳解及 React 中實踐

Immutable data

Shared mutable state is the root of all evil(共享的可變狀態是萬惡之源)

-- Pete Hunt

有人說 Immutable 可以給 React 應用帶來數十倍的提升,也有人說 Immutable 的引入是近期 JavaScript 中偉大的發明,因為同期 React 太火,它的光芒被掩蓋了。這些至少說明 Immutable 是很有價值的,下面我們來一探究竟。

JavaScript 中的物件一般是可變的(Mutable),因為使用了引用賦值,新的物件簡單的引用了原始物件,改變新的物件將影響到原始物件。如 foo={a: 1}; bar=foo; bar.a=2

 你會發現此時 foo.a 也被改成了 2。雖然這樣做可以節約記憶體,但當應用複雜後,這就造成了非常大的隱患,Mutable 帶來的優點變得得不償失。為了解決這個問題,一般的做法是使用 shallowCopy(淺拷貝)或 deepCopy(深拷貝)來避免被修改,但這樣做造成了 CPU 和記憶體的浪費。

Immutable 可以很好地解決這些問題。

什麼是 Immutable Data

Immutable Data 就是一旦建立,就不能再被更改的資料。對 Immutable 物件的任何修改或新增刪除操作都會返回一個新的 Immutable 物件。Immutable 實現的原理是 Persistent Data Structure

(持久化資料結構),也就是使用舊資料建立新資料時,要保證舊資料同時可用且不變。同時為了避免 deepCopy 把所有節點都複製一遍帶來的效能損耗,Immutable 使用了 Structural Sharing(結構共享),即如果物件樹中一個節點發生變化,只修改這個節點和受它影響的父節點,其它節點則進行共享。請看下面動畫:

Immutable 原理動畫

目前流行的 Immutable 庫有兩個:

immutable.js

Facebook 工程師 Lee Byron 花費 3 年時間打造,與 React 同期出現,但沒有被預設放到 React 工具集裡(React 提供了簡化的 Helper)。它內部實現了一套完整的 Persistent Data Structure,還有很多易用的資料型別。像 Collection

ListMapSetRecordSeq。有非常全面的mapfiltergroupByreduce``find函式式操作方法。同時 API 也儘量與 Object 或 Array 類似。

其中有 3 種最重要的資料結構說明一下:(Java 程式設計師應該最熟悉了)

  • Map:鍵值對集合,對應於 Object,ES6 也有專門的 Map 物件
  • List:有序可重複的列表,對應於 Array
  • Set:無序且不可重複的列表

seamless-immutable

與 Immutable.js 學院派的風格不同,seamless-immutable 並沒有實現完整的 Persistent Data Structure,而是使用 Object.defineProperty(因此只能在 IE9 及以上使用)擴充套件了 JavaScript 的 Array 和 Object 物件來實現,只支援 Array 和 Object 兩種資料型別,API 基於與 Array 和 Object 操持不變。程式碼庫非常小,壓縮後下載只有 2K。而 Immutable.js 壓縮後下載有 16K。

下面上程式碼來感受一下兩者的不同:

// 原來的寫法
let foo = {a: {b: 1}};
let bar = foo;
bar.a.b = 2;
console.log(foo.a.b);  // 列印 2
console.log(foo === bar);  //  列印 true

// 使用 immutable.js 後
import Immutable from 'immutable';
foo = Immutable.fromJS({a: {b: 1}});
bar = foo.setIn(['a', 'b'], 2);   // 使用 setIn 賦值
console.log(foo.getIn(['a', 'b']));  // 使用 getIn 取值,列印 1
console.log(foo === bar);  //  列印 false

// 使用  seamless-immutable.js 後
import SImmutable from 'seamless-immutable';
foo = SImmutable({a: {b: 1}})
bar = foo.merge({a: { b: 2}})   // 使用 merge 賦值
console.log(foo.a.b);  // 像原生 Object 一樣取值,列印 1
console.log(foo === bar);  //  列印 false

Immutable 優點

1. Immutable 降低了 Mutable 帶來的複雜度

可變(Mutable)資料耦合了 Time 和 Value 的概念,造成了資料很難被回溯。

比如下面一段程式碼:

function touchAndLog(touchFn) {
  let data = { key: 'value' };
  touchFn(data);
  console.log(data.key); // 猜猜會列印什麼?
}

在不檢視 touchFn 的程式碼的情況下,因為不確定它對 data 做了什麼,你是不可能知道會列印什麼(這不是廢話嗎)。但如果 data 是 Immutable 的呢,你可以很肯定的知道列印的是 value

2. 節省記憶體

Immutable.js 使用了 Structure Sharing 會盡量複用記憶體,甚至以前使用的物件也可以再次被複用。沒有被引用的物件會被垃圾回收。

import { Map} from 'immutable';
let a = Map({
  select: 'users',
  filter: Map({ name: 'Cam' })
})
let b = a.set('select', 'people');

a === b; // false
a.get('filter') === b.get('filter'); // true

上面 a 和 b 共享了沒有變化的 filter 節點。

3. Undo/Redo,Copy/Paste,甚至時間旅行這些功能做起來小菜一碟

因為每次資料都是不一樣的,只要把這些資料放到一個數組裡儲存起來,想回退到哪裡就拿出對應資料即可,很容易開發出撤銷重做這種功能。

後面我會提供 Flux 做 Undo 的示例。

4. 併發安全

傳統的併發非常難做,因為要處理各種資料不一致問題,因此『聰明人』發明了各種鎖來解決。但使用了 Immutable 之後,資料天生是不可變的,併發鎖就不需要了

然而現在並沒什麼卵用,因為 JavaScript 還是單執行緒執行的啊。但未來可能會加入,提前解決未來的問題不也挺好嗎?

5. 擁抱函數語言程式設計

Immutable 本身就是函數語言程式設計中的概念,純函數語言程式設計比面向物件更適用於前端開發。因為只要輸入一致,輸出必然一致,這樣開發的元件更易於除錯和組裝。

像 ClojureScript,Elm 等函數語言程式設計語言中的資料型別天生都是 Immutable 的,這也是為什麼 ClojureScript 基於 React 的框架 --- Om 效能比 React 還要好的原因。

Immutable 缺點

1. 需要學習新的 API

No Comments

2. 增加了資原始檔大小

No Comments

3. 容易與原生物件混淆

這點是我們使用 Immutable.js 過程中遇到最大的問題。寫程式碼要做思維上的轉變。

雖然 Immutable.js 儘量嘗試把 API 設計的原生物件類似,有的時候還是很難區別到底是 Immutable 物件還是原生物件,容易混淆操作。

Immutable 中的 Map 和 List 雖對應原生 Object 和 Array,但操作非常不同,比如你要用 map.get('key') 而不是 map.keyarray.get(0) 而不是 array[0]。另外 Immutable 每次修改都會返回新物件,也很容易忘記賦值。

當使用外部庫的時候,一般需要使用原生物件,也很容易忘記轉換。

下面給出一些辦法來避免類似問題發生:

  1. 使用 Flow 或 TypeScript 這類有靜態型別檢查的工具
  2. 約定變數命名規則:如所有 Immutable 型別物件以 $$ 開頭。
  3. 使用 Immutable.fromJS 而不是 Immutable.Map 或 Immutable.List 來建立物件,這樣可以避免 Immutable 和原生物件間的混用。

更多認識

Immutable.is

兩個 immutable 物件可以使用 === 來比較,這樣是直接比較記憶體地址,效能最好。但即使兩個物件的值是一樣的,也會返回 false

let map1 = Immutable.Map({a:1, b:1, c:1});
let map2 = Immutable.Map({a:1, b:1, c:1});
map1 === map2;             // false

為了直接比較物件的值,immutable.js 提供了 Immutable.is 來做『值比較』,結果如下:

Immutable.is(map1, map2);  // true

Immutable.is 比較的是兩個物件的 hashCode 或 valueOf(對於 JavaScript 物件)。由於 immutable 內部使用了 Trie 資料結構來儲存,只要兩個物件的 hashCode 相等,值就是一樣的。這樣的演算法避免了深度遍歷比較,效能非常好。

後面會使用 Immutable.is 來減少 React 重複渲染,提高效能。

另外,還有 moricortex 等,因為類似就不再介紹。

與 Object.freeze、const 區別

Object.freeze 和 ES6 中新加入的 const 都可以達到防止物件被篡改的功能,但它們是 shallowCopy 的。物件層級一深就要特殊處理了。

Cursor 的概念

這個 Cursor 和資料庫中的遊標是完全不同的概念。

由於 Immutable 資料一般巢狀非常深,為了便於訪問深層資料,Cursor 提供了可以直接訪問這個深層資料的引用。

import Immutable from 'immutable';
import Cursor from 'immutable/contrib/cursor';

let data = Immutable.fromJS({ a: { b: { c: 1 } } });
// 讓 cursor 指向 { c: 1 }
let cursor = Cursor.from(data, ['a', 'b'], newData => {
  // 當 cursor 或其子 cursor 執行 update 時呼叫
  console.log(newData);
});

cursor.get('c'); // 1
cursor = cursor.update('c', x => x + 1);
cursor.get('c'); // 2

實踐

與 React 搭配使用,Pure Render

熟悉 React 的都知道,React 做效能優化時有一個避免重複渲染的大招,就是使用 shouldComponentUpdate(),但它預設返回 true,即始終會執行 render() 方法,然後做 Virtual DOM 比較,並得出是否需要做真實 DOM 更新,這裡往往會帶來很多無必要的渲染併成為效能瓶頸。

當然我們也可以在 shouldComponentUpdate() 中使用使用 deepCopy 和 deepCompare 來避免無必要的 render(),但 deepCopy 和 deepCompare 一般都是非常耗效能的

Immutable 則提供了簡潔高效的判斷資料是否變化的方法,只需 === 和 is 比較就能知道是否需要執行 render(),而這個操作幾乎 0 成本,所以可以極大提高效能。修改後的 shouldComponentUpdate 是這樣的:

注意:React 中規定 state 和 props 只能是一個普通物件,所以比較時要比較物件的 key,謝謝 @chenmnkken 指正。

import { is } from 'immutable';

shouldComponentUpdate: (nextProps = {}, nextState = {}) => {
  const thisProps = this.props || {}, thisState = this.state || {};

  if (Object.keys(thisProps).length !== Object.keys(nextProps).length ||
      Object.keys(thisState).length !== Object.keys(nextState).length) {
    return true;
  }

  for (const key in nextProps) {
    if (!is(thisProps[key], nextProps[key])) {
      return true;
    }
  }

  for (const key in nextState) {
    if (thisState[key] !== nextState[key] && !is(thisState[key], nextState[key])) {
      return true;
    }
  }
  return false;
}

使用 Immutable 後,如下圖,當紅色節點的 state 變化後,不會再渲染樹中的所有節點,而是隻渲染圖中綠色的部分:

react reconciliation

你也可以藉助 React.addons.PureRenderMixin 或支援 class 語法的 [pure-render-decorator](felixgirault/pure-render-decorator · GitHub) 來實現。

setState 的一個技巧

React 建議把 this.state 當作 Immutable 的,因此修改前需要做一個 deepCopy,顯得麻煩:

import '_' from 'lodash';

const Component = React.createClass({
  getInitialState() {
    return {
      data: { times: 0 }
    }
  },
  handleAdd() {
    let data = _.cloneDeep(this.state.data);
    data.times = data.times + 1;
    this.setState({ data: data });
    // 如果上面不做 cloneDeep,下面列印的結果會是已經加 1 後的值。
    console.log(this.state.data.times);
  }
}

使用 Immutable 後:

  getInitialState() {
    return {
      data: Map({ times: 0 })
    }
  },
  handleAdd() {
    this.setState({ data: this.state.data.update('times', v => v + 1) });
    // 這時的 times 並不會改變
    console.log(this.state.data.get('times'));
  }

上面的 handleAdd 可以簡寫成:

  handleAdd() {
    this.setState(({data}) => ({
      data: data.update('times', v => v + 1) })
    });
  }

與 Flux 搭配使用

由於 Flux 並沒有限定 Store 中資料的型別,使用 Immutable 非常簡單。

現在是實現一個類似帶有新增和撤銷功能的 Store:

import { Map, OrderedMap } from 'immutable';
let todos = OrderedMap();
let history = [];  // 普通陣列,存放每次操作後產生的資料

let TodoStore = createStore({
  getAll() { return todos; }
});

Dispatcher.register(action => {
  if (action.actionType === 'create') {
    let id = createGUID();
    history.push(todos);  // 記錄當前操作前的資料,便於撤銷
    todos = todos.set(id, Map({
      id: id,
      complete: false,
      text: action.text.trim()
    }));
    TodoStore.emitChange();
  } else if (action.actionType === 'undo') {
    // 這裡是撤銷功能實現,
    // 只需從 history 陣列中取前一次 todos 即可
    if (history.length > 0) {
      todos = history.pop();
    }
    TodoStore.emitChange();
  }
});

與 Redux 搭配使用

Redux 是目前流行的 Flux 衍生庫。它簡化了 Flux 中多個 Store 的概念,只有一個 Store,資料操作通過 Reducer 中實現;同時它提供更簡潔和清晰的單向資料流(View -> Action -> Middleware -> Reducer),也更易於開發同構應用。目前已經在我們專案中大規模使用。

由於 Redux 中內建的 combineReducers 和 reducer 中的 initialState 都為原生的 Object 物件,所以不能和 Immutable 原生搭配使用。

幸運的是,Redux 並不排斥使用 Immutable,可以自己重寫 combineReducers 或使用 redux-immutablejs 來提供支援。

上面我們提到 Cursor 可以方便檢索和 update 層級比較深的資料,但因為 Redux 中已經有了 select 來做檢索,Action 來更新資料,因此 Cursor 在這裡就沒有用武之地了。

總結

Immutable 可以給應用帶來極大的效能提升,但是否使用還要看專案情況。由於侵入性較強,新專案引入比較容易,老專案遷移需要評估遷移。對於一些提供給外部使用的公共元件,最好不要把 Immutable 物件直接暴露在對外介面中。

如果 JS 原生 Immutable 型別會不會太美,被稱為 React API 終結者的 Sebastian Markbåge 有一個這樣的提案,能否通過現在還不確定。不過可以肯定的是 Immutable 會被越來越多的專案使用。