Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

不可变数据结构(immutable data) #33

Open
sunyongjian opened this issue Nov 13, 2017 · 0 comments
Open

不可变数据结构(immutable data) #33

sunyongjian opened this issue Nov 13, 2017 · 0 comments

Comments

@sunyongjian
Copy link
Owner

不可变数据

引入

我是通过使用 React 才去关注 immutable data 这个概念的。事实上,你去搜 immutable 的 JS 相关文章,也基本都是近两年的,大概是随着 React 的推广才备受关注。但是这篇文章不会去介绍 React 是如何在意 immutable data 的,而是从原生 JS,写一些自己的思考。

可变/不可变对象

可变对象是一个可在其创建后修改状态的对象,而不可变对象则是创建之后,不能再修改状态,对其任何删改操作,都应返回一个新的对象。

一个例子开始:

var x = {
    a: 1
}
var y = x;
x.a = 2;
console.log(y); //{ a: 2 }

这在我们刚开始学 js 的时候就知道了,js 中的对象都是参考(reference)类型,x = y 是对象赋值引用,两者共用一个对象的空间,所以 x 改动了,y 自然也改变。

数组也是一样的:

var ary = [1, 2, 3];
var list = ary;
ary.push(4);
console.log(list); // [1, 2, 3, 4]

在 JS 中,objects, arrays,functions, classes, sets, maps 都是可变数据。
不过字符串和数字就不会。

var str = 'hello world';
var sub = str;
str = str.slice(0, 5);
console.log(sub); // 'hello world'

var a = 1;
var b = a;
a += 2;
console.log(b); // 1

像这样,sub = strb = a 的赋值操作,都不会影响之前的数据。

为什么要有不可变数据

首先,不可变数据类型是源于函数式编程中的,是一条必备的准则。函数式对数据处理的时候,通过把问题抽象成一个个的纯函数,每个纯函数的操作都会返回新的数据类型,都不会影响之前的数据,保证了变量/参数的不可变性,增加代码可读性。

另外,js 中对象可变的好处可能是为了节约内存,相比字符串、数字,它承载的数据量更大更多,不可变带来每次操作都要产生新的对象,新的数据结构,这与 js 设计之初用来做网页中表单验证等简单操作是有悖的。而且,我们最开始也确实感受到可变带来的便捷,但是反之它带来的副作用远超过这种便捷,程序越大代码的可读性,复杂度也越来越高。

举一个栗子:

const data = {
  name: 'syj',
  age: 24,
  hobby: 'girl',
  location: 'beijing'
}
// 有一个改变年龄的方法
function addAge(obj) {
    obj.age += 1;
    return obj;
}

// 一个改变地址的方法
function changeLocation(obj, v) {
    obj.location = v;
    return obj;
}

// 这两个方法我期待的是得到只改变想改变的属性的 data
console.log(addAge(data));
console.log(changeLocation(obj, 'shanghai'));

但实际上 addAge 已经把原始数据 data 改变了,当我再去使用的时候,已经是被污染的数据。这个栗子其实没有那么的典型,因为没有结合业务,但是也可以说明一些问题,就是可变数据带来的不确定影响。这两个函数都是有“副作用”的,即对传入数据做了修改,当你调用两次 addAge,得到的却是两个完全不同的结果,这显然不是我们想要的。如果遵循不可变数据的原则,每次对原始数据结构的修改、操作,都返回新的数据结构,就不会出现这种情况。关于返回新的数据结构,就需要用到数据拷贝。

数据拷贝

之前 y = x 这样的操作,显然是无法完成数据拷贝的,这只是赋值引用,为了避免这种对象间的赋值引用,我们应该更多的使用 const 定义数据对象,去避免这种操作。
而我们要给新对象(数据)创建一个新的引用,也就是需要数据拷贝。然而对象的数据结构通常是不同的(嵌套程度等),在数据拷贝的时候,需要考虑到这个问题,如果对象是深层次的

比较一下 JS 中几种原生的拷贝方法,了解他们能实现的程度。

Object.assign

像这样:

const x = { a: 1 };

const y = Object.assign({}, x);
x.a = 11;
console.log(y); // { a: 1 }

诚然,此次对 y 的赋值,再去改变 x.a 的时候,y.a 并没有发生变化,保持了不变性。你以为就这么简单吗?看另一个栗子:

const x = { a: 1, b: { c: 2 } };

const y = Object.assign({}, x);

x.b.c = 22;

console.log(y); // { a: 1, b: { c: 22}}

对 x 的操作,使 y.b.c 也变成了 22。为什么?因为 Object.assign 是浅拷贝,也就是它只会赋值对象第一层的 kv,而当第一层的 value 出现 object/array 的时候,它还是会做赋值引用操作,即 x,y 的 b 共用一个 {c: 2} 的地址。还有几个方法也是这样的。

Object.freeze

