JavaScript不可变数据结构新的可能

1,984 阅读13分钟

什么是不可变数据结构

不可变数据结构的概念来自函数式编程,在函数式编程中,程序对数据的处理是通过一个个纯函数串联起来的。对于纯函数来说,每次都会返回一个新的数据对象,从而不会影响之前的数据,保证了数据的不可变性,增加了程序的稳定性和可读性。

在JavaScript中,没有不可变数据的概念,除了基础类型以外,其他的引用类型的数据都是可变的。

var test = {
    a: {
        b: 1
    }
};
var copy = test;

test.a.b = 2;

console.log(copy) // {a: {b: 2}}}

通过上面的例子可以看出来,在js中引用类型共用同一个内存地址,只有数据改变了,就会影响到所有指向这个内存地址的变量。

React中的不可变数据结构

在React和Redux中,都需要使用不可变数据去管理状态。

在React中,当你更改了一个组件的state时,这个组件以及它下面的所有子组件都会触发更新。当一个应用非常庞大时,这个全量的组件树更新开销会非常大,甚至会导致页面卡顿。React为了优化组件的渲染,提供了一个生命周期函数ShouldComponentUpdate,通过这个函数来判断当前的组件是否需要重新渲染。

在shouldComponentUpdate这个方法中,它会判断组件的props和state是否改变,通过返回true和false来告诉组件时候要重新渲染。

因为这个优化的存在,所以如果你使用的组件是基于PureComponent或者React.memo包装的,或者在组件内部使用了ShouldComponentUpdate这个方法做了优化,那么你的组件状态必须是不可变的,不然在进行前后数据比较的时候,就会一直判断数据没有变化,导致组件不会重新渲染。

常规写法

JavaScript中虽然有Object.assign等方法能快速生成一个新的对象,但是这些方法都是浅拷贝的,对于嵌套解构比较深的数据,是无能为力的。

虽然可以在每次操作中对state进行深拷贝,这样每一次的state都是一个全新的对象,但是这样做会存在比较大的问题。一方面,如果数据比较大,且操作频繁的话,那么深拷贝会是一个非常昂贵的操作;另一面,每次都生成新的数据,那么组件基于shouldComponentUpdate的优化都将不起作用,每一次父组件的数据改变了,子组件都会重新render。

在React组件中,为了在每次操作的时候返回一个新的状态,我们通常会使用扩展运算符来进行处理。

一个简单的例子:

