【JS】变量声明与赋值,引用、值传递与对象拷贝

夯实基础系列。虽然想整理之前测试相关的东西,不过最近看书有讲到这个,遂将之前收藏的博客、文章结合书里的讲解做了一些整理和例子来加深这块的印象。(今天也才把测试的看了没来得及整理)

ES6 为我们引入了 letconst 两种新的变量声明关键字,同时也引入了块作用域;本文首先介绍 ES6 中常用的三种变量声明方式,然后讨论了 JavaScript 按值传递的特性以及多种的赋值方式,最后介绍了复合类型拷贝的技巧

变量声明

在 JavaScript 中,基本的变量声明可以用 var 方式;JavaScript 允许省略 var,直接对未声明的变量赋值。也就是说,var a = 1 与 a = 1,这两条语句的效果相同。但是由于这样的做法很容易不知不觉地创建全局变量(尤其是在函数内部),所以建议总是使用 var 命令声明变量。在 ES6 中,对于变量声明的方式进行了扩展,引入了 let 与 const。var 与 let 两个关键字创建变量的区别在于, var 声明的变量作用域是最近的函数块;而 let 声明的变量作用域是最近的闭合块,往往会小于函数块。另一方面,以 let 关键字创建的变量虽然同样被提升到作用域头部,但是并不能在实际声明前使用;如果强行使用则会抛出 ReferenceError 异常。

var

var 是 JavaScript 中基础的变量声明方式之一,其基本语法为:

1
2
3
4
var x // Declaration and initialization
x = 'Hello World' // Assignment
// Or all in one
var y = 'Hello World'

ECMAScript 6 以前我们在 JavaScript 中并没有其他的变量声明方式,以 var 声明的变量作用于函数作用域中,如果没有相应的闭合函数作用域,那么该变量会被当做默认的全局变量进行处理。

1
2
3
4
5
function sayHello() {
var hello = 'Hello World'
return hello
}
console.log(hello)

像如上这种调用方式会抛出异常: ReferenceError: hello is not defined,因为 hello 变量只能作用于 sayHello 函数中,不过如果按照如下先声明全局变量方式再使用时,其就能够正常调用:

1
2
3
4
5
var hello = 'Hello World'
function sayHello() {
return hello
}
console.log(hello)

let

在 ECMAScript 6 中我们可以使用 let 关键字进行变量声明:

1
2
3
4
let x // Declaration and initialization
x = 'Hello World' // Assignment
// Or all in one
let y = 'Hello World'

let 关键字声明的变量是属于块作用域,也就是包含在 {} 之内的作用于。使用 let 关键字的优势在于能够降低偶然的错误的概率,因为其保证了每个变量只能在最小的作用域内进行访问。

1
2
3
4
5
6
7
var name = 'Peter'
if (name === 'Peter') {
let hello = 'Hello Peter'
} else {
let hello = 'Hi'
}
console.log(hello)

上述代码同样会抛出 ReferenceError: hello is not defined 异常,因为 hello 只能够在闭合的块作用域中进行访问,我们可以进行如下修改:

1
2
3
4
5
6
7
8
var name = 'Peter'
if (name === 'Peter') {
let hello = 'Hello Peter'
console.log(hello)
} else {
let hello = 'Hi'
console.log(hello)
}

我们可以利用这种块级作用域的特性来避免闭包中因为变量保留而导致的问题,譬如如下两种异步代码,使用 var 时每次循环中使用的都是相同变量;而使用 let 声明的 i 则会在每次循环时进行不同的绑定,即每次循环中闭包捕获的都是不同的 i 实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
for (let i = 0; i < 2; i++) {
setTimeout(() => {
console.log(`i:${i}`)
}, 0)
}
for (var j = 0; j < 2; j++) {
setTimeout(() => {
console.log(`j:${j}`)
}, 0)
}
let k = 0
for (k = 0; k < 2; k++) {
setTimeout(() => {
console.log(`k:${k}`)
}, 0)
}
// output
i: 0
i: 1
j: 2
j: 2
k: 2
k: 2

