深入理解作用域和闭包(上)

本文涉及的产品
云解析 DNS,旗舰版 1个月
全局流量管理 GTM,标准版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
简介: 深入理解作用域和闭包(上)

前言


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


执行结果如下:


640.png

                                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


我们先来分析下上述例子中的agetomAgetomAge = age属于原始值复制,由于原始值是保存在栈内存的,所以它会在栈中新开启新区域,将age的值复制到新区域里,如下图所示:


640.png

                                           image-20210319152045841


最后,我们来分析下上述例子中的objtomObj


  • tomObj = obj属于引用值复制。
  • 引用值是保存在堆内存里的,因此它复制过来的是指针。


上述示例代码中,obj与tomObj都指向了堆内存中的同一个位置,tomObj的指针指向了obj,在深入理解原型链与继承 文章中,我们知道对象是拥有原型链的,因此当我们向obj中添加了name属性,tomObj也会包含这个属性。


接下来,我们画个图来描述下上述话语:


640.png

                               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,结果分别为:1110


我们在在调用add函数时,传递了count参数进去,在函数内部处理时,它会把count的值复制一份到局部变量,在内部进行修改时,它改的就是复制过来的值,因此我们内部自增了num不会影响到函数外面的count变量。


运行结果如下:


640.png

                           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属性。


运行结果如下:


640.png

                                    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函数执行完毕,出栈


我们画个图来理解下上述过程:


640.png

                                image-20210322100055908


作用域与作用域链


我们了解完上下文之后,接下来就可以轻松的理解作用域了。


执行上下文代码时,当前上下文可以访问到的变量集合就是作用域


上下文代码在执行的时候,会创建变量对象的一个作用域链,这个作用域链决定了各种上下文的代码在访问变量和函数时的顺序。


代码正在执行的上下文的变量对象,始终位于作用域链的最前端,如果上下文是函数,则其活动对象用作变量对象。


活动对象最初只有一个默认变量:arguments(全局上下文不存在),作用域链中的下一个变量对象来自包含上下文,再下一个对象来自再一个包含上下文。以此类推直至全局上下文。


全局上下文的变量对象,始终是作用域链的最后一个变量对象。


代码执行时的标识符解析是通过沿作用域链逐级搜索标识符名称完成的,搜索过程始终从作用链的最前端开始,逐级往后,直到找到标识符。(没找到标识符,则会报错)

接下来,我们通过一个例子来讲解下上述话语:


var name = "神奇的程序员";
function changeName() {
  console.log(arguments);
  name = "大白";
}
changeName();
console.log(name); // 大白


上述代码中:


  • 函数changeName的作用域链包含两个上下文对象:自身的函数上下文对象、全局上下文对象
  • arguments处在自身的变量对象中,name处在全局上下文的变量对象中
  • 我们可以在函数内部访问argumentsname属性,就是因为可以通过作用域链找到它们。


执行结果如下:


640.png

                               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函数内部,我们可以访问它自身的上下文对象和全局上下文对象中定义的变量
  • 在全局上下文中,我们就只能访问全局上下文中存在的变量。


通过上述例子的分析,我们知道了作用域链的查找是由内到外的,内部可以访问外部的变量,外部不可以访问内部的变量。


接下来,我们画个图来描述下上述例子的作用域链,如下所示:


640.png

                                 image-20210320181607527


注意⚠️:函数参数被认为是当前上下文中的变量,因此它也跟上下文中的其他变量遵循相同的访问规则。

相关文章
|
10天前
|
存储 缓存 JavaScript
哪些情况适合使用块级作用域,哪些情况适合使用函数作用域?
【10月更文挑战第29天】块级作用域和函数作用域在不同的场景下各有优势,合理地选择和运用这两种作用域可以使JavaScript代码更加清晰、高效和易于维护。在实际开发中,需要根据具体的业务需求、代码结构和编程模式来决定使用哪种作用域,或者在适当的情况下结合使用两者,以达到最佳的编程效果。
|
2月前
|
Java
作用域
作用域
19 2
|
2月前
C 作用域详解
在 C 语言中,作用域决定了变量和函数的可见性和生命周期,包括块作用域、函数作用域、文件作用域和全局作用域。块作用域内的变量仅在块内有效,函数作用域内的变量在整个函数内有效,文件作用域内的全局变量和函数在整个文件内有效,而全局作用域内的变量和函数在整个程序运行期间有效。作用域的优先级遵循局部变量优先的原则,局部变量会遮蔽同名的全局变量。变量的生命周期分为局部变量(函数调用时创建和销毁)、全局变量(程序开始时创建和结束时销毁)以及静态变量(整个程序期间有效)。理解作用域有助于避免命名冲突和错误,提高代码的可读性和可维护性。
|
2月前
|
移动开发
浅谈H5闭包
浅谈H5闭包
|
6月前
|
自然语言处理 JavaScript 前端开发
深入理解作用域、作用域链和闭包
在 JavaScript 中,作用域是指变量在代码中可访问的范围。理解 JavaScript 的作用域和作用域链对于编写高质量的代码至关重要。本文将详细介绍 JavaScript 中的词法作用域、作用域链和闭包的概念,并探讨它们在实际开发中的应用场景。
|
设计模式 自然语言处理 JavaScript
一篇文章帮你真正理解javascsript作用域闭包
一篇文章帮你真正理解javascsript作用域闭包
85 0
|
存储 缓存 JavaScript
深入理解作用域和闭包(下)
深入理解作用域和闭包(下)
深入理解作用域和闭包(下)
|
自然语言处理 前端开发 JavaScript
作用域闭包
作用域闭包
88 0
|
自然语言处理 JavaScript 前端开发
作用域是什么
作用域是什么
121 0
|
存储 自然语言处理 JavaScript
作用域相关的知识点:闭包、执行上下文、LHS/RHS、词法作用域
作用域相关的知识点:闭包、执行上下文、LHS/RHS、词法作用域
131 0