比较JS中的原始值和引用值

理解为什么一些数据类型是值的拷贝而另一些是值的引用,这些不同在代码中是如何体现的,这些概念的混淆铸成无数bug的产生,快来一窥究竟计算机内存中到底发生了什么吧!

序言

本篇文章旨在提供一个非常使用的模型,更好的理解JavaScript中变量的某些有趣的行为。不需要你知道JavaScript引擎是如何实现这些模型的,因为每一种JavaScript引擎的工作原理也不尽相同,不过,该模型能准确清除的描述和解释清楚。

随着你的编程职业生涯的推进,你会更深层次的了解各种各样的数据值是怎么一回事。本篇将为你提供一个适用的模型,帮助你理解在JavaScript中各种变量的行为。

原始值和引用值

JavaScript有5种数据类型的赋值是以值得拷贝的方式:

① Boolean(布尔值)

② null(空值)

③ undefined (未定义值)

④ String(字符值)

⑤ Number(数值)

上述5种值成为原始类型(primitive types)的值。

JavaScript有3中数据类型的赋值是以引用的拷贝的方式:

① Array(数组)

② Function(函数/方法)

③ Object(对象)

这3中数据类型从技术上将都是对象类型,因此我们可以统称为他们为对象类型。

原始值

如果给一个变量赋值一个原始类型值,我们可以认为该变量包含这个原始值。

var x = 10;
var y = 'abc';
var z = null;

x 包含 10,y 包含 ‘abc’。为了巩固这一思想,我们可以形象的认为已经将这些变量和他们所代表的值按如下的方式存储在内存中。

当使用 = 将该变量赋值给其他变量值,其实是将值拷贝了一份并赋值给新的变量,因此原始值是通过值的拷贝来赋值的。

var x = 10;
var y = 'abc';

var a = x;
var b = y;

console.log(x, y, a, b);
// -> 10, 'abc', 10, 'abc'

现在,a 和 x 都包含 10,b 和 y 都包含 ‘abc’ 这个值,他们各自都是互相独立的:

改变其中一个变量的值不会影响到其他的变量的值,这些变量之间内有任何关联:

var x = 10;
var y = 'abc';

var a = x;
var b = y;

a = 5;
b = 'def';

console.log(x, y, a, b); // -> 10, 'abc', 5, 'def'

对象

将一个非原始值赋值给一个变量,相当于给这个变量赋值了一个引用类型的值,这个引用执行对象在内存中的地址,而这个变量实际上并不包含这个值。

对象在我们计算机的内存中的一小块地儿上存在着,当我们声明一个数组:

arr = []

我们就在内存中创建了一个数组,在变量 arr 中包含的其实是一个地址,通过该地址能定位到那个数组。