handleAdd = () => {
    this.setState((state) => ({
        words: [...state.words, 'music']
    });    
}

但是当处理复杂的嵌套数据时,这种写法的可读性就会很差,并且当需要进行复杂的数据操作时,代码也难以维护

handleClick = () => {
    this.setState((state) => {
        return {
            address: {
                ...state.address,
                province: {
                    ...state.address.province,
                    city: 'hangzhou',
                }
            }
        };
    })
}

与组件中处理state相同,在Redux中编写Reducer函数的时候,也会遇到这种数据嵌套解构的问题。

对于这种繁琐的数据解构,在社区中有两个比较主流的不可变数据的库可以来帮助我们处理这个问题。

ImmutableJS

ImmutableJS 是Facebook开源的一个库,它实现的原理是Persistent Data Structure(持久化数据结构),并使用Structural Sharing(结构共享)来实现高性能的数据共享,一个对象树中的某个节点发生了变化,那么只需要修改这个节点和它的父节点,其他的节点则被新的对象树共享。

var test = {
    name: 'music',
    list: [1,2,3,4]
};
    
var a = Immutable.fromJS(test);
var b = a.set('name', 'netease');

console.log(a.list === b.list) // true

从上面的例子可以看出来,a是通过test转换来的Immutable对象,b由a生成,并且修改了name这个属性,但是list这个属性没有被修改,所以a和b共用了list。

虽然ImmutableJS实现是不可变数据结构,但是存在一些比较明显的问题:

  • ImmutableJS在内部实现了一套自己的数据解构,并且和js不兼容,需要通过fromJS这个方法将js的对象转换成它自己的数据解构,如果js要使用这个Immutable的数据,需要使用toJS这个方法将数据进行转换。
  • ImmutableJS本身具有很多的概念和API,学习成本很高,并且这个库的体积也比较大

Immer.js

Immer 是mobx的作者写的一个immutable库,它利用ES6的Proxy来对数据进行劫持,对于对象中没有被修改的数据,也会被共享,并且对于不支持Proxy的浏览器,可以使用defineProperty来进行兼容。

Immer设计简单,没有复杂的API,并且是通过js的内置语法来实现的,没有什么学习成本,基本上能满足对于不可变数据的需求。

/**
 * Classic React.setState with a deep merge
 */
onBirthDayClick1 = () => {
    this.setState(prevState => ({
        user: {
            ...prevState.user,
            age: prevState.user.age + 1
        }
    }))
}

/**
 * ...But, since setState accepts functions,
 * we can just create a curried producer and further simplify!
 */

import produce from 'immer';
onBirthDayClick2 = () => {
    this.setState(
        produce(draft => {
            draft.user.age += 1
        })
    )
}

官方也提供了 use-immer 这个库,用来对hooks进行支持

import React from "react";
import { useImmer } from "use-immer";


function App() {
  const [person, updatePerson] = useImmer({
    name: "Michel",
    age: 33
  });

  function updateName(name) {
    updatePerson(draft => {
      draft.name = name;
    });
  }

  function becomeOlder() {
    updatePerson(draft => {
      draft.age++;
    });
  }

  return (
    <div className="App">
      <h1>
        Hello {person.name} ({person.age})
      </h1>
      <input
        onChange={e => {
          updateName(e.target.value);
        }}
        value={person.name}
      />
      <br />
      <button onClick={becomeOlder}>Older</button>
    </div>
  );
}

ECMAScript新的提案:Record & Tuple

Record 和 Tuple 的提案现在处于stage 2 的阶段,还有更改的可能性。

虽然可以使用第三方库实现不可变数据,但是他们都不是在JavaScript语言层面上的。

相比于第三方库,提案中的Record和Tuple,是内置的、并且深度不可变的数据解构。

  • Record 和 Tuple 更容易调试
  • Record 和 Tuple 在用法和写法上接近对象和数组,不用像一些第三方库需要一些特定的操作去转换不同的数据结构
  • 避免开发人员在常规JS对象和不可变结构之间进行昂贵的转换操作,可以使开发者一直使用不可变数据结构

Record和Tuple是通过对数据结构的强制规范来实现深度不可变的,Record和Tuple只能包含基础类型、Record和Tuple类型的数据。

简单的示例

Record

const proposal = #{
  id: 1234,
  title: "Record & Tuple proposal",
  contents: `...`,
  // tuples are primitive types so you can put them in records:
  keywords: #["ecma", "tc39", "proposal", "record", "tuple"],
};

// Accessing keys like you would with objects!
console.log(proposal.title); // Record & Tuple proposal
console.log(proposal.keywords[1]); // tc39

// Spread like objects!
const proposal2 = #{
  ...proposal,
  title: "Stage 2: Record & Tuple",
};
console.log(proposal2.title); // Stage 2: Record & Tuple
console.log(proposal2.keywords[1]); // tc39

// Object work functions on Records:
console.log(Object.keys(proposal)); // ["contents", "id", "keywords", "title"]

Tuple

const measures = #[42, 12, 67, "measure error: foo happened"];

// Accessing indices like you would with arrays!
console.log(measures[0]); // 42
console.log(measures[3]); // measure error: foo happened

// Slice and spread like arrays!
const correctedMeasures = #[
  ...measures.slice(0, measures.length - 1),
  -1
];
console.log(correctedMeasures[0]); // 42
console.log(correctedMeasures[3]); // -1

// or use the .with() shorthand for the same result:
const correctedMeasures2 = measures.with(3, -1);
console.log(correctedMeasures2[0]); // 42
console.log(correctedMeasures2[3]); // -1

// Tuples support methods similar to Arrays
console.log(correctedMeasures2.map(x => x + 1)); // #[43, 13, 68, 0]

和Records类似,我们可以将Tuples看成类数组的结构

const ship1 = #[1, 2];
// ship2 is an array:
const ship2 = [-1, 3];

function move(start, deltaX, deltaY) {
  // we always return a tuple after moving
  return #[    start[0] + deltaX,
    start[1] + deltaY,
  ];
}

