ES6 变量声明与赋值:值传递、浅拷贝与深拷贝详解

ES6 变量声明与赋值:值传递、浅拷贝与深拷贝详解归纳于笔者的现代 JavaScript 开发:语法基础与实践技巧系列文章。本文首先介绍 ES6 中常用的三种变量声明方式,然后讨论了 JavaScript 按值传递的特性,最后介绍了复合类型拷贝的技巧;有兴趣的可以阅读下一章节 ES6 变量作用域与提升:变量的生命周期详解

变量声明与赋值

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

变量声明

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

var

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

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

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

let

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

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

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

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

const

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

JavaScript 中 const 关键字的表现于 C 中存在着一定差异,譬如下述使用方式在 JavaScript 中就是正确的,而在 C 中则抛出异常:

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

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

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

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

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

变量赋值

按值传递

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

而在 JavaScript 中,对比例子如下:

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

连续赋值

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

但是在连续赋值中,会发生引用保留,可以考虑如下情景:

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

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

Deconstruction: 解构赋值

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

而通过解构赋值的特性,可以变为:

数组与迭代器

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

这将为variable1到variableN的变量赋予数组中相应元素项的值。如果你想在赋值的同时声明变量,可在赋值语句前加入var、let或const关键字,例如:

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

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

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

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

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

对象

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

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

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

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

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

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

默认值

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

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

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

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

Three Dots

Rest Operator

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

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

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

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

Spread Operator

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

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

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

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

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

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

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

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

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

Shallow Copy: 浅拷贝

顶层属性遍历

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

Object.assign

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

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

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

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

  • 例子:浅拷贝一个对象

  • 例子:合并若干个对象

  • 例子:拷贝 symbol 类型的属性

  • 例子:继承属性和不可枚举属性是不能拷贝的

  • 例子:原始值会被隐式转换成其包装对象

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

使用 [].concat 来复制数组

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

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

浅拷贝的缺陷

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

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

DeepCopy: 深拷贝

递归属性遍历

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

调用如下:

利用 JSON 深拷贝

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

延伸阅读

1 收藏 评论

相关文章

可能感兴趣的话题



直接登录
跳到底部
返回顶部