- A+
在本文中,我们将研究可能的构造,在这些构造中,禁止引用发生变异是有益的。
需要复习一下JavaScript中的不可变性吗?读我们的指南,JavaScript中的不可变性.
原语与引用类型
JavaScript定义了两个总体组数据类型:
- 原语:低级值不变(例如字符串、数字、布尔等)
- 引用:属性的集合,表示可标识的堆内存,它们是可变(例如,对象、数组、
1Map
(等等)
假设我们声明一个常量,并为此分配一个字符串:
1 const message = 'hello';
考虑到字符串是原语,因此是不可变的,我们无法直接修改这个值。它只能用于生产新的价值:
1
2 console.log(message.replace('h', 'sm')); // 'smello'
console.log(message); // 'hello'
尽管
1 | replace() |
上
1 | message |
我们没有修改它的记忆。我们只是创建了一个新的字符串,留下了
1 | message |
完好无损。
变异指数
1 | message |
默认情况下为不操作,但将引发
1 | TypeError |
在……里面严格模式:
1
2
3
4 'use strict';
const message = 'hello';
message[0] = 'j'; // TypeError: 0 is read-only
请注意,如果声明
1 | message |
使用
1 | let |
关键字,我们将能够替换它解析的值:
1
2 let message = 'hello';
message = 'goodbye';
重要的是要强调这是不突变。相反,我们将一个不变的值替换为另一个。
可变引用
让我们将原语的行为与引用进行对比。让我们用几个属性声明一个对象:
1
2
3
4 const me = {
name: 'James',
age: 29,
};
考虑到JavaScript对象是可变的,我们可以更改其现有属性并添加新属性:
1
2
3
4 me.name = 'Rob';
me.isTall = true;
console.log(me); // Object { name: "Rob", age: 29, isTall: true };
与原语不同,对象可以直接变异,而不需要被新引用替换。我们可以通过两个声明共享一个对象来证明这一点:
1
2
3
4
5
6
7
8
9
10 const me = {
name: 'James',
age: 29,
};
const rob = me;
rob.name = 'Rob';
console.log(me); // { name: 'Rob', age: 29 }
继承的JavaScript数组。
1 | Object.prototype |
,也是可变的:
1
2
3
4
5 const names = ['James', 'Sarah', 'Rob'];
names[2] = 'Layla';
console.log(names); // Array(3) [ 'James', 'Sarah', 'Layla' ]
可变引用有什么问题?
假设我们有一个可变的前五个数组斐波那契数:
1
2
3
4 const fibonacci = [1, 2, 3, 5, 8];
log2(fibonacci); // replaces each item, n, with Math.log2(n);
appendFibonacci(fibonacci, 5, 5); // appends the next five Fibonacci numbers to the input array
从表面上看,这段代码似乎是无害的,但因为
1 | log2 |
改变它接收到的数组,我们的
1 | fibonacci |
数组将不再像名称所暗示的那样独占地表示Fibonacci数。相反,
1 | fibonacci |
会变成
1 | [0, 1, 1.584962500721156, 2.321928094887362, 3, 13, 21, 34, 55, 89] |
...因此,人们可能会认为,这些声明的名称在语义上是不准确的,从而使程序流更难理解。
JavaScript中的伪不可变对象
虽然JavaScript对象是可变的,但我们可以利用特定的构造来深入克隆引用,即扩展语法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24 const me = {
name: 'James',
age: 29,
address: {
house: '123',
street: 'Fake Street',
town: 'Fakesville',
country: 'United States',
zip: 12345,
},
};
const rob = {
...me,
name: 'Rob',
address: {
...me.address,
house: '125',
},
};
console.log(me.name); // 'James'
console.log(rob.name); // 'Rob'
console.log(me === rob); // false
扩展语法也与数组兼容:
1
2
3
4
5
6 const names = ['James', 'Sarah', 'Rob'];
const newNames = [...names.slice(0, 2), 'Layla'];
console.log(names); // Array(3) [ 'James', 'Sarah', 'Rob' ]
console.log(newNames); // Array(3) [ 'James', 'Sarah', 'Layla' ]
console.log(names === newNames); // false
在处理引用类型时要考虑不断,可以使代码的行为更加清晰。重新查看以前可变的斐波纳契示例,我们可以通过复制来避免这种突变。
1 | fibonacci |
进入一个新的数组:
1
2
3
4
5 const fibonacci = [1, 2, 3, 5, 8];
const log2Fibonacci = [...fibonacci];
log2(log2Fibonacci);
appendFibonacci(fibonacci, 5, 5);
与其让消费者承担创建副本的负担,不如
1 | log2 |
和
1 | appendFibonacci |
将它们的投入视为只读,在此基础上创造新的产出:
1
2
3
4
5
6
7
8
9
10
11 const PHI = 1.618033988749895;
const log2 = (arr: number[]) => arr.map(n => Math.log2(2));
const fib = (n: number) => (PHI ** n - (-PHI) ** -n) / Math.sqrt(5);
const createFibSequence = (start = 0, length = 5) =>
new Array(length).fill(0).map((_, i) => fib(start + i + 2));
const fibonacci = [1, 2, 3, 5, 8];
const log2Fibonacci = log2(fibonacci);
const extendedFibSequence = [...fibonacci, ...createFibSequence(5, 5)];
通过编写函数以返回新的引用以利于它们的输入,由
1 | fibonacci |
声明保持不变,其名称仍然是上下文的有效来源。最终,这段代码更多确定性.
用纸盖住裂缝
有了一点自律,我们也许能够对引用采取行动,就好像它们完全是可读的,但是它们可以阻止其他地方发生的突变。有什么能阻止我们引入无赖声明来变异
1 | fibonacci |
在我们的应用程序的一个远程部分?
1 fibonacci.push(4);
ECMAScript 5
1 | Object.freeze() |
,它提供了一些抵御变异对象的防御措施:
1
2
3
4
5
6
7
8
9
10
11
12 'use strict';
const me = Object.freeze({
name: 'James',
age: 29,
address: {
// props from earlier example
},
});
me.name = 'Rob'; // TypeError: 'name' is read-only
me.isTheBest = true; // TypeError: Object is not extensible
不幸的是,它只浅显地禁止属性突变,因此嵌套对象仍然可以更改:
1
2
3 // No TypeErrors will be thrown
me.address.house = '666';
me.address.foo = 'bar';
可以在特定树的所有对象上调用此方法,但这很快就会被证明是难以处理的。也许我们可以利用类型记录的特性来实现编译时的不可变性。
用Const断言深深冻结文字表达式
在打字稿中,我们可以使用Const断言,扩展为类型断言,从文字表达式中计算深度只读类型:
1
2
3
4
5
6
7
8
9
10
11
12 const sitepoint = {
name: 'SitePoint',
isRegistered: true,
address: {
line1: 'PO Box 1115',
town: 'Collingwood',
region: 'VIC',
postcode: '3066',
country: 'Australia',
},
contentTags: ['JavaScript', 'HTML', 'CSS', 'React'],
} as const;
用
1 | as const |
结果在打字本的计算中,最具体的,只读类型它可以:
1
2
3
4
5
6
7
8
9
10
11
12 {
readonly name: 'SitePoint';
readonly isRegistered: true;
readonly address: {
readonly line1: 'PO Box 1115';
readonly town: 'Collingwood';
readonly region: 'VIC';
readonly postcode: '3066';
readonly country: 'Australia';
};
readonly contentTags: readonly ['JavaScript', 'HTML', 'CSS', 'React'];
}
换言之:
- 打开原语将被缩小到精确的文字类型。
1boolean
=>
1true)
- 对象文本将使用
1readonly
- 数组文字
1readonly
元组(例如:
1string[]=>
1['foo', 'bar', 'baz'])
试图添加或替换任何值将导致类型记录编译器引发错误:
1
2 sitepoint.isCharity = true; // isCharity does not exist on inferred type
sitepoint.address.country = 'United Kingdom'; // Cannot assign to 'country' because it is a read-only property
Const断言导致只读类型,这种类型本质上不允许调用任何将改变对象的实例方法:
1 sitepoint.contentTags.push('Pascal'); // Property 'push' does not exist on type 'readonly ["JavaScript", "HTML"...]
当然,使用不变的对象来反映不同的值的唯一方法是从它们创建新的对象:
1
2
3
4 const microsoft = {
...sitepoint,
name: 'Microsoft',
} as const;
不变函数参数
由于Const断言只是用于将特定声明键入为一组具有文字值的只读属性的语法糖,因此仍然可以在函数体中更改引用:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21 interface Person {
name: string;
address: {
country: string;
};
}
const me = {
name: 'James',
address: {
country: 'United Kingdom',
},
} as const;
const isJames = (person: Person) => {
person.name = 'Sarah';
return person.name === 'James';
};
console.log(isJames(me)); // false;
console.log(me.name); // 'Sarah';
可以通过注释
1 | person |
参数
1 | Readonly<Person> |
,但这只会影响对象的根级属性:
1
2
3
4
5
6
7
8 const isJames = (person: Readonly<Person>) => {
person.name = 'Sarah'; // Cannot assign to 'name' because it is a read-only property.
person.address.country = 'Australia'; // valid
return person.name === 'James';
};
console.log(isJames(me)); // false
console.log(me.address.country); // 'Australia'
没有内置的实用程序类型来处理深度不变性,但是考虑到类型记录3.7引入了更好地支持递归类型通过推迟他们的决议,我们现在可以表达无限递归型将属性表示为
1 | readonly |
整个物体的深度:
1
2
3 type Immutable<T> = {
readonly [K in keyof T]: Immutable<T[K]>;
};
如果我们要描述
1 | person |
参数
1 | isJames() |
如
1 | Immutable<Person> |
,类型记录也将禁止我们变异嵌套对象:
1
2
3
4
5 const isJames = (person: Immutable<Person>) => {
person.name = 'Sarah'; // Cannot assign to 'name' because it is a read-only property.
person.address.country = 'Australia'; // Cannot assign to 'country' because it is a read-only property.
return person.name === 'James';
};
此解决方案也适用于深度嵌套数组:
1
2
3 const hasCell = (cells: Immutable<string[][]>) => {
cells[0][0] = 'no'; // Index signature in type 'readonly string[]' only permits reading.
};
尽管
1 | Immutable<T> |
作为手动定义的类型,有正在进行的将深度阅读<T>引入类型记录的讨论,具有更精细的语义。
一个真实世界的例子
剩馀非常受欢迎的国家管理图书馆,需要对状态进行不变的处理。以确定商店是否需要更新。我们可能有类似于此的应用程序状态和操作接口:
1
2
3
4
5
6
7
8
9
10
11
12
13
14 interface Action {
type: string;
name: string;
isComplete: boolean;
}
interface Todo {
name: string;
isComplete: boolean;
}
interface State {
todos: Todo[];
}
如果状态已经更新,那么我们的还原器应该返回一个全新的引用,我们可以键入
1 | state |
争论
1 | Immutable<State> |
禁止任何修改:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21 const reducer = (
state: Immutable<State>,
action: Immutable<Action>,
): Immutable<State> => {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
todos: [
...state.todos,
{
name: action.name,
isComplete: false,
},
],
};
default:
return state;
}
};
不变性的额外好处
在本文的整个过程中,我们已经观察到如何不改变地处理对象,从而产生更清晰、更确定的代码。尽管如此,还有一些额外的优势值得提高。
用严格比较算子检测变化
在JavaScript中,我们可以使用严格比较算子 (
1 | === |
)以确定两个对象是否共享相同的引用。在前面的示例中,考虑一下我们的减速机:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15 const reducer = (
state: Immutable<State>,
action: Immutable<TodoAction>,
): Immutable<State> => {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
// deeply merge TODOs
};
default:
return state;
}
};
由于只有在计算了已更改的状态时才创建新的引用,因此可以推断严格的引用相等表示未更改的对象:
1
2
3
4
5
6
7 const action = {
...addTodoAction,
type: 'NOOP',
};
const newState = reducer(state, action);
const hasStateChanged = state !== newState;
通过严格的引用等式检测更改比深入比较两个对象树更简单,后者通常涉及递归。
参考记忆计算
作为将引用和对象表达式视为一对一关系(即单个引用表示一组确切的属性和值)的必然结果,我们可以通过引用来回溯潜在的昂贵计算。如果我们想要添加一个数组,其中包含斐波纳契序列的头2000个数字,我们可以使用高阶函数和一个
1 | WeakMap |
可预测地缓存特定引用上的操作结果:
1
2
3
4
5
6
7
8
9
10
11
12
13
14 const memoise = <TArg extends object, TResult>(func: Function) => {
const results = new WeakMap<TArg, TResult>();
return (arg: TArg) =>
results.has(arg) ? results.get(arg) : results.set(arg, func(arg)).get(arg);
};
const sum = (numbers: number[]) => numbers.reduce((total, x) => total + x, 0);
const memoisedSum = memoise<number[], number>(sum);
const numbers = createFibSequence(0, 2000);
console.log(memoisedSum(numbers)); // Cache miss
console.log(memoisedSum(numbers)); // Cache hit