当语言没有提供任何明显的实现不可变性的方法时,人们如何在JavaScript中实现不可变的数据结构?




为什么要进行功能编程


我决定尝试"功能编程">因为我从多个来源了解到,函数式编程有以下好处:

  • 因为它的核心关注点是不变性,所以实现该范式的程序在运行时不太容易受到外部软件影响或更改代码的影响。

  • 许多人被函数式编程所吸引,因为它非常易于测试。因为函数必须总是为同一个参数返回相同的结果,所以代码是高度可预测的,使其具有高度可测试性。

  • 如上所述,编写函数可以创建非常可预测的代码,但也可以创建可读性很强的代码。由于代码是可预测的和可读的,所以它也很快被理解,尤其是那些知道如何实现这个概念的人。

。。。谁会不想要呢?当然,我试过了。




我的第一次失败尝试:


我第一次尝试实现";"功能编程">进展不顺利,我不知道我现在是否对这个概念有了更好的理解。在我的脑海中,我想到了状态(我的程序在任何给定时刻的状态)。我想让我实现的所有东西的状态都是不变的。我只写了两行代码,就很快意识到我对自己在做什么一无所知。我的第一个想法是使变量不可写,这并没有像我预期的那样成功。一切都是静态的,我不知道如何创建一个动态程序,同时实现不可变的变量。


显然,不可变并不意味着静态,但当系统中的所有值都无法更改时,我并不完全清楚如何实现动态系统

为了以清晰简洁的方式提出我的问题,不需要意见,我写了这个问题。

"如何使用JavaScript、TypeScipt和/或者Node.js开发人员在JavaScript不提供任何明确的不可变数据类型或支持的情况下,实现不可变数据结构来管理应用程序的状态">

Any不可变数据结构的一个例子就是我正在寻找的;以及如何实现允许我利用数据结构来管理JS应用程序状态的功能。如果答案涉及到使用第三方库、其他语言或任何其他工具,我完全可以。一个实际代码的例子会很棒,这样我就可以解释和理解一些东西。





Bellow是我创建一个不可变数据结构的可怕尝试,我可以实现它
虽然它不是很好的代码,但它展示了我试图实现的目标

'use strict';
const obj = {};
Object.defineProperties(obj, {
prop_1: {
value: (str) => {this.prop_3 = str};
writable: false,
},
prop_2: {
value: () => this.prop_3;
writable: false,
},
prop_3: {
value: '',
writable: false,
},
});
obj.prop_1('apples & bananas');
console.log(obj.prop_3);

/*
TERMINAL OUTPUT:
Debugger attached.
Waiting for the debugger to disconnect...
file:///home/ajay/Project-Repos/j-commandz/sandbox.js:19
this.prop_3 = str;
^
TypeError: Cannot assign to read only property 'prop_3' of object '#<Object>'
at Object.set (file:///home/ajay/Project-Repos/j-commandz/sandbox.js:19:19)
at file:///home/ajay/Project-Repos/j-commandz/sandbox.js:37:5
*/

没错,Javascript(与Haskell&co不同)并不能为不可变数据结构提供一流的支持(在Java中,关键字为final)。这并不意味着您不能以不可变的方式编写代码或对程序进行推理。

正如其他人所提到的,您仍然有一些本地JavaScriptAPI可以帮助您实现不变性(ish),但正如您已经意识到的那样,它们都不能真正解决问题(Object.freeze的作用很浅,const可以防止您重新分配变量,但不能使其发生突变,等等)


那么,如何做不可变JS

我想提前道歉,因为这个答案可能主要基于观点,并且不可避免地会因我自己的经验和思维方式而存在缺陷。所以,请谨慎选择以下内容,因为这只是我在这个话题上的两分钱。

我想说,不变性主要是一种心态,在此基础上,您可以构建所有支持(或更容易使用)它的语言API

我之所以说";这主要是一种精神状态;因为你可以(在某种程度上)用第三方库来弥补一流语言结构的不足(还有一些非常令人印象深刻的成功案例)。

但是不可变是如何工作的

好吧,它背后的想法是,任何变量都被视为固定的,任何突变都必须在新的实例中解决,而不影响原始的input

好消息是,所有javascript原语都已经是这样了。

const input = 'Hello World';
const output = input.toUpperCase();
console.log(input === output); // false

那么,问题是,我们如何将的一切视为它是原始

答案很简单,接受函数式编程的一些基本原则,让第三方库来填补这些语言空白。

  1. state与其transition逻辑分离:
class User {
name;
setName(value) { this.name = value }
}


const user = { name: 'Giuseppe' };
const setUserName = (name, user) => ({ ...user, name });
  1. 避免强制性方法并利用第三方专用库
import * as R from 'ramda';
const user = { 
name: 'Giuseppe',
address: {
city: 'London',
}
};

const setUserCity = R.assocPath(['address', 'city']);
const output = setUserCity('Verbicaro', user);
console.log(user === output); // recursively false

也许是我喜欢的一些libs的注释

  1. Ramda提供了不变性,并用通常在任何f语言中都能找到的所有声明性好东西丰富了js-api(避难所js和fp-ts也是伟大的成功案例)
  2. RxJS允许使用序列进行不可变和无副作用的编程,同时还提供惰性评估机制等
  3. CCD_ 9和CCD_

最后一个例子

const reducer = (user, { type, payload }) => {
switch(type) {
case 'user/address/city | set':
return R.assocPath(['address', 'city'], payload, user);

default:
return user;
}
}
const initial = {
name: 'Giuseppe',
address: {
city: 'Verbicaro',
},
};
const store = Redux.createStore(reducer, initial);
console.log('state', store.getState());
store.dispatch({ 
type: 'user/address/city | set', 
payload: 'London',
});
console.log('state2', store.getState());
<script src="https://cdnjs.cloudflare.com/ajax/libs/ramda/0.27.1/ramda.js" integrity="sha512-3sdB9mAxNh2MIo6YkY05uY1qjkywAlDfCf5u1cSotv6k9CZUSyHVf4BJSpTYgla+YHLaHG8LUpqV7MHctlYzlw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/redux/4.1.0/redux.js" integrity="sha512-tqb5l5obiKEPVwTQ5J8QJ1qYaLt+uoXe1tbMwQWl6gFCTJ5OMgulwIb3l2Lu7uBqdlzRf5yBOAuLL4+GkqbPPw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>

最后重申您自己的示例

const obj = {
prop_1(value) { 
return { ...this, prop_3: value }
},
prop_2: () => this.prop_3,
prop_3: '',
}
console.log(obj);
const obj2 = obj.prop_1('Apple & Banana');
console.log(obj2);

尽管JavaScript缺少不可变的内置数据结构,但不可变状态仍然是可能的

正如您所知,变量存储程序的状态。像Lisp这样的函数式语言通常通过将当前状态作为输入并返回新的更新状态作为输出来更改程序状态(用作另一个函数的输入;重复)。

JavaScript程序通常通过改变变量来改变程序状态,但也可以使用上面描述的Lisp所使用的方法。与其编写变异变量的函数,不如简单地编写输入当前状态并返回新输出状态的函数,而无需修改任何输入。

在JavaScript:中以不可变样式编程时,您可能会遇到一些缺点

  • JavaScript是针对突变而不是不变性进行优化的。因此,内存/性能可能会受到影响。不可变范式更喜欢生成新的值,而不是改变现有的值。(另一方面,类Lisp语言经过优化,偏向于不变性而非突变。)
  • 突变可能";"泄漏";如果您使用了许多变异数据的JavaScript基元中的任何一个(如Array.sort()),则可以将其添加到程序中

有助于在JS中使用不可变范式的库

Immer

Immer是一个JS库,有助于在JS:中使用不可变状态

基本思想是将所有更改应用于draftState,它是当前State的代理。一旦你突变完成后,Immer将根据突变到草案状态。这意味着您可以与只需简单地修改数据,同时保留不可变数据。

不可变.js

Immutable.js是另一个帮助在js:中实现不可变状态的js库

Immutable.js提供了许多持久的不可变数据结构包括:列表、堆栈、映射、有序映射、集合、有序集合和记录。

这些数据结构在现代JavaScript虚拟机上通过通过散列映射尝试和向量尝试使用结构共享由Clojure和Scala推广,最大限度地减少了复制或缓存的需要数据

森喜朗

Mori提取了ClojureScript优化的不可变数据结构,因此您可以在vanillaJS中使用它们。(ClojureScript是一个可编译为JavaScript的Lisp。)

JavaScript不可变库列表

这个不可变库列表分为两大类:

  • 带结构共享的持久数据结构
  • 不可变的助手(只是浅层复制JS对象)

JavaScript函数式编程的更长资源列表

https://project-awesome.org/stoeffel/awesome-fp-js

最后,ProseMirror是JavaScript中不可变状态的一个很好的现实例子:

