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

简介: 深入理解作用域和闭包(下)

变量作用域


在JavaScript中声明变量的关键字有:varletconst,不同关键字声明出来的变量,作用域大不相同,接下来我们来逐步分析下它们的作用域。


函数作用域


使用var声明变量时,变量会被自动添加到最接近的上下文。在函数中,最接近的上下文就是函数的局部上下文。


如果变量未声明直接初始化,那么它就会自动添加到全局上下文。


我们举个例子来验证下上述话语:


function getResult(readingVolume, likes) {
  var total = readingVolume + likes;
  globalResult = total;
  return total;
}
let result = getResult(200, 2);
console.log("globalResult = ", globalResult); // 202
console.log(total); // ReferenceError: total is not defined


上述代码中:


  • 我们声明了一个名为getResult的函数,接受两个参数
  • 函数内部使用var声明了一个名为total的变量,并赋值为两个参数之和。
  • 在函数内部,我们还直接初始化了一个名为globalResult的变量,并赋值为total的变量值
  • 最后,返回total的值。


我们调用getResult函数,传递参数2002,随后,打印globalResulttotal的值,我们发现globalResult的值正常打印出来了,total则会报错未定义,执行结果与上述话语完全吻合。


执行结果如下:


640.png

                              image-20210320204933307  


使用var声明会被拿到函数或全局作用域的顶部,位于作用域中所有代码之前,这个现象就叫变量提升


变量提升会导致同一作用域的代码可以在声明前使用,我们举个例子来验证下,如下所示:


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


上述代码:


  • 我们先打印了name变量,然后才使用var关键词进行了声明,打印的值为undefined
  • 随后,我们声明了一个名为getName的函数,在函数内部先答应name变量,随后才声明,答应的值为getName
  • 最后,调用getName方法。


无论是在全局上下文还是函数上下文中,我们在声明前调用一个变量它的值为undefined,没有报错就证明了var声明变量会造成变量提升。


块级作用域


使用let关键字声明的变量,会有自己的作用域块,它的作用域是块级的,块级作用域由最近的一对的花括号{}届定。也就是说,ifwhileforfunction的块内部用let声明的变量,它的作用域都界定在{}内部,甚至单独的块,在其内部用let声明变量,它的作用域也是界定在{}内部。


我们举个例子来验证下:


let result = true;
if (result) {
  let a;
}
console.log(a); // ReferenceError: a is not defined
while (result) {
  let b;
  result = false;
}
console.log(b); // ReferenceError: b is not defined
function foo() {
  let c;
}
console.log(c); // ReferenceError: c is not defined
{
  let d;
}
console.log(d); // ReferenceError: a is not defined


上述代码中,我们在if、while、function、以及单独的{}内都声明了变量,在块外部调用其内部的变量时都会报错ReferenceError: xx is not defined,除function外,如果我们在块内部使用var关键字去声明,那么在块外部就能正常访问到块内部的变量。


运行结果如下:


640.png

                                     image-20210320214600031


使用let声明变量时,同一个作用域内不能重复声明,如果重复则抛出SyntaxError错误。


我们举个例子来验证下:


let a = 10;
let a = 11;
console.log(a); // SyntaxError: Identifier 'a' has already been declared
var b = 10;
var b = 11;
console.log(b); // 11


上述代码中:


  • 我们使用let重复声明了两个同名变量a
  • 我们使用var重复声明了两个同名变量b


我们在打印a时,会报错SyntaxError: Identifier 'a' has already been declared


我们在打印b时,重复的var声明则会被忽略,哪个在后,结果就是哪个,所以值为11

注意⚠️:严格来讲,let声明的变量在运行时也会被提升,但是由于“暂时性死区”的缘故,实际上不能在声明之前使用let变量。因此从JavaScript代码的角度来

说,let的提升跟var是不一样的。


常量声明


使用const关键字声明的变量,必须赋予初始值,一经声明,在其生命周期的任何时候都不能再重新赋予新值。


我们举个例子来验证下:


const name = "神奇的程序员";
const obj = {};
obj.name = "神奇的程序员";
name = "大白";
obj = { name: "大白" };


上述代码中:


  • 我们使用const声明了两个变量nameobj
  • 为obj添加name属性,我们没有重新给obj赋值,因此它可以正常添加
  • 紧接着,我们给name赋了新值,此时就会报错TypeError: Assignment to constant variable.
  • 最后,我们给obj赋了新值,同样的也会报错。


运行结果如下:


640.png

                             image-20210320222904217


上述例子中使用const声明的obj可以修改它的属性,如果想让整个对象都不能修改,可以使用Object.freeze(),如下所示:


const obj1 = Object.freeze({ name: "大白" });
obj1.name = "神奇的程序员";
obj1.age = 20;
console.log(obj1.name);
console.log(obj1.age);


运行结果如下:


640.png

                                image-20210320223429928


注意⚠️:由于const声明暗示变量的值是单一类型且不可修改,JavaScript运行时编译器可以将其所有实例都替换成实际的值,而不会通过查询表进行变量查找(V8引擎就执行这种优化)。


变量的生存周期


接下来,我们来看下变量的生命周期。


  • 变量如果处在全局上下文中,如果我们不主动销毁,那么它的生存周期则是永久的。
  • 变量如果处在函数上下文中,它会随着函数调用的结束而被销毁。


我们举个例子来说明下:


var a = 10;
function getName() {
  var name = "神奇的程序员";
}


上述代码中:


  • 变量a处在全局上下文中,它的生存周期是永久的
  • 变量name处在函数上下文中,当getName执行完成后,name变量就会被销毁。


理解闭包


通过上述章节的分析,我们知道函数上下文中的变量会随着函数执行结束而销毁,如果我们通过某种方式让函数中的变量不让其随着函数执行结束而销毁,那么这种方式就称之为闭包


我们通过一个例子来讲解下:


var selfAdd = function() {
  var a = 1;
  return function() {
    a++;
    console.log(a);
  };
};
const addFn = selfAdd();
addFn(); // 打印2
addFn(); // 打印3
addFn(); // 打印4
addFn(); // 打印5


上述代码中:


  • 我们声明了一个名为selfAdd的函数
  • 函数内部定一个了一个变量a
  • 随后,在函数内部又返回了一个匿名函数的引用
  • 在匿名函数内部,它可以访问到selfAdd函数上下文中的变量
  • 我们在调用selfAdd()函数时,它返回匿名函数的引用
  • 因为匿名函数在全局上下文中被继续引用,因此它就有了不被销毁的理由。
  • 因此,这里就产生了一个闭包结构,selfAdd函数上下文中的变量生命就被延续了


接下来,我们通过一个例子来讲解下闭包的作用:


<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>学习闭包</title>
  <script type="text/javascript" src="js/index.js"></script>
</head>
<body>
<div>1</div>
<div>2</div>
<div>3</div>
<div>4</div>
<div>5</div>
</body>
</html>


window.onload = function() {
  const divs = document.getElementsByTagName("div");
  for (var i = 0; i < divs.length; i++) {
    divs[i].onclick = function() {
        alert(i);
      };
  }
};


上述代码中,我们获取了页面中的所有div标签,循环为每个标签绑定点击事件,由于点击事件是被异步触发的,当事件触发时,for循环早已结束,此时变量i的值已经是6,所以在div的点击事件函数中顺着作用域链从内到外查找变量i时,找到的值总是6。


我们的预想结果并非这样,此处我们可以借助闭包,把每次循环的i值都封闭起来,如下所示:


window.onload = function() {
  const divs = document.getElementsByTagName("div");
  for (var i = 0; i < divs.length; i++) {
    (function(i) {
      divs[i].onclick = function() {
        alert(i);
      };
    })(i);
  }
};


上述代码中:


  • 在for循环内部,我们用了一个自执行函数,把每次循环的i值都封闭起来
  • 当在事件函数中顺着作用域链查找变量i时,会先找到被封闭在闭包环境中的i
  • 代码中有5个div,因此这里的i分别就是0, 1, 2, 3, 4,符合了我们的预期


巧用块级作用域


在上述代码的for循环表达式中,使用var定义了变量i,我们在函数作用域章节讲过,使用var声明变量时,变量会被自动添加到最接近的上下文,此处变量i被提升到window.onload函数的上下文中,因此当我们每次执行for循环时,i的值都会被覆盖,同步代码执行完后,异步代码执行时,获取到的值就是覆盖后的值。


我们除了使用闭包解决上述问题,还可以let来解决,代码如下所示:


window.onload = function() {
  const divs = document.getElementsByTagName("div");
  for (let i = 0; i < divs.length; i++) {
    // let的隐藏作用域,可以理解成
    // {let i = 0}
  // {let i = 1}
    // {let i = 2}
    // {let i = 3}
    // {let i = 4}
    divs[i].onclick = function() {
      alert(i);
    };
  }
};


上述代码的for循环表达式中,我们使用let声明了变量i,我们在块级作用域章节讲过,使用let关键字声明的变量,会有自己的作用域块,所以在for循环表达式中使用let等价于在代码块中使用let,因此:


  • for (let i = 0; i < divs.length; i++)这段代码的括号之间,有一个隐藏的作用域
  • for (let i = 0; i < divs.length; i++) {循环体}在每次循环执行循环体之前,JS引擎会把i在循环体的上下文中重新声明并初始化一次