const x = { a: 1, b: { c: 2 } };
const y = Object.freeze(x);
x.a = 11;
console.log(y);

x.b.c = 22;

console.log(y); // { a: 1, b: { c: 22}}

freeze,看起来是真的“冻结”了,不可变了,其实效果是一样的,为了效率,做的浅拷贝。

deconstruction 解构

const x = { a: 1, b: { c: 2 } };
const y = { ...x };
x.a = 11;
console.log(y);

x.b.c = 22;

console.log(y);

es6 中的新方法,解构。数组也一样:

const x = [1, 2, [3, 4]];
const y = [...x];
x[2][0] = 33;
console.log(y); // [1, 2, [33, 4]]

同样是浅拷贝。

JS 原生对象的方法,是没有给我们提供深拷贝功能的。

deep-clone

如何去做深拷贝

  • 原生

拿上面的栗子来说,我们去实现深拷贝。

const x = { a: 1, b: { c: 2 } };
const y = Object.assign({}, x, {
  b: Object.assign({}, x.b)
})

x.b.c = 22;

console.log(y); // { a: 1, b: { c: 2 } }

不过这只是嵌套不多的时候,而更深层次的,就需要更复杂的操作了。实际上,deep-clone 确实没有一个统一的方法,需要考虑的地方挺多,比如效率,以及是否应用场景(是否每次都需要 deep-clone)。还有在 js 中,还要加上 hasOwnProperty 这样的判断。写个简单的方法:

function clone(obj) {
  // 类型判断。 isActiveClone 用来防止重复 clone,效率问题。
  if (obj === null || typeof obj !== 'object' || 'isActiveClone' in obj) {
    return obj;
  }

  //可能是 Date 对象
  const result = obj instanceof Date ? new Date(obj) : {};

  for (const key in obj) {
    if (Object.prototype.hasOwnProperty.call(obj, key)) {
      obj['isActiveClone'] = null;
      result[key] = clone(obj[key]);
      delete obj['isActiveClone'];
    }
  }

  return result;
}

var x = {
  a: 1,
  b: 2,
  c: {
    d: 3
  }
}
console.log(clone(x));
  • JSON
    最简单,偷懒的一种方式,JSON 的序列化再反序列化。
const y = JSON.parse(JSON.stringify(x));

普通的 string,number,object,array 都是可以做深拷贝的。不过这个方法比较偷懒,是存在坑的,比如不支持 NaN,正则,function 等。举个栗子:

const x = {
  a: function() {
    console.log('aaa')
  },
  b: NaN,
}

const y = JSON.parse(JSON.stringify(x));
console.log(y.b);
y.a()

试一下就知道了。

  • Library
    通常实现 deep-clone 的库:lodash$.extend(true, )... 目前最好用的是 immutable.js。 关于 immutable 的常用用法,之后会整理一下。

数据持久化

不变性可以让数据持久化变得容易。当数据不可变的时候,我们的每次操作,都不会引起初始数据的改变。也就是说在一定时期内,这些数据是永久存在的,而你可以通过读取,实现类似于“回退/切换快照”般的操作。这是我们从函数式编程来简单理解这个概念,而不涉及硬盘存储或者数据库存储的概念。

首先,无论数据结构的深浅,每次操作都对整个数据结构进行完整的深拷贝,效率会很低。这就牵扯到在做数据拷贝的时候,利用数据结构,做一些优化。例如,我们可以观察某次操作,到底有没有引起深层次数据结构的变化,如果没有,我们是不是可以只做部分改变,而没变化的地方,还是可以共用的。这就是部分持久化。我知道的 immutable 就是这么做的,两个不可变数据是会共用某部分的。

思考

  • js 的对象天生是可变的?

    我觉得作者应该是设计之初就把 js 作为一种灵活性较高的语言去做的,而不可变数据涉及到数据拷贝的算法问题,深拷贝是可以实现的,但是如何最优、效率最高的实现拷贝,并保持数据不可变。这个地方是可以继续研究的。

  • 为什么不可变数据的热度越来越高?

    随着 js 应用的场景越来越多,业务场景也越来越复杂,一些早就沉淀下来的编程思维,也被引入 js 中,像 MVC,函数式等等。经典的编程思想,设计模式永远都是不过时的,而不可变数据结构也是如此。而我觉得真正让它受关注的,还是 React 的推出,因为 React 内部就是通过 state/props 比较(===)去判断是否 render 的,三个等号的比较就要求新的 state 必须是新的引用。另外 Redux 在 React 中的广泛应用,也让函数式编程火热,而函数式编程最重要的原则之一就是不可变数据,所以你在使用
    Redux 的时候,改变 store 必须返回新的 state。所以,React-Redux 全家桶,让 immutable data 备受关注,而 immutable,就是目前最好的实现方案。

最后

之后会探究 immutable data 在 React 中的重要性,包括 diff,re-render,redux。自然而然也可以总结出这方面的 React 性能优化。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant