前言
JavaScript中的变量是松散类型的,没有规则定义它必须包含什么数据类型,它的值和数据类型在执行期间是可以改变的。
这样的设计规则很强大,但是也会引发不少的问题,比如我们本文即将要讨论的作用域与闭包,欢迎各位感兴趣的开发者阅读本文。
原理解析
理解作用域与闭包之前,我们需要先来深入解析下变量。
变量的原始值与引用值
变量可以存储两种不同类型的数据:原始值与引用值
- 用 基础包装类型 创建的值就是原始值
- 用 引用类型 创建的值就是引用值
我们来看下基础包装类型与引用类型都有什么:
- 基础包装类型:Number、String、Boolean、Undefined、Null、Symbol、BigInt
- 引用类型:Array ,Function, Date, RegExp 等
在把一个值赋给变量时,JavaScript引擎必须确定这个值是 原始值 还是 引用值 :
- 保存 原始值 的变量是按值访问的,它保存在栈内存里。
- 保存 引用值 的变量是按引用访问的,它保存在堆内存里。
引用值就是保存在内存中的对象,JavaScript不允许直接访问内存位置,因此不能直接操作对象所在的内存空间。
在操作对象时,实际操作的是该对象的引用,所以保存引用值的变量是按引用访问的。
属性的操作
原始值和引用值的定义方式很类似,都是创建一个变量,然后给它赋值。
不过,在变量保存了这个值之后,可以对这个值做什么,则有着很大的区别。
- 引用值可以添加、修改、删除其属性和方法
- 原始值不能有属性与方法,只能修改其值本身
接下来,我们举个例子来验证下:
let person = {}; person.name = "神奇的程序员"; console.log(person.name); let person1 = ""; person1.name = "神奇的程序员"; console.log(person1.name);
上述代码中:
- 我们创建了一个名为
person
的空对象,它是引用值 - 随后,给
person
添加name属性并赋值 - 随后,打印
person.name
,和预想一样,会得到正确的结果 - 紧接着,我们创建了一个名为
person1
的空字符串,它是原始值 - 随后,我们给
person1
添加name属性并赋值 - 最后,打印
person1.name
,值为undefined
执行结果如下:
image-20210318235346264
注意⚠️:当我们使用基础包装类型来创建变量时,得到的值是对象,它是引用值,可以添加属性和方法。例如:
let person2 = new String(""); person2.name = "神奇的程序员"; console.log(person2.name);
值的复制
我们将一个变量的值复制到另一个变量时,JS引擎在处理原始值与引用值的时候也是不相同的,接下来我们就来具体分析下。
- 复制原始值时,它的值会被复制到新变量的位置。
- 复制引用值时,它的指针会被复制到新变量的位置。
我们通过一个例子来讲解下:
let age = 20; let tomAge = age; let obj = {}; let tomObj = obj; obj.name = "tom"; console.log(tomObj.name); // tom
上述代码中:
- 我们创建了一个变量,命名为
age
,赋值为20,它是一个原始值 - 随后,我们创建了一个名为
tomAge
的变量,将其赋值为age。 - 紧接着,我们创建了一个空对象,命名为
obj
。 - 随后,我们创建了名为
tomObj
的对象,将其赋值为obj。 - 随后,我们给
obj
添加了name
属性,赋值为tom
。 - 最后,我们打印
tomObj.name
,发现值为tom
。
我们先来分析下上述例子中的age
与tomAge
,tomAge = age
属于原始值复制,由于原始值是保存在栈内存的,所以它会在栈中新开启新区域,将age的值复制到新区域里,如下图所示:
image-20210319152045841
最后,我们来分析下上述例子中的obj
与tomObj
:
tomObj = obj
属于引用值复制。- 引用值是保存在堆内存里的,因此它复制过来的是指针。
上述示例代码中,obj与tomObj都指向了堆内存中的同一个位置,tomObj的指针指向了obj,在深入理解原型链与继承 文章中,我们知道对象是拥有原型链的,因此当我们向obj中添加了name属性,tomObj也会包含这个属性。
接下来,我们画个图来描述下上述话语:
image-20210319204337610
参数的传递
我们有了前两个章节的铺垫之后,接下来我们来分析下函数的参数是怎么传递的。
在JavaScript中所有函数的参数都是按值传递的,也就是说函数外的值会被复制到函数内部的参数中,这个复制机制与我们上个章节所讲一致。
- 按值传递参数时,值会被复制到一个局部变量,函数内部修改的是局部变量。
- 按引用传递参数时,值在内存中的位置会被保存在一个局部变量里。
我们通过一个例子先来验证下按值传递参数的规则,如下所示:
function add(num) { num++; return num; } let count = 10; const result = add(count); console.log(result); // 11 console.log(count); // 10
上述代码中:
- 首先,我们创建了一个名为
add
的函数,他接受一个参数num
- 在函数内部,对参数进行自增,然后将其返回。
- 紧接着,我们声明了一个名为
count
的变量并赋值为10。 - 调用
add
函数,声明result
变量来接收函数的返回值。 - 最后,打印result与count,结果分别为:
11
、10
我们在在调用add
函数时,传递了count
参数进去,在函数内部处理时,它会把count
的值复制一份到局部变量,在内部进行修改时,它改的就是复制过来的值,因此我们内部自增了num
不会影响到函数外面的count
变量。
运行结果如下:
image-20210319235807949
接下来,我们通过一个例子验证下按引用传递参数的规则,如下所示:
function setAge(obj) { obj.age = 10; obj = {}; obj.name = "神奇的程序员"; return obj; } let tom = {}; const result1 = setAge(tom); console.log("tom.age", tom.age); // 10 console.log("tom.name", tom.name); // undefined console.log("result1.age", result1.age); // undefined console.log("result1.name", result1.name); // 神奇的程序员
上述代码中:
- 我们创建了一个名为
setAge
的函数,它接受一个对象 - 在函数内部,为参数对象新增了一个name属性,将其赋值为10
- 随后,我们将参数对象赋值为一个空对象,又添加了一个name属性并赋值。
- 最后,返回参数对象。
- 紧接着,我们创建一个名为
tom
的空对象 - 随后,将tom对象当作参数传给
setAge
方法并调用,声明result1
变量来接收其返回值 - 最后,我们打印
tom
对象与result1
对象的属性,执行结果符合按引用传递参数的规则
我们在调用setAge
函数时,函数内部会把参数对象的引用拷贝一份到局部变量,此时参数对象的引用是指向函数外面的tom
对象的,我们往参数对象中添加age属性,函数外面的tom
对象也会被添加age属性。
当我们在函数内部将obj
赋值为一个空对象时,局部变量的对象引用就指向了这个空对象,它与函数外面的tom
对象也就断开了关联,所以我们添加了name属性,只会给新对象添加。
最后我们在函数内部返回的参数对象,它是指向一个新的地址的,自然就只有name属性。
所以,tom
对象里只有age
属性,result1
对象里只有name
属性。
运行结果如下:
image-20210320001519276
执行上下文与作用域
了解完变量之后,接下来我们来学习下执行上下文。
执行上下文在JavaScript
中是一个比较重要的概念,它采用栈作为数据结构,为了方便起见,本文简称它为上下文,它的规则如下:
- 变量或函数的上下文决定它们能访问哪些数据
- 每个上下文都会关联一个变量对象
- 这个上下文中定义的所有变量和函数都存在于变量对象上,无法通过代码访问
- 上下文在其所有代码都执行完毕后销毁
全局上下文
全局上下文指的就是最外层的上下文,它根据宿主环境决定,具体规则如下:
- 全局上下文在关闭网页或退出浏览器时销毁
- 全局上下文会根据不同的宿主环境变化,在浏览器中指的就是window对象
- 使用var定义的全局变量和函数都会出现在window对象上
- 使用let和const声明的全局变量与函数不会出现在window对象上
函数上下文
每个函数都有自己的上下文,接下来我们来看下函数的执行上下文规则:
- 函数开始执行时,它的上下文会被推入一个上下文栈中。
- 函数执行完成后,上下文栈会弹出该函数上下文。
- 将控制权归还给之前的执行上下文
- JS程序的执行流就是通过这个上下文栈来控制的
我们举个例子来说明下上下文栈:
function fun3() { console.log('fun3') } function fun2() { fun3(); } function fun1() { fun2(); } fun1();
JavaScript
开始解析代码时,最先遇到的是全局代码,所以在初始化的时候首先会往栈内压入一个全局执行上下文,整个应用程序结束时栈被清空。
当执行一个函数的时候,就会创建一个执行上下文,并且压入执行上下文栈,当函数执行完毕的时候,就会将函数的执行上下文从栈中弹出。
知道了上述概念后,我们回到上述代码中:
- 执行
fun1()
函数时,会创建一个上下文,将其压入执行上下文栈 fun1
函数内部又调用了fun2
函数,因此创建fun2
函数的上下文,将其压入上下文栈fun2
函数内部又调用了fun3
函数,因此创建fun3
函数的上下文,将其压入上下文栈fun3
函数执行完毕,出栈fun2
函数执行完毕,出栈fun1
函数执行完毕,出栈
我们画个图来理解下上述过程:
image-20210322100055908
作用域与作用域链
我们了解完上下文之后,接下来就可以轻松的理解作用域了。
执行上下文代码时,当前上下文可以访问到的变量集合就是作用域。
上下文代码在执行的时候,会创建变量对象的一个作用域链,这个作用域链决定了各种上下文的代码在访问变量和函数时的顺序。
代码正在执行的上下文的变量对象,始终位于作用域链的最前端,如果上下文是函数,则其活动对象用作变量对象。
活动对象最初只有一个默认变量:arguments
(全局上下文不存在),作用域链中的下一个变量对象来自包含上下文,再下一个对象来自再一个包含上下文。以此类推直至全局上下文。
全局上下文的变量对象,始终是作用域链的最后一个变量对象。
代码执行时的标识符解析是通过沿作用域链逐级搜索标识符名称完成的,搜索过程始终从作用链的最前端开始,逐级往后,直到找到标识符。(没找到标识符,则会报错)
接下来,我们通过一个例子来讲解下上述话语:
var name = "神奇的程序员"; function changeName() { console.log(arguments); name = "大白"; } changeName(); console.log(name); // 大白
上述代码中:
- 函数
changeName
的作用域链包含两个上下文对象:自身的函数上下文对象、全局上下文对象 arguments
处在自身的变量对象中,name
处在全局上下文的变量对象中- 我们可以在函数内部访问
arguments
与name
属性,就是因为可以通过作用域链找到它们。
执行结果如下:
image-20210320171253397
接下来,我们举个例子来讲解下作用域链的查找过程:
var name = "神奇的程序员"; function changeName() { let insideName = "大白"; function swapName() { let tempName = insideName; insideName = name; name = tempName; // 可以访问tempName、insideName、name } // 可以访问insideName、name swapName(); } // 可以访问name changeName(); console.log(name);
上述代码:
- 作用域链中包含三个上下文对象:
swapName
函数的上下文对象、changeName
函数的上下文对象、全局上下文对象 - 在
swapName
函数内部,我们可以访问三个上下文对象中定义的所有变量。 - 在
changeName
函数内部,我们可以访问它自身的上下文对象和全局上下文对象中定义的变量 - 在全局上下文中,我们就只能访问全局上下文中存在的变量。
通过上述例子的分析,我们知道了作用域链的查找是由内到外的,内部可以访问外部的变量,外部不可以访问内部的变量。
接下来,我们画个图来描述下上述例子的作用域链,如下所示:
image-20210320181607527
注意⚠️:函数参数被认为是当前上下文中的变量,因此它也跟上下文中的其他变量遵循相同的访问规则。