const

const 关键字一般用于常量声明,用 const 关键字声明的常量需要在声明时进行初始化并且不可以再进行修改,并且 const 关键字声明的常量被限制于块级作用域中进行访问。

1
2
3
4
5
6
7
8
9
10
11
12
13
function f() {
{
let x;
{
// okay, block scoped name
const x = "sneaky";
// error, const
x = "foo";
}
// error, already declared in block
let x = "inner";
}
}

JavaScript 中 const 限制的并非值不可变性;而是创建了不可变的绑定,即对于某个值的只读引用,并且禁止了对于该引用的重赋值,即如下的代码会触发错误:

1
2
3
const numbers = [1, 2, 3, 4, 6]
numbers = [7, 8, 9, 10, 11] // error: assignment to constant variable
console.log(numbers[4])

我们可以参考如下图片理解这种机制,每个变量标识符都会关联某个存放变量实际值的物理地址;所谓只读的变量即是该变量标识符不可以被重新赋值,而该变量指向的值还是可变的。

JavaScript 中存在着所谓的原始类型与复合类型,使用 const 声明的原始类型是值不可变的:

1
2
3
4
5
6
7
8
9
10
11
# Example 1
const a = 10
a = a + 1 // error: assignment to constant variable
# Example 2
const isTrue = true
isTrue = false // error: assignment to constant variable
# Example 3
const sLower = 'hello world'
const sUpper = sLower.toUpperCase() // create a new string
console.log(sLower) // print hello world
console.log(sUpper) // print HELLO WORLD

而如果我们希望将某个对象同样变成不可变类型,则需要使用 Object.freeze();不过该方法仅对于键值对的 Object 起作用,而无法作用于 Date、Map 与 Set 等类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# Example 4
const me = Object.freeze({name: “Jacopo”})
me.age = 28
console.log(me.age) // print undefined
# Example 5
const arr = Object.freeze([-1, 1, 2, 3])
arr[0] = 0
console.log(arr[0]) // print -1
# Example 6
const me = Object.freeze({
name: 'Jacopo',
pet: {
type: 'dog',
name: 'Spock'
}
})
me.pet.name = 'Rocky'
me.pet.breed = 'German Shepherd'
console.log(me.pet.name) // print Rocky
console.log(me.pet.breed) // print German Shepherd

即使是 Object.freeze() 也只能防止顶层属性被修改,而无法限制对于嵌套属性的修改,这一点我们会在下文的浅拷贝与深拷贝部分继续讨论。

变量赋值

按值传递

JavaScript 中永远是按值传递(pass-by-value),只不过当我们传递的是某个对象的引用时,这里的值指的是对象的引用。按值传递中函数的形参是被调用时所传实参的副本。修改形参的值并不会影响实参。而按引用传递(pass-by-reference)时,函数的形参接收实参的隐式引用,而不再是副本。这意味着函数形参的值如果被修改,实参也会被修改。同时两者指向相同的值。

1
2
3
4
5
6
7
8
9
10
11
12
function changeStuff(a, b, c) {
a = a * 10;
b.item = "changed";
c = {item: "changed"};
}
var num = 10;
var obj1 = {item: "unchanged"};
var obj2 = {item: "unchanged"};
changeStuff(num, obj1, obj2);
console.log(num); // 10
console.log(obj1.item); // changed
console.log(obj2.item); // unchanged

JavaScript 按值传递就表现于在内部修改了 c 的值但是并不会影响到外部的 obj2 变量。如果我们更深入地来理解这个问题,JavaScript 对于对象的传递则是按共享传递的(pass-by-sharing,也叫按对象传递、按对象共享传递)。最早由Barbara Liskov. 在1974年的GLU语言中提出;该求值策略被用于Python、Java、Ruby、JS等多种语言。该策略的重点是:调用函数传参时,函数接受对象实参引用的副本(既不是按值传递的对象副本,也不是按引用传递的隐式引用)。 它和按引用传递的不同在于:在共享传递中对函数形参的赋值,不会影响实参的值。按共享传递的直接表现就是上述代码中的 obj1,当我们在函数内修改了 b 指向的对象的属性值时,我们使用 obj1 来访问相同的变量时同样会得到变化后的值。

