Immutable.js 初識

ping4god發表於2016-07-09

文章部落格地址:http://pinggod.com/2016/Immutable/

Immutable.js 所建立的資料有一個迷人的特性:資料建立後不會被改變。我們使用 Immutable.js 的示例來解釋這一特性:

var Immutable = require(`immutable`);

var map1 = Immutable.Map({a:1, b:2, c:3});
var map2 = map1.set(`b`, 50);

map1.get(`b`); // 2
map2.get(`b`); // 50

在上面程式碼第三行中,map1 使用 set 方法更新資料,結果返回一個新的 Map 型別資料 map2,map2 包含了更新後的資料,但是 map1 沒有發生變化。這種特性讓我們在引用資料的時候毫無後顧之憂,因為任何對資料的修改都不會影響最原始的資料。在 Immutable.js 誕生之前,我們可以使用深拷貝的方式模擬這一特性,但是會耗費過多的記憶體空間和計算力。Immutable.js 相比深拷貝的優勢在於區分發生變化的資料和未變化的資料,對於上面的 map1 和 map2,b 是變化的資料,所以 map1 和 map2 各儲存一份 b 資料,而 ac 是未變化的資料,所以 map1 和 map2 仍然共享 ac 的資料。

概覽

Immutable Data 鼓勵開發者使用純函式式的開發方式,並從函式式開發中引入了惰性計算的特性。雖然加入了很多函式式的概念,Immutable.js 仍然提供了類似原生 JavaScript Array、Map 和 Set 中的方法,並且提供了在原生 JavasScript 資料和 Immutable 資料之間快速轉換的機制。

