变量赋值与原始/对象可变性
如果您不熟悉 JavaScript 变量赋值与原始/对象可变性的工作原理,您可能会发现自己遇到无法解释的 bug。下面我们来看看它到底如何工作。
JavaScript 数据类型
JavaScript 数据类型分为基本数据类型(值/原始类型),引用(对象)数据类型。
-
JavaScript 有七种基本数据类型:
String
、Number
、Boolean
、Null
、Undefined
、BigInt
(ES10 新增)、Symbol
(ES6 新增) -
JavaScript 还有一种对象数据类型:
Object
。其中包括最为熟知Array
、Math
、Function
等。
赋值、重新赋值和突变
赋值、重新赋值和突变是 JavaScript 中需要了解和区分的重要概念。我们来看几个例子,理解它们。
赋值
let name = 'IU'
从右到左分析该表达式:
- 我们创建字符串
"IU"
- 我们创建变量
name
- 我们为变量
name
指定一个对先前创建的字符串的引用
因此,赋值可以看作是创建变量名并使该变量引用数据(无论是原始还是对象数据类型)的过程。
重新赋值
让我们扩展这个例子。首先,我们将给变量 name
分配一个对字符串 "IU"
的引用,然后我们将重新赋值该变量给一个对字符串 "UI"
的引用:
let name = 'IU'
name = 'UI'
分析
-
我们创建字符串
"IU"
-
我们创建变量
name
-
我们为变量
name
指定一个对先前创建的字符串的引用 -
我们创建字符串
"UI"
-
我们重新赋值变量
name
对字符串"UI"
的引用
突变(Mutation)
突变是改变数据的行为。值得注意的是,到目前为止,在我们的例子中,我们没有改变任何数据。
原始值突变
事实上,在上一个例子中,即使我们希望原始值不能突变(它们是不可变的),我们也无法改变任何数据。
让我们试着改变一个字符串,但失败的例子:
let name = 'IU'
name[0] = 'U'
console.log(name) // "IU"
显然,我们的突变尝试失败了。这是意料之中的:我们不能简单地改变原始数据类型。
对象突变
对于对象来说,我们可以简单的改变值:
let user = {
name: 'IU',
}
user.name = 'UI'
console.log(user) // { name: "UI" }
可以看到,成功的改变了值。但重要的是要记住,我们从来没有重新赋值过 user
变量,但是我们确实改变了它所指向的对象。
下面来看看为什么理解这些是很重要,我们来看看两个例子。
示例一:原始值
let name = 'IU'
let name2 = name
name2 = 'UI'
console.log(name, name2) // "IU" "UI"
分析
-
我们创建了字符串
"IU"
-
我们创建变量
name
并给它赋一个引用给字符串"IU"
-
我们创建了变量
name2
并给字符串"IU"
赋了一个引用 -
我们创建了字符串
"UI"
,并重新赋值name2
来引用该字符串 -
当我们
console.log
打印name
和name2
变量时,我们发现name
仍然引用"IU"
',name2
引用字符串"UI"
示例二:对象
let user = { name: 'UI' }
let user2 = user
user2.name = 'IU'
console.log(user, user2)
// { name: "IU" }
// { name: "IU" }
分析
-
我们创建对象
{name: "UI"}
-
我们创建
user
变量,并将其引用赋给已创建的对象 -
我们创建了
user2
变量,并将它设置为user
,它引用之前创建的对象。(注意:user2
现在引用的是user
所引用的同一个对象!) -
我们创建字符串
"IU"
,并通过重新分配name
属性来引用"IU"
来改变对象。 -
当我们使用
console.log
打印user
和user2
时,我们注意到内存中两个变量所引用的对象已经发生了变化。
真正的区别:可变性
如上所述,基本数据类型是不可变的。这意味着我们真的不必担心两个变量是否指向内存中的同一个原始值:哪个原始值不会改变。充其量,我们可以重新分配一个变量来指向其他数据,但这不会影响其他变量。
另一方面,对象是可变的。因此,我们必须记住,多个变量可能指向内存中的同一个对象。突变这些变量中的一个是错误的行为,你正在突变它所引用的对象,这将反映在引用同一对象的任何其他变量中。
如何防止这种情况发生?
在许多情况下,您不希望两个变量引用同一个对象。防止这种情况的最好方法是在赋值时创建对象的一个副本。
有两种方法可以创建对象的副本:使用 Object.assign()
方法和扩展运算符(...
)。
let user= { name: 'IU' }
// Object.assign
let user2= Object.assign({}, user)
// 扩展运算符
let user3= { ...user}
user2.name = 'LAY'
user3.name = 'KAI'
console.log(user, user2, user3)
// { name: "IU" }
// { name: "LAY" }
// { name: "KAI" }
可以看到,我们成功的创建了对象的副本。但需要注意的是:这并不是一种有效的方法,因为我们只是创建 user
对象的浅拷贝。
浅拷贝
如果我们的对象中嵌套了对象,那么像 object.assign
和扩展运算符(...
)这样的浅层复制机制将只创建根级对象的副本,但深层对象仍将被共享。举个例子:
let user = {
name: 'IU',
friend: {
name: 'LAY',
age: 18
},
}
let user2 = { ...user }
user2.name = 'UI'
user2.friend.name = 'KAI'
user2.friend.age = '19'
console.log(user)
console.log(JSON.stringify(user, null, 2))
console.log(JSON.stringify(user2, null, 2))
/*
{
"name": "IU",
"friend": {
"name": "KAI",
"age": "19"
}
}
{
"name": "UI",
"friend": {
"name": "KAI",
"age": "19"
}
}
*/
因此,我们复制顶级属性,但仍在共享对对象树中更深层对象的引用。如果这些较深的对象发生了突变,则在访问 user
或 user2
变量时会反映出来。
这时,我们就需要深度创建一个对象的副本。
深拷贝
有许多方法可以深拷贝 JavaScript 对象。这里我将介绍一个最简单的方法:JSON.stringify/JSON.parse
。
如果对象足够简单,可以使用 JSON.stringify
将其转换为字符串,然后使用 JSON.parse
将其转换回 JavaScript 对象。
let user = {
name: 'IU',
friend: {
name: 'LAY',
age: 18
},
}
let user2 = JSON.parse(JSON.stringify(user))
user2.friend.age = 19
console.log(JSON.stringify(user, null, 2))
console.log(JSON.stringify(user2, null, 2))
/*
{
"name": "IU",
"friend": {
"name": "LAY",
"age": 18
}
}
{
"name": "IU",
"friend": {
"name": "LAY",
"age": 19
}
}
*/
可以看到,我们成功了,但这是有局限性的。如果您的对象有任何不能用 JSON 字符串表示的数据(例如函数),那么这些数据将丢失!
还有其他更好的方法,我们在找时间在总结浅拷贝和浅拷贝都有哪几种实现方法。