ProseMirror是一个用JavaScript编写的编辑器,它使用持久数据结构来存储文档数据。

  • 注意,按照约定,此数据结构是不可变的。开发人员必须确保数据结构不会发生变化。数据结构可能会发生变化,但结果是不确定的,很可能是不希望的。因此ProseMirror提供了支持不变性的文档和函数
  • 使用Object.freeze()可以强制该数据结构的不变性,但通常避免这样做,因为这会带来巨大的性能损失
  • 还要注意的是,不变性不是"不变";要么全有要么全无"主数据结构是不可变的,但可变变量在更有意义的地方使用(比如循环变量)

JavaScript并没有真正实现不可变数据的方法,至少不是显而易见的方法。

您可能是JS的新手,但使对象不可变的明显方法是冻结它:

const obj = Object.freeze({
prop_2() { return this.prop_3 }
prop_3: '',
});
obj.prop_3 = 'apples & bananas'; // throws as expected
console.log(obj.prop_3);

如果对象是不可变的,我们需要用我们想要的新值来创建新的对象,而不是分配给它们的属性。在对象文本中扩展属性语法有助于我们实现这一点,辅助方法也是如此:

const base = {
withProp(newVal) {
return { withProp: this.withProp, prop: newVal };
},
prop: '';
};
const obj1 = base.withProp('apples & bananas');
console.log(obj1.prop);
const obj2 = {...base, prop: obj1.prop + ' & oranges'};
console.log(obj2.prop);

有了足够的自我约束(或者演练,或者代码审查,或者像类型检查器和linters这样的工具),这种克隆对象的编程风格就变得很自然了,你再也不会因为意外分配而出错了。

当然,用更复杂的(嵌套的)结构来做这件事是很麻烦的,所以有相当多的库提供辅助函数,还有更高级的纯函数数据结构的实现,它们比每次克隆整个数据更有效。

一种(多种)想法可能是将对象包装在Proxy中。类似于以下片段:

另请参阅。。。

function createObject(someObject) {
const localScores = {value: {history: [], current: []}, writable: true};
const proxyHandler = {
get: (target, prop) => {
if (!(prop in target)) {
console.log(`'${prop}' is a non existing property`);
return null;
}
return target[prop].value;
},
set: (obj, prop, value) => {
if (obj[prop] && obj[prop].writable) {
obj[prop].value = value;
return value;
}
console.log(`'${prop}' is not writable, sorry`);
},
};  
return new Proxy(
{...someObject, ...{ local: localScores } }, 
proxyHandler
);
}
const obj = createObject({
prop1: {value: 'some string 1', writable: false}, 
prop2: {value: '', writable: true}, 
prop3: {value: 42, writable: false},
});
obj.nothing;
obj.prop1 = 'no dice';
obj.prop2 = 'apples & bananas!';
obj.local = { 
history: [...obj.local.history, obj.local.current], 
current: [...obj.local.current, ...[1,2,3]] };
obj.local = { 
history: [...obj.local.history, obj.local.current], 
current: [...obj.local.current, obj.prop3] };
obj.local = { 
history: [...obj.local.history, obj.local.current], 
current: [...obj.local.current, ...[123, 321]] };
console.log(`obj.prop1: ${obj.prop1}`);
console.log(`obj.prop2 has a new value: ${obj.prop2}`);
console.log(`obj.local: ${JSON.stringify(obj.local)}`);

欢迎使用函数式编程!

一种解决方案是使用ES6类。getter返回属性的深度副本,setter抛出错误。

示例代码:

class Person {
_name = "";
constructor(name) {
this._name = name;
}
get name() {
return this.name;
}
set name(name) {
throw new Error("Can't reassign a Person's name");
}
}
class ImmutableArray {
_arr = [];
constructor(arr) {
this._arr = [...arr];
}
get arr() {
return [...this._arr];
}
set arr(arr) {
throw new Error("Can't reassign a ImmutableArray");
}
}
const aPerson = new Person("jiho");
aPerson.name = "W3Dojo"; // Error: Can't reassign a Person's name
const aImmutableArray = new ImmutableArray([1, 2, 3]);
aImmutableArray.arr = [2]; // Error: Can't reassign a ImmutableArray
const arr = aImmutableArray.arr;
arr[2] = 20;
console.log(aImmutableArray.arr[2]); // 3

在这种方法中,类内的属性是不可变的。

了解更多信息

MDN中的私有类字段,但它处于第3阶段。

最新更新