连续赋值

JavaScript 中是支持变量的连续赋值,即譬如:

var a=b=1;

为了解释上述问题,我们引入一个新的变量:

1
2
3
4
5
var a = {n:1};
var b = a; // 持有a,以回查
a.x = a = {n:2};
alert(a.x);// --> undefined
alert(b.x);// --> [object Object]

实际上在连续赋值中,值是直接赋予给变量指向的内存地址:

a.x  =  a  = {n:2}
        │      │
{n:1}<──┘      └─>{n:2}

Deconstruction: 解构赋值

解构赋值允许你使用类似数组或对象字面量的语法将数组和对象的属性赋给各种变量。这种赋值语法极度简洁,同时还比传统的属性访问方法更为清晰。传统的访问数组前三个元素的方式为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
var first = someArray[0];
var second = someArray[1];
var third = someArray[2];
// 结构赋值
var [first, second, third] = someArray;
// === Arrays
var [a, b] = [1, 2];
console.log(a, b);//=> 1 2
// Use from functions, only select from pattern
var foo = () => {
return [1, 2, 3];
};
var [a, b] = foo();
console.log(a, b);// => 1 2
// Omit certain values
var [a, , b] = [1, 2, 3];
console.log(a, b);// => 1 3
// Combine with spread/rest operator (accumulates the rest of the values)
var [a, ...b] = [1, 2, 3];
console.log(a, b);// => 1 [ 2, 3 ]
// Fail-safe.
var [, , , a, b] = [1, 2, 3];
console.log(a, b);// => undefined undefined
// Swap variables easily without temp
var a = 1, b = 2;
[b, a] = [a, b];
console.log(a, b);// => 2 1
// Advance deep arrays
var [a, [b, [c, d]]] = [1, [2, [[[3, 4], 5], 6]]];
console.log("a:", a, "b:", b, "c:", c, "d:", d);// => a: 1 , b: 2, c: [ [ 3, 4 ], 5 ] , d: 6
// === Objects
var {user: x} = {user: 5};
console.log(x);// => 5
// Fail-safe
var {user: x} = {user2: 5};
console.log(x);// => undefined
// More values
var {prop: x, prop2: y} = {prop: 5, prop2: 10};
console.log(x, y);// => 5 10
// Short-hand syntax
var { prop, prop2} = {prop: 5, prop2: 10};
console.log(prop, prop2);// => 5 10
// Equal to:
var { prop: prop, prop2: prop2} = {prop: 5, prop2: 10};
console.log(prop, prop2);// => 5 10
// Oops: This doesn't work:
var a, b;
{ a, b } = {a: 1, b: 2};
// But this does work
var a, b;
({ a, b } = {a: 1, b: 2});
console.log(a, b);// => 1 2
// This due to the grammar in JS.
// Starting with { implies a block scope, not an object literal.
// () converts to an expression.
// From Harmony Wiki:
// Note that object literals cannot appear in
// statement positions, so a plain object
// destructuring assignment statement
// { x } = y must be parenthesized either
// as ({ x } = y) or ({ x }) = y.
// Combine objects and arrays
var {prop: x, prop2: [, y]} = {prop: 5, prop2: [10, 100]};
console.log(x, y);// => 5 100
// Deep objects
var {
prop: x,
prop2: {
prop2: {
nested: [ , , b]
}
}
} = { prop: "Hello", prop2: { prop2: { nested: ["a", "b", "c"]}}};
console.log(x, b);// => Hello c
// === Combining all to make fun happen
// All well and good, can we do more? Yes!
// Using as method parameters
var foo = function ({prop: x}) {
console.log(x);
};
foo({invalid: 1});// => undefined
foo({prop: 1});// => 1
// Can also use with the advanced example
var foo = function ({
prop: x,
prop2: {
prop2: {
nested: b
}
}
}) {
console.log(x, ...b);
};
foo({ prop: "Hello", prop2: { prop2: { nested: ["a", "b", "c"]}}}); // => Hello a b c
// In combination with other ES2015 features.
// Computed property names
const name = 'fieldName';
const computedObject = { [name]: name }; // (where object is { 'fieldName': 'fieldName' })
const { [name]: nameValue } = computedObject;
console.log(nameValue) // => fieldName
// Rest and defaults
var ajax = function ({ url = "localhost", port: p = 80}, ...data) {
console.log("Url:", url, "Port:", p, "Rest:", data);
};
ajax({ url: "someHost" }, "additional", "data", "hello");// => Url: someHost Port: 80 Rest: [ 'additional', 'data', 'hello' ]
ajax({ }, "additional", "data", "hello");// => Url: localhost Port: 80 Rest: [ 'additional', 'data', 'hello' ]
// Ooops: Doesn't work (in traceur)
var ajax = ({ url = "localhost", port: p = 80}, ...data) => {
console.log("Url:", url, "Port:", p, "Rest:", data);
};
ajax({ }, "additional", "data", "hello");
// probably due to traceur compiler
But this does:
var ajax = ({ url: url = "localhost", port: p = 80}, ...data) => {
console.log("Url:", url, "Port:", p, "Rest:", data);
};
ajax({ }, "additional", "data", "hello");
// Like _.pluck
var users = [
{ user: "Name1" },
{ user: "Name2" },
{ user: "Name2" },
{ user: "Name3" }
];
var names = users.map( ({ user }) => user );
console.log(names);// => [ 'Name1', 'Name2', 'Name2', 'Name3' ]
// Advanced usage with Array Comprehension and default values
var users = [
{ user: "Name1" },
{ user: "Name2", age: 2 },
{ user: "Name2" },
{ user: "Name3", age: 4 }
];
[for ({ user, age = "DEFAULT AGE" } of users) console.log(user, age)];
// => Name1 DEFAULT AGE
// => Name2 2
// => Name2 DEFAULT AGE
// => Name3 4