Immutable.js 的 API 主要包含以下幾部分:

  • formJS(),將 JavaScript Object 和 Array 徹底轉換為 Immutable Map 和 List

  • is(),與 Object.is() 類似都是對值的比較,但它會將 Immutable Iterable 視為值型別資料而不是引用型別資料,如果兩個 Immutable Iterable 的值相等,則返回 true。與 Object.is() 不同的是,is(0, -0) 的結果為 true

  • List,有序索引集,類似於 JavaScript 中的 Array

  • Map,無序 Iterable,讀寫 Key 的複雜度為 O(log32 N)

  • OrderedMap,有序 Map,排序依據是資料的 set() 操作

  • Set,元素為獨一無二的集合,新增資料和判斷資料是否存在的複雜度為 O(log32 N)

  • OrderedSet,有序 Set,排序依據是資料的 add 操作。

  • Stack,有序集合,且使用 unshift(v)shift() 進行新增和刪除操作的複雜度為 O(1)

  • Range(),返回一個 Seq.Indexed 型別的資料集合,該方法接收三個引數 (start = 1, end = infinity, step = 1),分別表示起始點、終止點和步長,如果 start 等於 end,則返回空的資料結合

  • Repeat(),返回一個 Seq.indexed 型別的資料結合,該方法接收兩個引數 (value,times),value 表示重複生成的值,times 表示重複生成的次數,如果沒有指定 times,則表示生成的 Seq 包含無限個 value

  • Record,用於衍生新的 Record 類,進而生成 Record 例項。Record 例項類似於 JavaScript 中的 Object 例項,但只接收特定的字串作為 key,且擁有預設值

  • Seq,序列(may not be backed by a concrete data structure)

  • Iterable,可以被迭代的 (Key, Value) 鍵值對集合,是 Immutable.js 中其他所有集合的基類,為其他所有集合提供了 基礎的 Iterable 操作函式(比如 map()filter

  • Collection,建立 Immutable 資料結構的最基礎的抽象類,不能直接構造該型別

1. fromJS()

Immutable.fromJS({a: {b: [10, 20, 30]}, c: 40}, function (key, value) {
    var isIndexed = Immutable.Iterable.isIndexed(value);
    return isIndexed ? value.toList() : value.toOrderedMap();
});
// true, "b", {b: [10, 20, 30]}
// false, "a", {a: {b: [10, 20, 30]}, c: 40}
// false, "", {"": {a: {b: [10, 20, 30]}, c: 40}}

fromJS() 的使用方式類似於 JSON.parse(),接收兩個引數:json 資料和 reviver 函式。

2. List

List<T>(): List<T>
List<T>(iter: Iterable.Indexed<T>): List<T>
List<T>(iter: Iterable.Set<T>): List<T>
List<K, V>(iter: Iterable.Keyed<K, V>): List<any>
List<T>(array: Array<T>): List<T>
List<T>(iterator: Iterator<T>): List<T>
List<T>(iterable: Object): List<T>

List() 是一個構造方法,可以用於建立新的 List 資料型別,上面程式碼演示了該構造方法接收的引數型別,此外 List 擁有兩個靜態方法:

  • List.isList(value),判斷 value 是否是 List 型別

  • List.of(...values),建立包含 ...values 的列表

下面演示幾個 List 常用的操作,更詳細的 API 說明請參考官方文件:

// 1. 檢視 List 長度
const $arr1 = List([1, 2, 3]);
$arr1.size
// => 3

// 2. 新增或替換 List 例項中的元素
// set(index: number, value: T)
// 將 index 位置的元素替換為 value,即使索引越界也是安全的
const $arr2 = $arr1.set(-1, 0);
// => [1, 2, 0]
const $arr3 = $arr1.set(4, 0);
// => [ 1, 2, 3, undefined, 0 ]

// 3. 刪除 List 例項中的元素
// delete(index: number)
// 刪除 index 位置的元素
const $arr4 = $arr1.delete(1);
// => [ 1, 3 ]

// 4. 向 List 插入元素
// insert(index: number, value: T)
// 向 index 位置插入 value
const $arr5 = $arr1.insert(1, 1.5);
// => [ 1, 1.5, 2, 3 ]

// 5. 清空 List
// clear()
const $arr6 = $arr1.clear();
// => []

3. Map

Map 可以使用任何型別的資料作為 Key 值,並使用 Immutable.is() 方法來比較兩個 Key 值是否相等:

Map().set(List.of(1), `listofone`).get(List.of(1));
// => `listofone`

但是使用 JavaScript 中的引用型別資料(物件、陣列)作為 Key 值時,雖然有時兩個 Key 很像,但它們也是兩個不同的 Key 值:

console.log(Map().set({}, 1).get({}))
// => undefined

Map() 是 Map 型別的構造方法,行為類似於 List(),用於建立新的 Map 例項,此外,還包含兩個靜態方法:Map.isMap() 和 Map.of()。下面介紹幾個 Map 例項的常用操作,更詳細的 API 使用說明請參考官方文件:

// 1. Map 例項的大小
const $map1 = Map({ a: 1 });
$map1.size
// => 1

// 2. 新增或替換 Map 例項中的元素
// set(key: K, value: V)
const $map2 = $map1.set(`a`, 2);
// => Map { "a": 2 }

// 3. 刪除元素
// delete(key: K)
const $map3 = $map1.delete(`a`);
// => Map {}

// 4. 清空 Map 例項
const $map4 = $map1.clear();
// => Map {}

// 5. 更新 Map 元素
// update(updater: (value: Map<K, V>) => Map<K, V>)
// update(key: K, updater: (value: V) => V)
// update(key: K, notSetValue: V, updater: (value: V) => V)
const $map5 = $map1.update(`a`, () => (2))
// => Map { "a": 2 }

// 6. 合併 Map 例項
const $map6 = Map({ b: 2 });
$map1.merge($map6);
// => Map { "a": 1, "b": 2 }

OrderedMap 是 Map 的變體,它除了具有 Map 的特性外,還具有順序性,當開發者遍歷 OrderedMap 的例項時,遍歷順序為該例項中元素的宣告、新增順序。

4. Set

Set 和 ES6 中的 Set 類似,都是沒有重複值的集合,OrderedSet 是 Set 的遍歷,可以保證遍歷的順序性。

// 1. 建立 Set 例項
const $set1 = Set([1, 2, 3]);
// => Set { 1, 2, 3 }

// 2. 新增元素
const $set2 = $set1.add(1).add(4);
// => Set { 1, 2, 3, 4 }

// 3. 刪除元素
const $set3 = $set1.delete(3);
// => Set { 1, 2 }

// 4. 並集
const $set4 = Set([2, 3, 4, 5, 6]);
$set1.union($set1);
// => Set { 1, 2, 3, 4, 5, 6 }

// 5. 交集
$set1.intersect($set4);
// => Set { 3, 2 }

// 6. 差集
$set1.subtract($set4);
// => Set { 1 }

5. Stack

Stack 是基於 Signle-Linked List 實現的可索引集合,使用 unshift(v)shift() 執行新增和刪除元素的複雜度為 O(1)

// 1. 建立 Stack 例項
const $stack1 = Stack([1, 2, 3]);
// => Stack [ 1, 2, 3 ]

// 2. 取第一個元素
$stack1.peek()
// => 1

// 2. 取任意位置元素
$stack1.get(2)
// => 3

// 3. 判斷是否存在
$stack1.has(10)
// => false

6. Range() 和 Repeat()

Range(start?, end?, step?) 接收三個可選引數,使用方法如下:

// 1. 不傳參
Range();
// => Range [ 0...Infinity ]

// 2. 設定 start 起點
Range(10);
// => Range [ 10...Infinity ]

// 3. 設定 start 起點和 end 終點
Range(10, 20);
// => Range [ 10, 11, 12, 13, 14, 15, 16, 17, 18, 19 ]

// 4. 設定 start 起點、end 終點和 step 步長
Range(10, 20, 3);
// => Range [ 10, 13, 16, 19 ]

Repeat(value, times?) 接收兩個引數,其中 times 重複次數是可選引數:

Repeat(`foo`);
// => Repeat [ foo Infinity times ]

Repeat(`foo`, 3);
// => Repeat [ foo 3 times ]

類似 Range()Repeat(value) 這樣生成無限長度集合的操作,內部都存在惰性計算的機制,只有真實取值時才會生成相應的結果。使用 ES6 中的 Generator 函式,可以輕鬆實現一個惰性計算:

function* bigArr() {
    for (let i = 0; i < 100000; i++) {
        console.log(`bigArr(${i}): ${i}`)
        yield i;
    }
}

const arr = bigArr();

for (let i = 0; i < 10; i++) {
    console.log(arr.next());
}
// bigArr(0): 0
// => { value: 0, done: false }
// => bigArr(1): 1
// => { value: 1, done: false }
// => bigArr(2): 2
// => { value: 2, done: false }
// => bigArr(3): 3
// => { value: 3, done: false }
// => bigArr(4): 4
// => { value: 4, done: false }
// => bigArr(5): 5
// => { value: 5, done: false }
// => bigArr(6): 6
// => { value: 6, done: false }
// => bigArr(7): 7
// => { value: 7, done: false }
// => bigArr(8): 8
// => { value: 8, done: false }
// => bigArr(9): 9
// => { value: 9, done: false }

7. Record

Record 在表現上類似於 ES6 中的 Class,但在某些細節上還有所不同。通過 Record() 可以建立一個新的 Record 類,使用該類可以建立具體的 Record 例項,該例項包含在 Record() 建構函式中宣告的所有屬性和預設值。如果 Record 例項中的某個屬性被刪除了,則只會講例項中的屬性值恢復為預設值:

// 1. 建立 Record 例項
const A = Record({ a: 1, b: 2 });
const r = new A({ a: 3 });
// => Record { "a": 3, "b": 2 }

// 2. 刪除例項屬性
const rr = r.remove(`a`);
// => Record { "a": 1, "b": 2 }

此外,Record 例項還具有擴充套件性:

class ABRecord extends Record({a:1,b:2}) {
  getAB() {
    return this.a + this.b;
  }
}

var myRecord = new ABRecord({b: 3})
myRecord.getAB()
// => 4

8. Seq

Seq 有兩個特點:immutable,一旦建立就不能被修改;lazy,惰性求值。在下面的程式碼中,雖然組合了多種遍歷操作,但實際上並不會有任何的求值操作,只是純粹的宣告一個 Seq:

var oddSquares = Immutable.Seq.of(1,2,3,4,5,6,7,8)
    .filter(x => x % 2)
    .map(x => x * x);

如果要從 oddSquares 中取出索引為 1 的元素,則執行過程為:

console.log(oddSquares.get(1));

// filter(1)
// filter(2)
// filter(3)
// map(3)
// => 9

Seq() 是 Seq 的構造方法,它根據傳入的引數型別,輸出響應的 Seq 型別:

  • 輸入 Seq,輸出 Seq

  • 輸入 Iterable,輸出同型別的 Seq(Keyed, Indexed, Set)

  • 輸入 Array-like,輸出 Seq.Indexed

  • 輸入附加 Iterator 的 Object,輸出 Seq.Indexed

  • 輸入 Iterator,輸出 Seq。indexed

  • 輸入 Object,輸出 Seq.Keyed

預設情況下 Seq 的惰性計算結果不會被快取,比如在下面的程式碼中,由於每個 join() 都會遍歷執行 map,所以 map 總共執行了六次:

var squares = Seq.of(1,2,3).map(x => x * x);
squares.join() + squares.join();

如果開發者知道 Seq 的結果會被反覆用到,那麼就可以使用 cacheResult() 將惰性計算的結果儲存到記憶體中:

var squares = Seq.of(1,2,3).map(x => x * x).cacheResult();
squares.join() + squares.join();

9. Iterable 和 Collection

Iterable 是鍵值對形式的集合,其例項可以執行遍歷操作,是 immutable.js 中其他資料型別的基類,所有擴充套件自 Iterable 的資料型別都可以使用 Iterable 所宣告的方法,比如 map 和 filter 等。

Collection 是 Concrete Data Structure 的基類,使用該類時需要至少繼承其子類中的一個:Collection.Keyed / Collection.Indexed / Collection.Set。

React

在 React 官方文件的《Advanced Performance》 一節中,專門對 React 的效能瓶頸、優化方式做了詳細的解析。當一個 React 元件的 props 和 state 發生變化時,React 會根據變化後的 props 和 state 建立一個新的 virtual DOM,然後比較新舊兩個 vritual DOM 是否一致,只有當兩者不同時,React 才會將 virtual DOM 渲染真實的 DOM 結點,而對 React 進行效能優化的核心就是減少渲染真實 DOM 結點的頻率,間接地指出開發者應該準確判斷 props 和 state 是否真正發生了變化。

在比對新舊 vritual DOM 和渲染真實 DOM 前,React 為開發者提供了 shouldComponentUpdate() 方法中斷接下來的比對和渲染操作,預設情況下,該方法總會返回 true,如果它返回 false,則不執行比對和渲染操作:

// 最簡單的實現:
shouldComponentUpdate (nextProps) {
    return this.props.value !== nextProps.value;
}

看起來挺簡單,實在不然。當我們需要比對的值是物件、陣列等引用值時,就會出現問題:

// 假設 this.props.value 是 { foo: `bar` }
// 假設 nextProps.value 是 { foo: `bar` },
// 顯然這兩者引用的記憶體地址不同,但它們具有相同的值,這種時候不應該繼續執行渲染
this.props.value !== nextProps.value; // true

如果資料是 Immutable Data 的話,那麼資料發生變化就會生成新的物件,開發者只需要檢查物件應用是否發生變化即可:

var SomeRecord = Immutable.Record({ foo: null });
var x = new SomeRecord({ foo: `bar`  });
var y = x.set(`foo`, `baz`);
x === y; // false

處理這一問題的另一種方式是通過 setter 設定 flag 對髒資料進行檢查,但冗雜的程式碼是在讓人頭疼。

總結

Immutable.js 所提供的 Immutable Data 和 JavaScript 固有的 Mutable Data 各有優勢,未來 ECAMScript 有可能制定一套原生的 Immutable Data 規範,在這之前,Immutable.js 是一個不錯的選擇。之前已經寫文章熟悉過 Lodash 這一工具庫,Immutable 內部也封裝了諸多常用的資料操作函式,所以如果讓我來選擇的話,在 React 技術棧中我會更偏愛 Immutable。

參考資料