const ship1Moved = move(ship1, 1, 0);
// passing an array to move() still works:
const ship2Moved = move(ship2, 3, -1);

console.log(ship1Moved === ship2Moved); // true
// ship1 and ship2 have the same coordinates after moving

禁止的操作

就像前面说的,Records 和 Tuples 是深度不可变的,所以在他们中插入一个对象会报TypeError的错误

const instance = new MyClass();
const constContainer = #{
    instance: instance
};
// TypeError: Record literals may only contain primitives, Records and Tuples

const tuple = #[1, 2, 3];

tuple.map(x => new MyClass(x));
// TypeError: Callback to Tuple.prototype.map may only return primitives, Records or Tuples

// The following should work:
Array.from(tuple).map(x => new MyClass(x))

语法

在这个提案中定义了新的语法片段,将会添加到JavaScript语言中。

我们通过在普通对象或者数组前面添加 # 修饰符来表示一个Record或者Tuple。

#{}
#{ a: 1, b: 2 }
#{ a: 1, b: #[2, 3, #{ c: 4 }] }
#[]
#[1, 2]
#[1, 2, #{ a: 3 }]

语法错误

和数组不同,Tuples不允许空的占位符

const x = #[,]; // SyntaxError, holes are disallowed by syntax

Record中不允许使用__proto__这个标识符来定义属性

const x = #{ __proto__: foo }; // SyntaxError, __proto__ identifier prevented by syntax

const y = #{ "__proto__": foo }; // valid, creates a record with a "__proto__" property.

在Records中不允许使用方法的简写

#{ method() { } }  // SyntaxError

运行时错误

Records中只允许字符串作为键,不允许使用Symbols来作为键

const record = #{ [Symbol()]: #{} };
// TypeError: Record may only have string as keys

Records 和 Tuples 只能包含基础类型和其他的Records 和 Tuples。如果尝试向他们中增加除了下面这些类型的变量: Record, Tuple, String, Number, Symbol, Boolean, Bigint, undefined, 和 null,将会抛出TypeError的报错。

相等性

和布尔、字符串这些基础类型相同,Records 和 Tuples 在进行相等性的判断时,是值的比较,而不是引用的比较。