数组与迭代器

以上是数组解构赋值的一个简单示例,其语法的一般形式为:

[ variable1, variable2, ..., variableN ] = array;

事实上,用变量来描述并不恰当,因为你可以对任意深度的嵌套数组进行解构:

1
2
3
4
var [foo, [[bar], baz]] = [1, [[2], 3]];
console.log(foo);// 1
console.log(bar);// 2
console.log(baz);// 3

此外,你可以在对应位留空来跳过被解构数组中的某些元素:

var [,,third] = ["foo", "bar", "baz"];
console.log(third);  // "baz"

而且你还可以通过“不定参数”模式捕获数组中的所有尾随元素:

var [head, ...tail] = [1, 2, 3, 4];
console.log(tail);// [2, 3, 4]

当访问空数组或越界访问数组时,对其解构与对其索引的行为一致,最终得到的结果都是:undefined。

console.log([][0]); // undefined
var [missing] = [];
console.log(missing); // undefined

请注意,数组解构赋值的模式同样适用于任意迭代器:

1
2
3
4
5
6
7
8
9
10
function* fibs() {
var a = 0;
var b = 1;
while (true) {
yield a;
[a, b] = [b, a + b];
}
}
var [first, second, third, fourth, fifth, sixth] = fibs();
console.log(sixth); // 5

对象

通过解构对象,你可以把它的每个属性与不同的变量绑定,首先指定被绑定的属性,然后紧跟一个要解构的变量。

1
2
3
4
5
6
var robotA = { name: "Bender" };
var robotB = { name: "Flexo" };
var { name: nameA } = robotA;
var { name: nameB } = robotB;
console.log(nameA);// "Bender"
console.log(nameB);// "Flexo"

当属性名与变量名一致时,可以通过一种实用的句法简写:

var { foo, bar } = { foo: "lorem", bar: "ipsum" };
console.log(foo);    // "lorem"
console.log(bar);    // "ipsum"

与数组解构一样,你可以随意嵌套并进一步组合对象解构:

