作者:Dr. Axel Rauschmayer翻譯:瘋狂的技術宅
原文:https://2ality.com/2019/10/sh...
未經允許嚴禁轉載
本文回答了以下問題:
- 麼是共享可變狀態?
- 爲什麼會出現問題?
- 如何避免其問題?
標有“(高級)”的部分會更深入,如果你想更快地閱讀本文,可以跳過。
什麼是共享可變狀態,爲什麼會有問題?
共享可變狀態的解釋如下:
- 如果兩個或多個參與方可以更改相同的數據(變量,對象等),並且
- 如果它們的生命週期重疊,
則可能會有一方修改會導致另一方無法正常工作的風險。以下是一個例子:
function logElements(arr) {
while (arr.length > 0) {
console.log(arr.shift());
}
}
function main() {
const arr = ['banana', 'orange', 'apple'];
console.log('Before sorting:');
logElements(arr);
arr.sort(); // changes arr
console.log('After sorting:');
logElements(arr); // (A)
}
main();
// Output:
// 'Before sorting:'
// 'banana'
// 'orange'
// 'apple'
// 'After sorting:'
這裏有兩個獨立的部分:函數logElements()
和函數main()
。後者想要在對數組進行排序的前後都打印其內容。但是它到用了 logElements()
,會導致數組被清空。所以 main()
會在A行輸出一個空數組。
在本文的剩餘部分,我們將介紹三種避免共享可變狀態問題的方法:
- 通過複製數據避免共享
- 通過無損更新來避免數據變動
- 通過使數據不可變來防止數據變動
針對每一種方法,我們都會回到剛纔看到的示例並進行修復。
通過複製數據避免共享
在開始研究如何避免共享之前,我們需要看一下如何在 JavaScript 中複製數據。
淺拷貝與深拷貝
對於數據,有兩個可複製的“深度”:
- 淺拷貝僅複製對象和數組的頂層條目。原始值和副本中的輸入值仍然相同。
- 深拷貝還會複製條目值的條目。也就是說,它會完整遍歷樹,並複製所有節點。
不幸的是,JavaScript 僅內置了對淺拷貝的支持。如果需要深拷貝,則需要自己實現。
JavaScript 中的淺拷貝
讓我們看一下淺拷貝的幾種方法。
通過傳播複製普通對象和數組
const copyOfObject = {...originalObject};
const copyOfArray = [...originalArray];
但是傳播有幾個限制:
- 不復制原型:
class MyClass {}
const original = new MyClass();
assert.equal(MyClass.prototype.isPrototypeOf(original), true);
const copy = {...original};
assert.equal(MyClass.prototype.isPrototypeOf(copy), false);
- 正則表達式和日期之類的特殊對象有未複製的特殊“內部插槽”。
- 僅複製自己的(非繼承)屬性。鑑於原型鏈的工作原理,這通常是最好的方法。但是你仍然需要意識到這一點。在以下示例中,
copy
中沒有original
的繼承屬性.inheritedProp
,因爲我們僅複製自己的屬性,而未保留原型。
const proto = { inheritedProp: 'a' };
const original = {__proto__: proto, ownProp: 'b' };
assert.equal(original.inheritedProp, 'a');
assert.equal(original.ownProp, 'b');
const copy = {...original};
assert.equal(copy.inheritedProp, undefined);
assert.equal(copy.ownProp, 'b');
- 僅複製可枚舉的屬性。例如數組實例自己的屬性
.length
不可枚舉,也不能複製:
const arr = ['a', 'b'];
assert.equal(arr.length, 2);
assert.equal({}.hasOwnProperty.call(arr, 'length'), true);
const copy = {...arr};
assert.equal({}.hasOwnProperty.call(copy, 'length'), false);
- 與 property 的 attributes無關,它的副本始終是可寫和可配置的 data 屬性,例如:
const original = Object.defineProperties({}, {
prop: {
value: 1,
writable: false,
configurable: false,
enumerable: true,
},
});
assert.deepEqual(original, {prop: 1});
const copy = {...original};
// Attributes `writable` and `configurable` of copy are different:
assert.deepEqual(Object.getOwnPropertyDescriptors(copy), {
prop: {
value: 1,
writable: true,
configurable: true,
enumerable: true,
},
});
這意味着,getter 和 setter 都不會被如實地被複制:value
屬性(用於數據屬性),get
屬性(用於 getter)和set
屬性(用於 setter)是互斥的。
const original = {
get myGetter() { return 123 },
set mySetter(x) {},
};
assert.deepEqual({...original}, {
myGetter: 123, // not a getter anymore!
mySetter: undefined,
});
- 拷貝很淺:該副本具有原始版本中每個鍵值條目的新版本,但是原始值本身不會被複制。 例如:
const original = {name: 'Jane', work: {employer: 'Acme'}};
const copy = {...original};
// Property .name is a copy
copy.name = 'John';
assert.deepEqual(original,
{name: 'Jane', work: {employer: 'Acme'}});
assert.deepEqual(copy,
{name: 'John', work: {employer: 'Acme'}});
// The value of .work is shared
copy.work.employer = 'Spectre';
assert.deepEqual(
original, {name: 'Jane', work: {employer: 'Spectre'}});
assert.deepEqual(
copy, {name: 'John', work: {employer: 'Spectre'}});
這些限制有的可以消除,而其他則不能:
- 我們可以在拷貝過程中爲副本提供與原始原型相同的原型:
class MyClass {}
const original = new MyClass();
const copy = {
__proto__: Object.getPrototypeOf(original),
...original,
};
assert.equal(MyClass.prototype.isPrototypeOf(copy), true);
另外,我們可以在副本創建後通過 Object.setPrototypeOf()
設置原型。
- 沒有簡單的方法可以通用地複製特殊對象。
- 如前所述,僅複製自己的屬性是功能而非限制。
-
我們可以用
Object.getOwnPropertyDescriptors()
和Object.defineProperties()
複製對象(操作方法稍後說明):- 他們考慮了所有屬性(而不僅僅是
value
),因此正確地複製了getters,setters,只讀屬性等。 - 用
Object.getOwnPropertyDescriptors()
檢索可枚舉和不可枚舉的屬性。
- 他們考慮了所有屬性(而不僅僅是
- 我們將在本文後面的內容中介紹深拷貝。
通過 Object.assign()
進行淺拷貝(高級)
Object.assign()
的工作原理就像傳播到對象中一樣。也就是說以下兩種複製方式大致相同:
const copy1 = {...original};
const copy2 = Object.assign({}, original);
使用方法而不是語法的好處是可以通過庫在舊的 JavaScript 引擎上對其進行填充。
不過 Object.assign()
並不完全像傳播。它在一個相對微妙的方面有所不同:它以不同的方式創建屬性。
-
Object.assign()
使用 assignment 創建副本的屬性。 - 傳播定義副本中的新屬性。
除其他事項外,assignment 會調用自己的和繼承的設置器,而 definition 不會(關於 assignment 與 definition 的更多信息)。這種差異很少引起注意。以下代碼是一個例子,但它是人爲設計的:
const original = {['__proto__']: null};
const copy1 = {...original};
// copy1 has the own property '__proto__'
assert.deepEqual(
Object.keys(copy1), ['__proto__']);
const copy2 = Object.assign({}, original);
// copy2 has the prototype null
assert.equal(Object.getPrototypeOf(copy2), null);
通過 Object.getOwnPropertyDescriptors()
和 Object.defineProperties()
進行淺拷貝(高級)
JavaScript 使我們可以通過屬性描述符創建屬性,這些對象指定屬性屬性。例如,通過 Object.defineProperties()
,我們已經看到了它。如果將該方法與 Object.getOwnPropertyDescriptors()
結合使用,則可以更加忠實地進行復制:
function copyAllOwnProperties(original) {
return Object.defineProperties(
{}, Object.getOwnPropertyDescriptors(original));
}
這消除了通過傳播複製對象的兩個限制。
首先,能夠正確複製自己 property 的所有 attribute。我們現在可以複製自己的 getter 和 setter:
const original = {
get myGetter() { return 123 },
set mySetter(x) {},
};
assert.deepEqual(copyAllOwnProperties(original), original);
其次,由於使用了 Object.getOwnPropertyDescriptors()
,非枚舉屬性也被複制了:
const arr = ['a', 'b'];
assert.equal(arr.length, 2);
assert.equal({}.hasOwnProperty.call(arr, 'length'), true);
const copy = copyAllOwnProperties(arr);
assert.equal({}.hasOwnProperty.call(copy, 'length'), true);
JavaScript 的深拷貝
現在該解決深拷貝了。首先我們將手動進行深拷貝,然後再研究通用方法。
通過嵌套傳播手動深拷貝
如果嵌套傳播,則會得到深層副本:
const original = {name: 'Jane', work: {employer: 'Acme'}};
const copy = {name: original.name, work: {...original.work}};
// We copied successfully:
assert.deepEqual(original, copy);
// The copy is deep:
assert.ok(original.work !== copy.work);
Hack:通過 JSON 進行通用深拷貝
儘管這是一個 hack,但是在緊要關頭,它提供了一個快速的解決方案:爲了對 `original
對象進行深拷貝”,我們首先將其轉換爲 JSON 字符串,然後再解析該它:
function jsonDeepCopy(original) {
return JSON.parse(JSON.stringify(original));
}
const original = {name: 'Jane', work: {employer: 'Acme'}};
const copy = jsonDeepCopy(original);
assert.deepEqual(original, copy);
這種方法的主要缺點是,我們只能複製具有 JSON 支持的鍵和值的屬性。
一些不受支持的鍵和值將被忽略:
assert.deepEqual(
jsonDeepCopy({
[Symbol('a')]: 'abc',
b: function () {},
c: undefined,
}),
{} // empty object
);
其他導致的例外:
assert.throws(
() => jsonDeepCopy({a: 123n}),
/^TypeError: Do not know how to serialize a BigInt$/);
實現通用深拷貝
可以用以下函數進行通用深拷貝:
function deepCopy(original) {
if (Array.isArray(original)) {
const copy = [];
for (const [index, value] of original.entries()) {
copy[index] = deepCopy(value);
}
return copy;
} else if (typeof original === 'object' && original !== null) {
const copy = {};
for (const [key, value] of Object.entries(original)) {
copy[key] = deepCopy(value);
}
return copy;
} else {
// Primitive value: atomic, no need to copy
return original;
}
}
該函數處理三種情況:
- 如果
original
是一個數組,我們創建一個新的 Array,並將original
的元素複製到其中。 - 如果
original
是一個對象,我們將使用類似的方法。 - 如果
original
是原始值,則無需執行任何操作。
讓我們嘗試一下deepCopy()
:
const original = {a: 1, b: {c: 2, d: {e: 3}}};
const copy = deepCopy(original);
// Are copy and original deeply equal?
assert.deepEqual(copy, original);
// Did we really copy all levels
// (equal content, but different objects)?
assert.ok(copy !== original);
assert.ok(copy.b !== original.b);
assert.ok(copy.b.d !== original.b.d);
注意,deepCopy()
僅解決了一個擴展問題:淺拷貝。而其他所有內容:不復制原型,僅部分複製特殊對象,忽略不可枚舉的屬性,忽略大多數屬性。
通常完全完全實現複製是不可能的:並非所有數據的都是一棵樹,有時你並不需要所有屬性,等等。
更簡潔的 deepCopy()
版本
如果我們使用 .map()
和 Object.fromEntries()
,可以使以前的 deepCopy()
實現更加簡潔:
function deepCopy(original) {
if (Array.isArray(original)) {
return original.map(elem => deepCopy(elem));
} else if (typeof original === 'object' && original !== null) {
return Object.fromEntries(
Object.entries(original)
.map(([k, v]) => [k, deepCopy(v)]));
} else {
// Primitive value: atomic, no need to copy
return original;
}
}
在類中實現深拷貝(高級)
通常使用兩種技術可以實現類實例的深拷貝:
-
.clone()
方法 - 複製構造函數
.clone()
方法
該技術爲每個類引入了一個方法 .clone()
,其實例將被深拷貝。它返回 this
的深層副本。以下例子顯示了可以克隆的三個類。
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
clone() {
return new Point(this.x, this.y);
}
}
class Color {
constructor(name) {
this.name = name;
}
clone() {
return new Color(this.name);
}
}
class ColorPoint extends Point {
constructor(x, y, color) {
super(x, y);
this.color = color;
}
clone() {
return new ColorPoint(
this.x, this.y, this.color.clone()); // (A)
}
}
A 行展示了此技術的一個重要方面:複合實例屬性值也必須遞歸克隆。
靜態工廠方法
拷貝構造函數是用當前類的另一個實例來設置當前實例的構造函數。拷貝構造函數在靜態語言(例如 C++ 和 Java)中很流行,你可以在其中通過 static 重載(static 表示它在編譯時發生)提供構造函數的多個版本。
在 JavaScript 中,你可以執行以下操作(但不是很優雅):
class Point {
constructor(...args) {
if (args[0] instanceof Point) {
// Copy constructor
const [other] = args;
this.x = other.x;
this.y = other.y;
} else {
const [x, y] = args;
this.x = x;
this.y = y;
}
}
}
這是使用方法:
const original = new Point(-1, 4);
const copy = new Point(original);
assert.deepEqual(copy, original);
相反,靜態工廠方法在 JavaScript 中效果更好(static 意味着它們是類方法)。
在以下示例中,三個類 Point
,Color
和 ColorPoint
分別具有靜態工廠方法 .from()
:
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
static from(other) {
return new Point(other.x, other.y);
}
}
class Color {
constructor(name) {
this.name = name;
}
static from(other) {
return new Color(other.name);
}
}
class ColorPoint extends Point {
constructor(x, y, color) {
super(x, y);
this.color = color;
}
static from(other) {
return new ColorPoint(
other.x, other.y, Color.from(other.color)); // (A)
}
}
在 A 行中,我們再次使用遞歸複製。
這是 ColorPoint.from()
的工作方式:
const original = new ColorPoint(-1, 4, new Color('red'));
const copy = ColorPoint.from(original);
assert.deepEqual(copy, original);
拷貝如何幫助共享可變狀態?
只要我們僅從共享狀態讀取,就不會有任何問題。在修改它之前,我們需要通過複製(必要的深度)來“取消共享”。
防禦性複製是一種在問題可能出現時始終進行復制的技術。其目的是確保當前實體(函數、類等)的安全:
- 輸入:複製(潛在地)傳遞給我們的共享數據,使我們可以使用該數據而不受外部實體的干擾。
- 輸出:在將內部數據公開給外部方之前複製內部數據,意味着不會破壞我們的內部活動。
請注意,這些措施可以保護我們免受其他各方的侵害,同時也可以保護其他各方免受我們的侵害。
下一節說明兩種防禦性複製。
複製共享輸入
請記住,在本文開頭的例子中,我們遇到了麻煩,因爲 logElements()
修改了其參數 arr
:
function logElements(arr) {
while (arr.length > 0) {
console.log(arr.shift());
}
}
讓我們在此函數中添加防禦性複製:
function logElements(arr) {
arr = [...arr]; // defensive copy
while (arr.length > 0) {
console.log(arr.shift());
}
}
現在,如果在 main()
內部調用 logElements()
不會再引發問題:
function main() {
const arr = ['banana', 'orange', 'apple'];
console.log('Before sorting:');
logElements(arr);
arr.sort(); // changes arr
console.log('After sorting:');
logElements(arr); // (A)
}
main();
// Output:
// 'Before sorting:'
// 'banana'
// 'orange'
// 'apple'
// 'After sorting:'
// 'apple'
// 'banana'
// 'orange'
複製公開的內部數據
讓我們從 StringBuilder
類開始,該類不會複製它公開的內部數據(A行):
class StringBuilder {
constructor() {
this._data = [];
}
add(str) {
this._data.push(str);
}
getParts() {
// We expose internals without copying them:
return this._data; // (A)
}
toString() {
return this._data.join('');
}
}
只要不使用 .getParts()
,一切就可以正常工作:
const sb1 = new StringBuilder();
sb1.add('Hello');
sb1.add(' world!');
assert.equal(sb1.toString(), 'Hello world!');
但是,如果更改了 .getParts()
的結果(A行),則 StringBuilder
會停止正常工作:
const sb2 = new StringBuilder();
sb2.add('Hello');
sb2.add(' world!');
sb2.getParts().length = 0; // (A)
assert.equal(sb2.toString(), ''); // not OK
解決方案是在內部 ._data
被公開之前防禦性地對它進行復制(A行):
class StringBuilder {
constructor() {
this._data = [];
}
add(str) {
this._data.push(str);
}
getParts() {
// Copy defensively
return [...this._data]; // (A)
}
toString() {
return this._data.join('');
}
}
現在,更改 .getParts()
的結果不再幹擾 sb
的操作:
const sb = new StringBuilder();
sb.add('Hello');
sb.add(' world!');
sb.getParts().length = 0;
assert.equal(sb.toString(), 'Hello world!'); // OK
通過無損更新來避免數據改變
我們將首先探討以破壞性方式和非破壞性方式更新數據之間的區別。然後將學習非破壞性更新如何避免數據改變。
背景:破壞性更新與非破壞性更新
我們可以區分兩種不同的數據更新方式:
- 數據的破壞性更新使數據被改變,使數據本身具有所需的形式。
- 數據的非破壞性更新創建具有所需格式的數據副本。
後一種方法類似於先複製然後破壞性地更改它,但兩者同時進行。
示例:以破壞性和非破壞性的方式更新對象
這就是我們破壞性地設置對象的屬性 .city
的方式:
const obj = {city: 'Berlin', country: 'Germany'};
const key = 'city';
obj[key] = 'Munich';
assert.deepEqual(obj, {city: 'Munich', country: 'Germany'});
以下函數以非破壞性的方式更改屬性:
function setObjectNonDestructively(obj, key, value) {
const updatedObj = {};
for (const [k, v] of Object.entries(obj)) {
updatedObj[k] = (k === key ? value : v);
}
return updatedObj;
}
它的用法如下:
const obj = {city: 'Berlin', country: 'Germany'};
const updatedObj = setObjectNonDestructively(obj, 'city', 'Munich');
assert.deepEqual(updatedObj, {city: 'Munich', country: 'Germany'});
assert.deepEqual(obj, {city: 'Berlin', country: 'Germany'});
傳播使 setObjectNonDestructively()
更加簡潔:
function setObjectNonDestructively(obj, key, value) {
return {...obj, [key]: value};
}
注意:setObject NonDestructively()
的兩個版本都進行了較淺的更新。
示例:以破壞性和非破壞性的方式更新數組
以下是破壞性地設置數組元素的方式:
const original = ['a', 'b', 'c', 'd', 'e'];
original[2] = 'x';
assert.deepEqual(original, ['a', 'b', 'x', 'd', 'e']);
非破壞性地更新數組要複雜得多。
function setArrayNonDestructively(arr, index, value) {
const updatedArr = [];
for (const [i, v] of arr.entries()) {
updatedArr.push(i === index ? value : v);
}
return updatedArr;
}
const arr = ['a', 'b', 'c', 'd', 'e'];
const updatedArr = setArrayNonDestructively(arr, 2, 'x');
assert.deepEqual(updatedArr, ['a', 'b', 'x', 'd', 'e']);
assert.deepEqual(arr, ['a', 'b', 'c', 'd', 'e']);
.slice()
和擴展使 setArrayNonDestructively()
更加簡潔:
function setArrayNonDestructively(arr, index, value) {
return [
...arr.slice(0, index), value, ...arr.slice(index+1)]
}
注意:setArrayNonDestructively()
的兩個版本都進行了較淺的更新。
手動深度更新
到目前爲止,我們只是淺層地更新了數據。讓我們來解決深度更新。以下代碼顯示瞭如何手動執行此操作。我們正在更改 name
和 employer
。
const original = {name: 'Jane', work: {employer: 'Acme'}};
const updatedOriginal = {
...original,
name: 'John',
work: {
...original.work,
employer: 'Spectre'
},
};
assert.deepEqual(
original, {name: 'Jane', work: {employer: 'Acme'}});
assert.deepEqual(
updatedOriginal, {name: 'John', work: {employer: 'Spectre'}});
實現通用深度更新
以下函數實現了通用的深度更新。
function deepUpdate(original, keys, value) {
if (keys.length === 0) {
return value;
}
const currentKey = keys[0];
if (Array.isArray(original)) {
return original.map(
(v, index) => index === currentKey
? deepUpdate(v, keys.slice(1), value) // (A)
: v); // (B)
} else if (typeof original === 'object' && original !== null) {
return Object.fromEntries(
Object.entries(original).map(
(keyValuePair) => {
const [k,v] = keyValuePair;
if (k === currentKey) {
return [k, deepUpdate(v, keys.slice(1), value)]; // (C)
} else {
return keyValuePair; // (D)
}
}));
} else {
// Primitive value
return original;
}
}
如果我們將 value
視爲要更新的樹的根,則 deepUpdate()
只會深度更改單個分支(A 和 C 行)。所有其他分支均被淺複製(B 和 D 行)。
以下是使用 deepUpdate()
的樣子:
const original = {name: 'Jane', work: {employer: 'Acme'}};
const copy = deepUpdate(original, ['work', 'employer'], 'Spectre');
assert.deepEqual(copy, {name: 'Jane', work: {employer: 'Spectre'}});
assert.deepEqual(original, {name: 'Jane', work: {employer: 'Acme'}});
非破壞性更新如何幫助共享可變狀態?
使用非破壞性更新,共享數據將變得毫無問題,因爲我們永遠不會改變共享數據。 (顯然,這只有在各方都這樣做的情況下才有效。)
有趣的是,複製數據變得非常簡單:
const original = {city: 'Berlin', country: 'Germany'};
const copy = original;
僅在必要時以及在我們進行無損更改的情況下,才進行 original
的實際複製。
通過使數據不變來防止數據改變
我們可以通過使共享數據不變來防止共享數據發生改變。接下來,我們將研究 JavaScript 如何支持不變性。之後,討論不可變數據如何幫助共享可變狀態。
背景:JavaScript 中的不變性
JavaScript 具有三個級別的保護對象:
-
Preventing extensions 使得無法向對象添加新屬性。但是,你仍然可以刪除和更改屬性。
- 方法:
Object.preventExtensions(obj)
- 方法:
-
Sealing 可以防止擴展,並使所有屬性都無法配置(大約:您無法再更改屬性的工作方式)。
- 方法:
Object.seal(obj)
- 方法:
-
Freezing 使對象的所有屬性不可寫後將其密封。也就是說,對象是不可擴展的,所有屬性都是隻讀的,無法更改它。
- 方法:
Object.freeze(obj)
- 方法:
有關更多信息,請參見 “Speaking JavaScript”。
鑑於我們希望對象是完全不變的,因此在本文中僅使用 Object.freeze()
。
淺層凍結
Object.freeze(obj)
僅凍結 obj 及其屬性。它不會凍結那些屬性的值,例如:
const teacher = {
name: 'Edna Krabappel',
students: ['Bart'],
};
Object.freeze(teacher);
assert.throws(
() => teacher.name = 'Elizabeth Hoover',
/^TypeError: Cannot assign to read only property 'name'/);
teacher.students.push('Lisa');
assert.deepEqual(
teacher, {
name: 'Edna Krabappel',
students: ['Bart', 'Lisa'],
});
實現深度凍結
如果要深度凍結,則需要自己實現:
function deepFreeze(value) {
if (Array.isArray(value)) {
for (const element of value) {
deepFreeze(element);
}
Object.freeze(value);
} else if (typeof value === 'object' && value !== null) {
for (const v of Object.values(value)) {
deepFreeze(v);
}
Object.freeze(value);
} else {
// Nothing to do: primitive values are already immutable
}
return value;
}
回顧上一節中的例子,我們可以檢查 deepFreeze()
是否真的凍結了:
const teacher = {
name: 'Edna Krabappel',
students: ['Bart'],
};
deepFreeze(teacher);
assert.throws(
() => teacher.name = 'Elizabeth Hoover',
/^TypeError: Cannot assign to read only property 'name'/);
assert.throws(
() => teacher.students.push('Lisa'),
/^TypeError: Cannot add property 1, object is not extensible$/);
不可變包裝器(高級)
用不可變的包裝器包裝可變的集合並提供相同的 API,但沒有破壞性的操作。現在對於同一集合,我們有兩個接口:一個是可變的,另一個是不可變的。當我們具有要安全的公開內部可變數據時,這很有用。
接下來展示了 Maps 和 Arrays 的包裝器。它們都有以下限制:
- 它們比較簡陋。爲了使它們適合實際中的使用,需要做更多的工作:更好的檢查,支持更多的方法等。
- 他們是淺拷貝。
map的不變包裝器
類 ImmutableMapWrapper
爲 map 生成包裝器:
class ImmutableMapWrapper {
constructor(map) {
this._self = map;
}
}
// Only forward non-destructive methods to the wrapped Map:
for (const methodName of ['get', 'has', 'keys', 'size']) {
ImmutableMapWrapper.prototype[methodName] = function (...args) {
return this._self[methodName](...args);
}
}
這是 action 中的類:
const map = new Map([[false, 'no'], [true, 'yes']]);
const wrapped = new ImmutableMapWrapper(map);
// Non-destructive operations work as usual:
assert.equal(
wrapped.get(true), 'yes');
assert.equal(
wrapped.has(false), true);
assert.deepEqual(
[...wrapped.keys()], [false, true]);
// Destructive operations are not available:
assert.throws(
() => wrapped.set(false, 'never!'),
/^TypeError: wrapped.set is not a function$/);
assert.throws(
() => wrapped.clear(),
/^TypeError: wrapped.clear is not a function$/);
數組的不可變包裝器
對於數組 arr
,常規包裝是不夠的,因爲我們不僅需要攔截方法調用,而且還需要攔截諸如 arr [1] = true
之類的屬性訪問。 JavaScript proxies 使我們能夠執行這種操作:
const RE_INDEX_PROP_KEY = /^[0-9]+$/;
const ALLOWED_PROPERTIES = new Set([
'length', 'constructor', 'slice', 'concat']);
function wrapArrayImmutably(arr) {
const handler = {
get(target, propKey, receiver) {
// We assume that propKey is a string (not a symbol)
if (RE_INDEX_PROP_KEY.test(propKey) // simplified check!
|| ALLOWED_PROPERTIES.has(propKey)) {
return Reflect.get(target, propKey, receiver);
}
throw new TypeError(`Property "${propKey}" can’t be accessed`);
},
set(target, propKey, value, receiver) {
throw new TypeError('Setting is not allowed');
},
deleteProperty(target, propKey) {
throw new TypeError('Deleting is not allowed');
},
};
return new Proxy(arr, handler);
}
讓我們包裝一個數組:
const arr = ['a', 'b', 'c'];
const wrapped = wrapArrayImmutably(arr);
// Non-destructive operations are allowed:
assert.deepEqual(
wrapped.slice(1), ['b', 'c']);
assert.equal(
wrapped[1], 'b');
// Destructive operations are not allowed:
assert.throws(
() => wrapped[1] = 'x',
/^TypeError: Setting is not allowed$/);
assert.throws(
() => wrapped.shift(),
/^TypeError: Property "shift" can’t be accessed$/);
不變性如何幫助共享可變狀態?
如果數據是不可變的,則可以共享數據而沒有任何風險。特別是無需防禦性複製。
非破壞性更新是對不變數據的補充,使其與可變數據一樣通用,但沒有相關風險。
用於避免共享可變狀態的庫
有幾種可用於 JavaScript 的庫,它們支持對不可變數據進行無損更新。其中流行的兩種是:
-
Immutable.js 提供了不變(版本)的數據結構,例如
List
,Map
,Set
和Stack
。 - Immer 還支持不可變性和非破壞性更新,但僅適用於普通對象和數組。
Immutable.js
在其存儲庫中,Immutable.js 的描述爲:
用於 JavaScript 的不可變的持久數據集,可提高效率和簡便性。
Immutable.js 提供了不可變的數據結構,例如:
List
-
Map
(不同於JavaScript的內置Map
) -
Set
(不同於JavaScript的內置Set
) Stack
- 等
在以下示例中,我們使用不可變的 Map
:
import {Map} from 'immutable/dist/immutable.es.js';
const map0 = Map([
[false, 'no'],
[true, 'yes'],
]);
const map1 = map0.set(true, 'maybe'); // (A)
assert.ok(map1 !== map0); // (B)
assert.equal(map1.equals(map0), false);
const map2 = map1.set(true, 'yes'); // (C)
assert.ok(map2 !== map1);
assert.ok(map2 !== map0);
assert.equal(map2.equals(map0), true); // (D)
說明:
- 在 A 行中,我們新創建了一個
map0
的不同版本map1
,其中true
映射到了'maybe'
。 - 在 B 行中,我們檢查更改是否爲非破壞性的。
- 在 C 行中,我們更新
map1
,並撤消在 A 行中所做的更改。 - 在 D 行中,我們使用 Immutable 的內置
.equals()
方法來檢查是否確實撤消了更改。
Immer
在其存儲庫中,Immer 庫 的描述爲:
通過更改當前狀態來創建下一個不可變狀態。
Immer 有助於非破壞性地更新(可能嵌套)普通對象和數組。也就是說,不涉及特殊的數據結構。
這是使用 Immer 的樣子:
import {produce} from 'immer/dist/immer.module.js';
const people = [
{name: 'Jane', work: {employer: 'Acme'}},
];
const modifiedPeople = produce(people, (draft) => {
draft[0].work.employer = 'Cyberdyne';
draft.push({name: 'John', work: {employer: 'Spectre'}});
});
assert.deepEqual(modifiedPeople, [
{name: 'Jane', work: {employer: 'Cyberdyne'}},
{name: 'John', work: {employer: 'Spectre'}},
]);
assert.deepEqual(people, [
{name: 'Jane', work: {employer: 'Acme'}},
]);
原始數據存儲在 people
中。 produce()
爲我們提供了一個變量 draft
。我們假設這個變量是 people
,並使用通常會進行破壞性更改的操作。 Immer 攔截了這些操作。代替變異draft
,它無損地改變 people
。結果由 modifiedPeople
引用。它是一成不變的。
致謝
- Ron Korvig 提醒我在 JavaScript 中進行深拷貝時使用靜態工廠方法,而不要重載構造函數。
擴展閱讀
-
傳播:
- “寫給不耐煩的程序員的JavaScript” 中的擴展爲對象字面量”一節
- “寫給不耐煩的程序員的JavaScript” 中的擴展爲數組字面量”一節
-
Property attributes:
- Speaking JavaScript 中的“Property Attributes and Property Descriptors”部分
- Speaking JavaScript 中的Protecting Objects 一節
-
原型鏈:
- “寫給不耐煩的程序員的JavaScript”中的“原型鏈”一節
- Speaking JavaScript 中的“Properties: Definition Versus Assignment” 部分
- “Speaking JavaScript” 中的“使用 JavaScript進行元編程”一節
本文首發微信公衆號:前端先鋒
歡迎掃描二維碼關注公衆號,每天都給你推送新鮮的前端技術文章
歡迎繼續閱讀本專欄其它高贊文章:
- 深入理解Shadow DOM v1
- 一步步教你用 WebVR 實現虛擬現實遊戲
- 13個幫你提高開發效率的現代CSS框架
- 快速上手BootstrapVue
- JavaScript引擎是如何工作的?從調用棧到Promise你需要知道的一切
- WebSocket實戰:在 Node 和 React 之間進行實時通信
- 關於 Git 的 20 個面試題
- 深入解析 Node.js 的 console.log
- Node.js 究竟是什麼?
- 30分鐘用Node.js構建一個API服務器
- Javascript的對象拷貝
- 程序員30歲前月薪達不到30K,該何去何從
- 14個最好的 JavaScript 數據可視化庫
- 8 個給前端的頂級 VS Code 擴展插件
- Node.js 多線程完全指南
- 把HTML轉成PDF的4個方案及實現