assert(#{ a: 1 } === #{ a: 1 });
assert(#[1, 2] === #[1, 2]);

对于js的对象来说,会有不同的结果

assert({ a: 1 } !== { a: 1 });
assert(Object(#{ a: 1 }) !== Object(#{ a: 1 }));
assert(Object(#[1, 2]) !== Object(#[1, 2]));

Record 中键的顺序,不会影响比较的结果,因为这些键是隐式排序的

assert(#{ a: 1, b: 2 } === #{ b: 2, a: 1 });

Object.keys(#{ a: 1, b: 2 })  // ["a", "b"]
Object.keys(#{ b: 2, a: 1 })  // ["a", "b"]

如果结构和内容都是深度相同的,那么Recrod 和 Tuple 根据下面几个相等操作符得出的结果就是相等的,这些操作符包括:Object.is(), == , ===,以及SameValueZero算法(用来比较Maps和Sets的键)。但是他们在处理 -0 时会有一些差异

  • Object.is()在遇到 -0 和 0 时,认为两者是不想等的
  • ==, === and SameValueZero 认为 -0 with 0 是相等的

== 和 === 运算符对于嵌套在Records 和 Tuples 中的其他类型的比较更加直接,当且仅当内容相同时返回true(0/-0除外)。这种直接性对NaN和其他类型的比较都会产生影响。

assert(#{ a:  1 } === #{ a: 1 });
assert(#[1] === #[1]);

assert(#{ a: -0 } === #{ a: +0 });
assert(#[-0] === #[+0]);
assert(#{ a: NaN } === #{ a: NaN });
assert(#[NaN] === #[NaN]);

assert(#{ a: -0 } == #{ a: +0 });
assert(#[-0] == #[+0]);
assert(#{ a: NaN } == #{ a: NaN });
assert(#[NaN] == #[NaN]);
assert(#[1] != #["1"]);

assert(!Object.is(#{ a: -0 }, #{ a: +0 }));
assert(!Object.is(#[-0], #[+0]));
assert(Object.is(#{ a: NaN }, #{ a: NaN }));
assert(Object.is(#[NaN], #[NaN]));

// Map keys are compared with the SameValueZero algorithm
assert(new Map().set(#{ a: 1 }, true).get(#{ a: 1 }));
assert(new Map().set(#[1], true).get(#[1]));
assert(new Map().set(#[-0], true).get(#[0]));

标准库的支持

Tuple的功能大致和Array相同,同样的,Record也能被Object静态方法操作。

assert(Object.keys(#{ a: 1, b: 2 }) === #["a", "b"]);
assert(#[1, 2, 3].map(x => x * 2), #[2, 4, 6]);

将Object和Array进行转换

可以使用Record() 和 Tuple.from() 进行转化

const record = Record({ a: 1, b: 2, c: 3 });
const record2 = Record.fromEntries([#["a", 1], #["b", 2], #["c", 3]]); // note that an iterable will also work
const tuple = Tuple.from([1, 2, 3]); // note that an iterable will also work
assert(record === #{ a: 1, b: 2, c: 3 });
assert(tuple === #[1, 2, 3]);
Record.from({ a: {} }); // TypeError: Can't convert Object with a non-const value to Record
Tuple.from([{}, {} , {}]); // TypeError: Can't convert Iterable with a non-const value to Tuple

需要注意的是,Record() 和 Tuple.from()期望的输入值由Records、Tuples或者其他的基础类型组成的集合。嵌套对象引用将会抛出TypeError的错误。

迭代协议

和数组一样,Tuple也是可迭代的。

const tuple = #[1, 2];

for (const o of tuple) { console.log(o); }
// output is:
// 1
// 2

和对象一样,Record只能被Object.entries类似的API进行迭代

const record = #{ a: 1, b: 2 };

// TypeError: record is not iterable
for (const o of record) { console.log(o); }

// Object.entries can be used to iterate over Records, just like for Objects
for (const [key, value] of Object.entries(record)) { console.log(key) }
// output is:
// a
// b

JSON.stringify

  • JSON.stringify(record)的行为等效于一个对象使用JSON.stringify
  • JSON.stringify(tuple)的行为等效于一个数组使用JSON.stringify

JSON.parseImmutable

我们建议添加JSON.parseImmutable方法,以便我们能直接从JSON字符串中提取Record/Tuple类型的数据,而不用从Object/Array中提取。

JSON.parseImmutable的签名和JSON.parse相同,唯一的区别在于它返回的类型是Record/Tuple。

Tuple.prototype

Tuple支持和数组相似的实例方法,但是有一些更改,Tuple上支持的所有 方法

typeof

Records 和 Tuples 将被识别为不同的类型

assert(typeof #{ a: 1 } === "record");
assert(typeof #[1, 2]   === "tuple");

在 Map|Set|WeakMap|WeakSet 中使用

可以将Record 和 Tuple 当成Map的键,也可以用作Set的值。当使用它们的时候,将按值对它们进行比较。

不能将Record和Tuple用作WeakMap的键或WeakSet的值,因为它们不是引用类型,并且它们的声明周期也无法被观察的。

Map
const record1 = #{ a: 1, b: 2 };
const record2 = #{ a: 1, b: 2 };

const map = new Map();
map.set(record1, true);
assert(map.get(record2));
Set
const record1 = #{ a: 1, b: 2 };
const record2 = #{ a: 1, b: 2 };

const set = new Set();
set.add(record1);
set.add(record2);
assert(set.size === 1);
WeakMap and WeakSet
const record = #{ a: 1, b: 2 };
const weakMap = new WeakMap();
const weakSet = new WeakSet();

// TypeError: Can't use a Record as the key in a WeakMap
weakMap.set(record, true);

// TypeError: Can't add a Record to a WeakSet
weakSet.add(record);

为什么要深度不可变性?

将Record和Tuple定义为复合的基础类型,使得它们中的所有内容都不能是引用类型的。这带来了一些缺点(引用对象变得困难,但是仍然能够做到),但是也使得不可变性有了更多的保证,避免一些常见的变成错误。

const object = {
   a: {
       foo: "bar",
   },
};
Object.freeze(object);
func(object);

// func is able to mutate object’s keys even if object is frozen

在上面的示例中,我们尝试使用Object.freeze来获得更多的数据不可变行的保证,但是由于freeze不支持深度冻结,因此a这个属性还是可以被修改操作的。使用Record和Tuple,这种不可变的约束是天生的:

const record = #{
   a: #{
       foo: "bar",
   },
};
func(record);
// runtime guarantees that record is entirely unchanged
assert(record.a.foo === "bar");

最后,深度不可变的数据结构,减少了通过深拷贝对象来实现数据不可变的需求

const clonedObject = JSON.parse(JSON.stringify(object));
func(clonedObject);
// now func can have side effects on clonedObject, object is untouched
// but at what cost?
assert(object.a.foo === "bar");

操作Record的提案:Deep Path Properties in Record Literals

Record有时会包含很深的嵌套结构,使用对象解构的方法对这些数据进行复用和拓展可能会很麻烦和冗长。这个提案引入一种新的语法以更简洁和易读的方式来描述这种深层嵌套的结构。

示例

const state1 = #{
    counters: #[
        #{ name: "Counter 1", value: 1 },
        #{ name: "Counter 2", value: 0 },
        #{ name: "Counter 3", value: 123 },
    ],
    metadata: #{
        lastUpdate: 1584382969000,
    },
};

const state2 = #{
    ...state1,
    counters[0].value: 2,
    counters[1].value: 1,
    metadata.lastUpdate: 1584383011300,
};

assert(state2.counters[0].value === 2);
assert(state2.counters[1].value === 1);
assert(state2.metadata.lastUpdate === 1584383011300);

// As expected, the unmodified values from "spreading" state1 remain in state2.
assert(state2.counters[2].value === 123);

如果没有这个提案的语法,可以通过其他的方式来创建state2:

// With records/tuples and recursive usage of spread syntax
const state2 = #{
    ...state1,
    counters: #[
        #{
            ...state1.counters[0],
            value: 2,
        },
        #{
            ...state1.counters[1],
            value: 1,
        },
        ...state1.counters,
    ],
    metadata: #{
        ...state1.metadata,
        lastUpdate: 1584383011300,
    },
}

// With Immer (and regular objects)
const state2 = Immer.produce(state1, draft => {
    draft.counters[0].value = 2;
    draft.counters[1].value = 1;
    draft.metadata.lastUpdate = 1584383011300;
});

// With Immutable.js (and regular objects)
const immutableState = Immutable.fromJS(state1);
const state2 = immutableState
    .setIn(["counters", 0, "value"], 2)
    .setIn(["counters", 1, "value"], 1)
    .setIn(["metadata", "lastUpdate"], 1584383011300);

一个简单的例子

const rec = #{ a.b.c: 123 };
assert(rec === #{ a: #{ b: #{ c: 123 }}});

计算出深度路径的键

const rec = #{ ["a"]["b"]["c"]: 123 }
assert(rec === #{ a: #{ b: #{ c: 123 }}});

可以将.操作符和计算属性进行混用

const b = "b";
const rec = #{ ["a"][b].c: 123 }
assert(rec === #{ a: #{ b: #{ c: 123 }}});

将深层路径的属性和解构相结合

const one = #{
    a: 1,
    b: #{
        c: #{
            d: 2,
            e: 3,
        }
    }
};
const two = #{
    b.c.d: 4,
    ...one,
};

assert(one.b.c.d === 2);
assert(two.b.c.d === 4);

可以用来遍历Tuple

const one = #{
    a: 1,
    b: #{
        c: #[2, 3, 4, #[5, 6]]
    },
}
const two = #{
    b.c[3][1]: 7,
    ...one,
};

assert(two.b.c === #[2, 3, 4, #[5, 7]]);

需要注意的点

当一个解构的对象中没有某个指定的深层路径属性的时候,会抛出TypeError的错误。

const one = #{ a: #{} };

#{ ...one, a.b.c: "foo" }; // throws TypeError

#{ ...one, a.b[0]: "foo" }; // also throws TypeError

如果深度路径属性尝试在Tuple上设置非数字键,会抛出TypeError的错误。

const one = #{ a: #[1,2,3] };

#{ ...one, a.foo: 4 }; // throws TypeError

提案链接

proposal-record-tuple

Deep Path Properties in Record Literals