1
2
3
4
5
6
7
8
9
var complicatedObj = {
arrayProp: [
"Zapp",
{ second: "Brannigan" }
]
};
var { arrayProp: [first, { second }] } = complicatedObj;
console.log(first);// "Zapp"
console.log(second);// "Brannigan"

当你解构一个未定义的属性时,得到的值为undefined:

var { missing } = {};
console.log(missing);    // undefined

请注意,当你解构对象并赋值给变量时,如果你已经声明或不打算声明这些变量(亦即赋值语句前没有let、const或var关键字),你应该注意这样一个潜在的语法错误:

{ blowUp } = { blowUp: 10 };    // Syntax error 语法错误

为什么会出错?这是因为JavaScript语法通知解析引擎将任何以{开始的语句解析为一个块语句(例如,{console}是一个合法块语句)。解决方案是将整个表达式用一对小括号包裹:

({ safe } = {});    // No errors 没有语法错误

默认值

当你要解构的属性未定义时你可以提供一个默认值:

1
2
3
4
5
6
var [missing = true] = [];
console.log(missing);// true
var { message: msg = "Something went wrong" } = {};
console.log(msg);// "Something went wrong"
var { x = 3 } = {};
console.log(x);// 3

由于解构中允许对对象进行解构,并且还支持默认值,那么完全可以将解构应用在函数参数以及参数的默认值中。

1
2
3
function removeBreakpoint({ url, line, column }) {
// ...
}

当我们构造一个提供配置的对象,并且需要这个对象的属性携带默认值时,解构特性就派上用场了。举个例子,jQuery的ajax函数使用一个配置对象作为它的第二参数,我们可以这样重写函数定义:

1
2
3
4
5
6
7
8
9
10
11
jQuery.ajax = function (url, {
async = true,
beforeSend = noop,
cache = true,
complete = noop,
crossDomain = false,
global = true,
// ... 更多配置
}) {
// ... do stuff
};

同样,解构也可以应用在函数的多重返回值中,可以类似于其他语言中的元组的特性:

function returnMultipleValues() {
  return [1, 2];
}
var [foo, bar] = returnMultipleValues();

Three Dots …

es6新增的特性

Rest Operator

在 JavaScript 函数调用时我们往往会使用内置的 arguments 对象来获取函数的调用参数,不过这种方式却存在着很多的不方便性。譬如 arguments 对象是 Array-Like 对象,无法直接运用数组的 .map() 或者 .forEach() 函数;并且因为 arguments 是绑定于当前函数作用域,如果我们希望在嵌套函数里使用外层函数的 arguments 对象,我们还需要创建中间变量。

1
2
3
4
5
6
7
8
9
10
function outerFunction() {
// store arguments into a separated variable
var argsOuter = arguments;
function innerFunction() {
// args is an array-like object
var even = Array.prototype.map.call(argsOuter, function(item) {
// do something with argsOuter
});
}
}

ES6 中为我们提供了 Rest Operator 来以数组形式获取函数的调用参数,Rest Operator 也可以用于在解构赋值中以数组方式获取剩余的变量:

1
2
3
4
5
6
7
8
9
function countArguments(...args) {
return args.length;
}
// get the number of arguments
countArguments('welcome', 'to', 'Earth'); // => 3
// destructure an array
let otherSeasons, autumn;
[autumn, ...otherSeasons] = cold;
otherSeasons // => ['winter']

典型的 Rest Operator 的应用场景譬如进行不定数组的指定类型过滤:

function filter(type, ...items) {
  return items.filter(item => typeof item === type);
}
filter('boolean', true, 0, false);        // => [true, false]
filter('number', false, 4, 'Welcome', 7); // => [4, 7]

尽管 Arrow Function 中并没有定义 arguments 对象,但是我们仍然可以使用 Rest Operator 来获取 Arrow Function 的调用参数:

1
2
3
4
5
6
7
8
(function() {
let outerArguments = arguments;
const concat = (...items) => {
console.log(arguments === outerArguments); // => true
return items.reduce((result, item) => result + item, '');
};
concat(1, 5, 'nine'); // => '15nine'
})();

Spread Operator

Spread Operator 则与 Rest Opeator 的功能正好相反,其常用于进行数组构建与解构赋值,也可以用于将某个数组转化为函数的参数列表,其基本使用方式如下:

1
2
3
4
5
6
7
let cold = ['autumn', 'winter'];
let warm = ['spring', 'summer'];
// construct an array
[...cold, ...warm] // => ['autumn', 'winter', 'spring', 'summer']
// function arguments from an array
cold.push(...warm);
cold // => ['autumn', 'winter', 'spring', 'summer']

我们也可以使用 Spread Operator 来简化函数调用:

1
2
3
4
5
6
7
8
9
10
11
12
class King {
constructor(name, country) {
this.name = name;
this.country = country;
}
getDescription() {
return `${this.name} leads ${this.country}`;
}
}
var details = ['Alexander the Great', 'Greece'];
var Alexander = new King(...details);
Alexander.getDescription(); // => 'Alexander the Great leads Greece'

还有另外一个好处就是可以用来替换 Object.assign 来方便地从旧有的对象中创建新的对象,并且能够修改部分值;譬如:

1
2
3
4
5
6
var obj = {a:1,b:2}
var obj_new_1 = Object.assign({},obj,{a:3});
var obj_new_2 = {
...obj,
a:3
}

最后我们还需要讨论下 Spread Operator 与 Iteration Protocols,实际上 Spread Operator 也是使用的 Iteration Protocols 来进行元素遍历与结果搜集;因此我们也可以通过自定义 Iterator 的方式来控制 Spread Operator 的表现。Iterable 协议规定了对象必须包含 Symbol.iterator 方法,该方法返回某个 Iterator 对象:

1
2
3
4
5
6
interface Iterable {
[Symbol.iterator]() {
//...
return Iterator;
}
}

该 Iterator 对象从属于 Iterator Protocol,其需要提供 next 成员方法,该方法会返回某个包含 done 与 value 属性的对象:

1
2
3
4
5
6
7
8
9
interface Iterator {
next() {
//...
return {
value: <value>,
done: <boolean>
};
};
}

典型的 Iterable 对象就是字符串:

1
2
3
4
5
6
7
var str = 'hi';
var iterator = str[Symbol.iterator]();
iterator.toString(); // => '[object String Iterator]'
iterator.next(); // => { value: 'h', done: false }
iterator.next(); // => { value: 'i', done: false }
iterator.next(); // => { value: undefined, done: true }
[...str]; // => ['h', 'i']

我们可以通过自定义 array-like 对象的 Symbol.iterator 属性来控制其在迭代器上的效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function iterator() {
var index = 0;
return {
next: () => ({ // Conform to Iterator protocol
done : index >= this.length,
value: this[index++]
})
};
}
var arrayLike = {
0: 'Cat',
1: 'Bird',
length: 2
};
// Conform to Iterable Protocol
arrayLike[Symbol.iterator] = iterator;
var array = [...arrayLike];
console.log(array); // => ['Cat', 'Bird']

arrayLike[Symbol.iterator] 为该对象创建了值为某个迭代器的属性,从而使该对象符合了 Iterable 协议;而 iterator() 又返回了包含 next 成员方法的对象,使得该对象最终具有和数组相似的行为表现。

Copy Composite Data Types: 复合类型的拷贝

Shallow Copy 浅拷贝

浅拷贝是指复制时,指对第一层键值对进行独立的赋值,一个简单的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
function shallowCopy(target, source) {
if (!source || typeof source !== 'object') return
// 这个方法有点问题,target一定要事先定好,不然就不能改变实参了
// 具体原因解释可看参考资料中 JS是值传递还是引用传递
if (!target || typeof target !== 'object') return

// 这边最好区别一下对象和数组的复制
for (var key in source) {
if (source.hasOwnProperty(key)) {
target[key] = source[key]
}
}
}

Object.assign

Object.assign() 方法可以把任意多个源对象所拥有的自身可枚举属性拷贝给目标对象,然后返回目标对象。
Object.assign 方法只会拷贝 源对象自身的并且可枚举的属性 到目标对象身上。注意,对于访问器属性,该方法会执行那个访问器属性的 getter 函数,然后把得到的值拷贝给目标对象如果你想拷贝访问器属性本身,请使用 Object.getOwnPropertyDescriptorObject.difineProperties()方法

注意,字符串类型和 symbol 类型的属性都会被拷贝。

注意,在属性拷贝过程中可能会产生异常,比如目标对象的某个只读属性和源对象的某个属性同名,这时该方法会抛出一个 TypeError 异常,拷贝过程中断,已经拷贝成功的属性不会受到影响,还未拷贝的属性将不会再被拷贝。

注意, Object.assign 会跳过那些值为 null 或 undefined 的源对象。

Object.assign(target, ...sources)

浅拷贝一个对象

var obj = {a: 1}
var copy = Object.assign({}, obj)
console.log(copy) // {a:1}

合并若干个对象

1
2
3
4
5
6
var o1 = { a: 1 }
var o2 = { b: 2 }
var o3 = { c: 3 }
var obj = Object.assign(o1, o2, o3)
console.log(obj) // { a: 1, b: 2, c: 3 }
console.log(o1) // { a: 1, b: 2, c: 3 }, 注意目标对象自身也会改变。

拷贝 symbol 类型的属性

1
2
3
4
var o1 = { a: 1 }
var o2 = { [Symbol('foo')]: 2 }
var obj = Object.assign({}, o1, o2)
console.log(obj) // { a: 1, [Symbol("foo")]: 2 }

继承属性和不可枚举属性是不能拷贝的

1
2
3
4
5
6
7
8
9
10
11
12
13
var obj = Object.create(
{ foo: 1 },
{
// foo是个继承属性
bar: { value: 2 }, // bar是个不可枚举属性
baz: {
value: 3,
enumerable: true // baz是个自身可枚举属性
}
}
)
var copy = Object.assign({}, obj)
console.log(copy) // {baz: 3}

原始值会被隐式转换成其包装对象

1
2
3
4
5
6
7
8
9
10
var v1 = '123'
var v2 = true
var v3 = 10
var v4 = Symbol('foo')

var obj = Object.assign({}, v1, null, v2, undefined, v3, v4)
// 源对象如果是原始值,会被自动转换成它们的包装对象,
// 而 null 和 undefined 这两种原始值会被完全忽略。
// 注意,只有字符串的包装对象才有可能有自身可枚举属性。
console.log(obj) // { "0": "1", "1": "2", "2": "3" }

例子:拷贝属性过程中发生异常

1
2
3
4
5
6
7
8
9
10
11
12
var target = Object.defineProperty({}, 'foo', {
value: 1,
writeable: false
}) // target 的 foo 属性是个只读属性。
Object.assign(target, { bar: 2 }, { foo2: 3, foo: 3, foo3: 3 }, { baz: 4 })
// TypeError: "foo" is read-only
// 注意这个异常是在拷贝第二个源对象的第二个属性时发生的。
console.log(target.bar) // 2,说明第一个源对象拷贝成功了。
console.log(target.foo2) // 3,说明第二个源对象的第一个属性也拷贝成功了。
console.log(target.foo) // 1,只读属性不能被覆盖,所以第二个源对象的第二个属性拷贝失败了。
console.log(target.foo3) // undefined,异常之后 assign 方法就退出了,第三个属性是不会被拷贝到的。
console.log(target.baz) // undefined,第三个源对象更是不会被拷贝到的。

使用 [].concat 来复制数组

同样类似于对于对象的复制,我们建议使用[].concat 来进行数组的深复制:

ar list = [1, 2, 3];
var changedList = [].concat(list);
changedList[1] = 2;
list === changedList; // false

同样的,concat 方法也只能保证一层深复制:

> list = [[1,2,3]]
[ [ 1, 2, 3 ] ]
> new_list = [].concat(list)
[ [ 1, 2, 3 ] ]
> new_list[0][0] = 4
4
> list
[ [ 4, 2, 3 ] ]

浅拷贝的缺陷

不过需要注意的是,assign 是浅拷贝,或者说,它是一级深拷贝,举两个例子说明:

1
2
3
4
5
6
7
8
9
10
11
12
const defaultOpt = {
title: {
text: 'hello world',
subtext: "It's my world"
}
}
const opt = Object.assign({}, defaultOpt, {
title: {
subtext: 'Yes, your world'
}
})
console.log(opt) // { title: { subtext: 'Yes, your world.' } }

上面这个例子中,对于对象的一级子元素而言,只会替换引用,而不会动态的添加内容。那么,其实 assign 并没有解决对象的引用混乱问题,参考下下面这个例子:

1
2
3
4
5
6
7
8
9
10
11
const defaultOpt = {
title: {
text: 'hello world',
subtext: "It's my world."
}
}
const opt1 = Object.assign({}, defaultOpt);
const opt2 = Object.assign({}, defaultOpt);
opt2.title.subtext = 'Yes, your world.';
console.log(opt1); // { title: { text: 'hello world', subtext: 'Yes, your world.' } }
console.log(opt2); // { title: { text: 'hello world', subtext: 'Yes, your world.' } }

DeepCopy: 深拷贝

递归属性遍历

一般来说,在JavaScript中考虑复合类型的深层复制的时候,往往就是指对于Date、Object与Array这三个复合类型的处理。我们能想到的最常用的方法就是先创建一个空的新对象,然后递归遍历旧对象,直到发现基础类型的子节点才赋予到新对象对应的位置。不过这种方法会存在一个问题,就是JavaScript中存在着神奇的原型机制,并且这个原型会在遍历的时候出现,然后原型不应该被赋予给新对象。那么在遍历的过程中,我们应该考虑使用hasOenProperty方法来过滤掉那些继承自原型链上的属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function clone(obj) {
var copy
// 处理基本类型 null undefined
if (null === obj || 'object' !== typeof obj) return obj
// 若为 Date 对象时
if (obj instanceof Date) {
copy = new Date()
copy.setTime(obj.getTime())
return copy
}
// 处理数组
if (obj instanceof Array) {
copy = []
for (var i = 0, len = obj.length; i < len; i++) {
copy[i] = clone(obj[i])
}
return copy
}
// 处理对象
if (obj instanceof Object) {
copy = {}
for (var attr in obj) {
if (obj.hasOwnProperty(attr)) {
copy[attr] = clone(obj[arr])
}
}
return copy
}
throw new Error('Unable to copy obj! Its type isn\'t supported!')
}

调用如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// This would be cloneable:
var tree = {
"left" : { "left" : null, "right" : null, "data" : 3 },
"right" : null,
"data" : 8
};
// This would kind-of work, but you would get 2 copies of the
// inner node instead of 2 references to the same copy
var directedAcylicGraph = {
"left" : { "left" : null, "right" : null, "data" : 3 },
"data" : 8
};
directedAcyclicGraph["right"] = directedAcyclicGraph["left"];
// Cloning this would cause a stack overflow due to infinite recursion:
var cylicGraph = {
"left" : { "left" : null, "right" : null, "data" : 3 },
"data" : 8
};
cylicGraph["right"] = cylicGraph;

利用JSON深拷贝

JSON.parse(JSON.stringify(obj))

对于一般的需求是可以满足的,但是它有缺点,下例中,可以看到JSON赋值会忽略掉值为undefined以及函数表达式

1
2
3
4
5
6
7
8
9
10
var obj = {
a: 1,
b: 2,
c: undefined,
sum: function() {
return a + b
}
}
var obj2 = JSON.parse(JSON.stringify(obj))
console.log(obj2) // Object {a: 1, b: 2}

参考资料

基于 JSX 的动态数据绑定
ECMAScript 2017(ES8)特性概述
WebAssembly 初体验:从零开始重构计算模块

Author: Fridolph
Link: http://blog.fridolph.wang/2018/06/23/【JS】变量声明与赋值,引用、值传递与对象拷贝/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.