我们可以将 地址 看作是一个新的数据类型,这种数据类型就和数值或字符值一样。地址指向内存中的一小块地儿,这块地儿被地址引用。就像字符串是用双引号(’'或"")括起来表示,那么一个地址我们姑且就用一对尖括号括起来表示(<>)。

当我们赋值并使用一个引用类型的变量时,代码是这样书写的:

var arr = [];
arr.push(1);

对上方两行代码在内存中的样子的一种形象的表示法:

第一行代码:

第二行代码:

注意变量 arr 的值和地址是静态的,动态变化的是存储在内存中的数组,当我们使用变量arr做一些事情的时候,比如push一个值进去,JavaScript引擎能根据变量arr的值定位到在内存中存储该数组的地方,并向其中插入数据。

引用的赋值

当一个引用类型,比如一个对象,使用 = 将其赋值给另一个变量,实际上是代表那个对象的地址引用赋值给了新的变量,就和是原始值的拷贝一样:

对象的拷贝是通过引用的拷贝完成的,而不是将对象拷贝了一份。

对象本身在拷贝过程中是没有变动的。唯一拷贝的是引用——指向对象的地址引用。

var reference = [1];
var refCopy = reference;

上面的代码在内存中的样子可以想象成是这样的:

这两个变量都包含指向同一数组的地址引用,他们有相同的地址。这就意味着如果我们改变其中一个变量(的地址引用指向的对象),另一个变量(的地址引用指向的同一个对象)也将改变。

reference.push(2);
console.log(reference, refCopy);
// -> [1,2],[1,2]

我们将 2 push进一个内存里的一个数组中,当我们使用 reference 和 refCopy时,他们指向同一个数组。

引用的重新分配

重新分配一个引用替换掉旧有的引用

var obj = {first: 'reference'};

在内存中:

可以这样重新分配:

var obj = { first: 'reference' };
obj = { second: 'ref2' };

如此以来,存储在obj中的地址就会改变,第一个对象仍然存在与内存中,就像这样:

当一个对象没有了指向它的引用,就像上图中的地址#234那样,这时JavaScript会执行垃圾回收操作。这也就意味着程序员已经确定断开所有指向这个对象的引用,以后也将不会再使用这个对象了,因此JavaScript引擎可以安全的从内存中删除这个对象,另一方面,删除之后的对象也不能再借由任何变量使用了。

扩展知识

当我们在引用类型变量间使用相等(==)和全等(===)比较运算符时,他们检查的是引用。如果变量包含得地址引用相同,那么比较结果就为 true。

var arrRef = ['Hi!'];
var arrRef2 = arrRef;

console.log(arrRef === arrRef2); // -> true

如果两个变量是不同对象的引用,即使他们从字面看起来相同,比较结果也是 false。

var arrRef = ['Hi!'];
var arrRef2 = arrRef;

console.log(arrRef === arrRef2); // -> true

比较对象

如果我们有两个不同的对象,想知道他们所拥有的属性是否相同,这时简单的使用比较操作就显得很无用。我们不得不亲自写一个函数,检查两个对象的每一个属性,确保他们是相同的,对于两个数组的比较,我们需要一个函数贯穿这个数组检查每一项是否相同和数组各项的排列顺序是否相同。

向函数传递参数

当我们向函数传递原始值时,函数将这些值拷贝一份到形参变量,这种效果等同于使用 = 赋值。

var hundred = 100;
var two = 2;

function multiply(x, y) {
    // PAUSE
    return x * y;
}

var twoHundred = multiply(hundred, two);

在上面的例子中,我们将100赋值给hundred,当我们传递其给multiply函数时,变量x得到了该值100,这种值的拷贝类似我们使用 = 赋值。同样的,变量hundred的值不会受到任何影响,在程序进入函数执行上下文时,在执行到// PAUSE的位置时,实际上在内存中是这样的:

multiply 包含一个指向函数的地址的引用,其他的变量都包含原始值。

twoHundred是undefined,因为我们还没有将函数返回的值赋值给他。直到函数返回,twoHundred的值都是undefined。

纯函数

我们称那些不会对外部作用域产生任何副作用的函数为纯函数。只要函数只使用传入的原始值参数而不使用函数周围作用域的任何变量,那么他就是纯函数,他不会对函数外作用域的任何事物产生影响,所有在函数内部声明的变量都将在函数返回后被回收内存。

当给函数传入了一个对象的引用,我们是可以通过这个传入的对象改变函数周围作用域的状态的。如果给函数传入一个数组的引用,并且在函数内部直接对这个数组的引用进行更改,比如向其中push数据,那么函数外的作用域的变量会受到影响,产生副作用。在函数返回之后,这种副作用持久的存在与外部作用域,这会造成一些意想不到的后果,并且还不容易追踪问题根源。

很多内置的数组方法,包括 Array.map和Array.filter在内,他们都是纯函数,给这些函数传入一个数组的引用,在函数内部,会将该数组拷贝一份,并对副本数组进行操作,而不对传入的原始数组操作,这样就不会接触到最原始的数组,外部作用域也不会受到影响。函数会最终返回一个新的做过相应处理后的数组。

下面对比纯函数与脏函数:

function changeAgeImpure(person) {
    person.age = 25;
    return person;
}

var alex = {
    name: 'Alex',
    age: 30
};

var changedAlex = changeAgeImpure(alex);

console.log(alex); // -> { name: 'Alex', age: 25 }
console.log(changedAlex); // -> { name: 'Alex', age: 25 }

上方示例代码中,向脏函数传入一个对象的引用,并直接将其age属性改变为25,因为是直接作用在原始对象上,所以alex对象也被改变。当函数返回person对象,其实是和传入的对象拥有相同的引用值。也就是alex和alexChange包含相同的引用,返回person这个变量,并多此一举的将其又赋值给了新的变量。

在看以下纯函数的处理手段:

function changeAgePure(person) {
    var newPersonObj = JSON.parse(JSON.stringify(person));
    newPersonObj.age = 25;
    return newPersonObj;
}

var alex = {
    name: 'Alex',
    age: 30
};

var alexChanged = changeAgePure(alex);

console.log(alex); // -> { name: 'Alex', age: 30 }
console.log(alexChanged); // -> { name: 'Alex', age: 25 }

在这个函数中,使用JSON.stringify()方法将对象转换成字符串,随即由将序列化后字符串使用JSON.parse()解析成对象,执行这些操作就创建一个新的对象,这个新的对象拥有和原始对象相同的属性,但是和原始对象互不影响,相互独立的存在于内存中。

在新的对象上改变age属性,原始对象不会受到影响,这个函数就是纯函数。他不会影响其作用域以外的任何对象,即使是以引用赋值的形式传入到该函数内的对象,这个新创建的对象有必要返回并存储到一个新的变量中,否则会在函数返回后被回收内存。

自测

原始值 vs. 引用一直是面试中笔试环节出现频次较高的考题,尝试指出下方代码运行后的输出结果?

function changeAgeAndReference(person) {
    person.age = 25;
    person = {
      name: 'John',
      age: 50
    };

    return person;
}

var personObj1 = {
    name: 'Alex',
    age: 30
};

var personObj2 = changeAgeAndReference(personObj1);

console.log(personObj1); // -> ?
console.log(personObj2); // -> ?

这个函数首先改变了原始对象的age属性,然后重新为变量分配新的对象,并返回这个新对象。

console.log(personObj1); // -> { name: 'Alex', age: 25 }
console.log(personObj2); // -> { name: 'John', age: 50 }

需要记住的是,使用函数的形参重新分配对象的引用就好比使用 = 赋值。函数中的变量person包含指向personObj1的引用,因此初始化操作直接作用于那个对象上,一旦重新分配一个对象给person,就不会再对原始对象的产生副作用。

这次重新分配不会影响到personObj1指向的的外部作用域的对象,person拥有新的引用值,所以不会影响到personObj1。

抛开函数,一个等价代码可能是这样的:

var personObj1 = {
    name: 'Alex',
    age: 30
};

var person = personObj1;
person.age = 25;

person = {
    name: 'John',
    age: 50
};

var personObj2 = person;

console.log(personObj1); // -> { name: 'Alex', age: 25 }
console.log(personObj2); // -> { name: 'John', age: 50 }

唯一的不同是,当使用函数是,变量person会在函数返回后被回收。

请使用Github账号登录留言