因为let在代码块中都有自己的作用域,所以在for循环中的表达式中使用let它的每一个值都会单独存在一个独立的作用域中不会被覆盖掉。


表层应用


接下来,我们通过几个例子来巩固下我们前面的所讲内容。


作用域提升


代码如下所示,我们在一个块内声明了一个函数foo(),初始化了一个foo变量,赋值为1。再次声明foo()函数,再次修改变量foo的值。


{
  function foo() {
    console.log(1111);
  }
  foo(); // 2222
  foo = 1;
  // 报错:此时foo的值已经是1了,而并非一个函数
  // console.log(foo());
  function foo() {
    console.log(2222);
  }
  foo = 2;
  console.log(foo); // 2
}
console.log(foo); // 1


上述代码中:


  • 在块内部,函数foo()声明了两次,由于JS引擎的默认行为函数会被提升,因此最终执行的是后者声明的函数
  • foo = 1属于直接初始化行为,它会自动添加到全局上下文。
  • 由于在块作用域内,foo是一个函数,在执行foo = 1时会开始找作用域链,在块作用域内找到了foo,因此将它赋值为了1。
  • 同样的,foo = 2也会开始找作用域链,在块作用域内找到了foo,因此将它赋值为了2。


综合上述,在块内给foo赋值时,它都优先在块作用域内找到了这个变量对象,并没有改变全局上下文中的foo,因此块外的console.log(foo)的值仍然是块内部第一次初始化时变量提升时的值。


执行上下文栈


接下来我们举个例子来巩固下执行上下文栈的知识,代码如下所示:


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


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


上述两段代码中,最后的执行结果都相同,不同之处在于:


  • 第一段代码,changeName()函数内部调用了f()函数并返回其执行结果
  • 第二段代码,changeName()函数内部直接返回了f函数的引用,形成了闭包结构。

它们在执行上下文栈的中的存储顺序也大不相同,我们先来分析下第一段代码:

  • 执行changeName()函数时,创建一个执行上下文,并将其压入上下文栈
  • changeName()函数内部调用了f()函数,创建一个执行上下文,并将其压入上下文栈
  • f()函数执行完毕,出栈
  • changeName()函数执行完毕,出栈


我们画个图来讲解下上述过程,如下所示:


640.png

                            image-20210322104014150


最后,我们分析下第二段代码:


  • 执行changeName()函数时,创建一个执行上下文,并将其压入上下文栈
  • changeName()函数执行完毕,出栈,返回f()函数引用
  • 执行f()函数时,创建一个执行上下文,并将其压入上下文栈
  • f()函数执行完毕,出栈

我们画个图来讲解下上述过程,如下所示:


640.png

                             image-20210322105200831


函数柯里化


函数柯里化是一种思想,它会把函数的结果缓存起来,它属于闭包的一种应用。


我们举个 未知参数求和 的例子来讲解下柯里化,代码如下所示:


function unknownSum() {
  // 存储每次函数调用时的参数
  let arr = [];
  const add = (...params) => {
    // 拼接新参数
    arr = arr.concat(params);
    return add;
  };
  // 对参数进行求和
  add.toString = function() {
    let result = 0;
    // 对arr中的元素进行求和
    for (let i = 0; i < arr.length; i++) {
      result += arr[i];
    }
    return result + "";
  };
  return add;
}
const result1 = unknownSum()(1, 6, 7, 8)(2)(3)(4);
console.log("result1 =", result1.toString());


未知参数求和:函数可以无限次调用,每次调用的参数都不固定。


上述代码中:


  • 我们声明了名为unknownSum()的函数
  • 函数内部声明了arr数组,用于保存每次传进来的参数
  • 函数内部实现了一个add函数,用于将传进来的参数数组传递拼接到arr数组
  • 函数内部重写了add函数的toString()方法,对arr数组进行了求和并返回结果
  • 最后,在函数内部返回add函数的引用,形成一个闭包结构


我们在调用unknownSum函数时,第一次调用()会返回add函数的引用,后续的调用()调用的都是add函数,参数传递给add函数后,由于闭包的缘故函数内部的arr变量并未销毁,因此add函数会把参数缓存到arr变量里。


最后调用add函数的toString方法,对arr内缓存的参数进行求和。


执行结果如下:


640.png

                                image-20210322112033471


代码地址


本文为《JS原理学习》系列的第3篇文章,本系列的完整路线请移步:JS原理学习 (1) 》学习路线规划

本系列文章的所有示例代码,请移步:js-learning


写在最后


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