不变性是函数式编程中的核心原则,在很多面向对象程序中也都有所体现。在这篇文章中,我将精确地说明什么是不变性、如何在JavaScript中使用这个概念以及为什么它是有用的。
什么是不变性?
可变性的文本定义是“易于改变或变化的”。在编程中,我们使用这个词来表示允状态随时间而变化的对象。一个不可改变的值的定义是完全相反的——在被创建之后,它永远不会改变。
如果你觉得这看起来很奇怪,请允许我提醒你:我们一直使用的许多值实际上是不可改变的。
var statement = "I am an immutable value";
var otherStr = statement.slice(8, 17);
我相信没有人会惊讶地发现第二行决不会改变statement
中的字符串。实际上,没有字符串方法可以改变他们操作的字符串,它们都是返回新的字符串。原因是字符串是不可变的——它们不能改变,我们只能创建新的字符串。
字符串并不是JavaScript内置的唯一不变的值。数字也是不变的。你能想象一个计算表达式2+3
改变了数字2的含义吗?
但是我们一直在用我们的对象和数组做这样的事情,虽然这听起来很荒谬。
在JavaScript中,变化是大量存在的
在JavaScript中,字符串和数字是特意被设计成不可变的。但是,请考虑以下使用数组的示例:
var arr = [];
var v2 = arr.push(2);
v2有什么值?如果数组与字符串和数字一样,v2将包含一个新数组,新数组中包含一个元素——数字2。然而,事实并非如此。相反,arr引用已被更新为包含数字2,而v2则包含了arr的新长度。
想象一下ImmutableArray类型。受字符串和数字特性的启发,它会有以下特性:
var arr = new ImmutableArray([1, 2, 3, 4]);
var v2 = arr.push(5);
arr.toArray(); // [1, 2, 3, 4]
v2.toArray(); // [1, 2, 3, 4, 5]
类似地,可以替代大多数对象的ImmutableMap将具有“设置”属性的方法,实际上该方法不会设置任何内容,但是返回具有所需更改内容的新对象:
var person = new ImmutableMap({name: "Chris", age: 32});
var olderPerson = person.set("age", 33);
person.toObject(); // {name: "Chris", age: 32}
olderPerson.toObject(); // {name: "Chris", age: 33}
就像2 + 3没有改变数字2或3的意义一样,一个庆祝他们33岁生日的人不会改变他们曾经是32岁的事实。
不变性在JavaScript中的实践
JavaScript现在还没有不可变的list和map,所以我们现在需要一个第三方库。这里有两个非常优秀的库我们可以使用。第一个是Mori,它可以确保在JavaScript中使用ClojureScript的持久数据结构和JavaScript支持的API。另一个是由Facebook开发人员撰写的immutable.js。对于这个演示我会使用immutable.js,仅仅因为JavaScript开发人员更熟悉它的API。
在这个演示中,我们将看一看扫雷游戏中的不可变数据是如何工作的。扫雷区域的板块由一个不可变的map表示,其中最有趣的数据部分是tiles
。它是一个由不可变maps组成的不可变list,其中每个map代表了板块上的一个tile区域。整个东西是使用JavaScript对象和数组初始化的,然后使用immutable.js的fromjs函数使其永生化。
function createGame(options) {
return Immutable.fromJS({
cols: options.cols,
rows: options.rows,
tiles: initTiles(options.rows, options.cols, options.mines)
});
}
游戏核心逻辑的剩余部分是被实现成了一个函数,这个函数将这个不可变结构作为第一个参数,并且返回一个新的实例。最重要的函数是revealTile。当这个函数被调用的时候,它将标记tile,让它被揭露显示出来。因为使用了可变数据结构,所以这将是非常容易的事情:
function revealTile(game, tile) {
game.tiles[tile].isRevealed = true;
}
然而,因为使用上面提到的那种不可改变的结构,它将变得有点令人折磨:
function revealTile(game, tile) {
var updatedTile = game.get('tiles').get(tile).set('isRevealed', true);
var updatedTiles = game.get('tiles').set(tile, updatedTile);
return game.set('tiles', updatedTiles);
}
唷!幸运的是,这种事情很常见。因此,我们的工具包提供了下面这样的方法:
function revealTile(game, tile) {
return game.setIn(['tiles', tile, 'isRevealed'], true);
}
现在,revealTile函数返回一个新的不可变实例,其中一个tile与以前的版本不同。setIn是空安全的,如果键中不存在任何内容,将使用空对象填充。对于这个游戏来说这是不可取的,因为一块丢失的tile表示我们试图在扫雷板块外面露出一块tile。这里可以在操作之前通过使用getIn函数查找tile来解决这种问题:
function revealTile(game, tile) {
return game.getIn(['tiles', tile]) ?
game.setIn(['tiles', tile, 'isRevealed'], true) :
game;
}
如果tile不存在,我们只需返回现有的游戏。这个演示是一个在实践中对不变性的快速体验,想更深入了解的人可以看看这个CodePen,其中包括一个完整的实现扫雷游戏的规则。
性能如何?
你可能会认为这会产生可怕的性能效果,在某些方面你是正确的。你无论何时向不可变对象添加内容,都需要通过复制现有值并将新值添加到它来创建新实例。这必将比转换单个对象更占用密集的内存以及处理更具挑战性的计算。
因为不可变的对象永远不会改变,它们可以使用一种称为“结构共享”的策略来实现,这将花费比你预期的要少得多的内存开销。与内置数组和对象相比,它仍然会有一个开销,但它是不变的,通常还可以通过不变性提供的其他优势来缩小。在实践中,许多情况下不可变数据的使用将提高你的应用程序的整体性能,即使某些孤立的操作将变得更加困难。
改进的更改跟踪
在任何UI框架中最困难的工作之一就是变化跟踪。人们对ECMAScript7提供了一个单独的API:Object.observe(),以帮助跟踪具有更好性能的目标变化存在广泛的质疑。虽然很多人都对这个API感到兴奋,但其他人觉得这是不正确的。在任何情况下,它都不能正确地解决更改跟踪问题:
var tiles = [{id: 0, isRevealed: false}, {id: 1, isRevealed: true}];
Object.observe(tiles, function () { /* ... */ });
tiles[0].id = 2;
tiles[0]对象的更改不会触发我们的更改观察事件,因此,提出的更改跟踪机制几乎失败甚至是最微不足道的案例。在这种情况下,不变性将怎样提供帮助?给定应用程序状态a和潜在的新应用程序状态b:
if (a === b) {
// 数据没有改变,中止
}
如果应用程序状态尚未被更新,那么它将与以前一样,我们根本不需要做任何事情。这确实要求我们跟踪持有该状态的引用,但现在整个问题已经简化到管理单个引用。
结论
我希望这篇文章能给你一些关于不变性是如何帮助你改进你的代码的底层知识,所提供的例子可以说明这样的做法在实践中是如何实现的。不变性的重要性在不断提升,这不会是你今年阅读的关于这个问题的最后一篇文章。因为我没有时间,你可以多去尝试这个事情,我保证你对于它会很兴奋。
感谢Christian先生允许我翻译该文章,原文链接:https://www.sitepoint.com/immutability-javascript/