闭包不迷路:JS词法作用域与闭包的青春实战

56 阅读9分钟

# 闭包不迷路:JS词法作用域与闭包的青春实战

引言:JavaScript的底层机制简述

在开始深入探讨词法作用域、作用域链和闭包之前,让我们先简要了解一下JavaScript的底层工作机制。JavaScript是由V8引擎(由Google开发,现在由Google和Node.js社区维护)执行的,V8引擎将JavaScript代码编译为机器码并执行。在执行过程中,V8引擎维护着一个调用栈(Call Stack),每当函数被调用时,会创建一个执行上下文(Execution Context)并压入调用栈。

执行上下文包含两个关键部分:

  1. 变量环境(Variable Environment):存储函数声明和变量声明
  2. 词法环境(Lexical Environment):包含当前作用域的变量和函数

JavaScript采用词法作用域(Lexical Scope)机制,这意味着作用域由代码在编译阶段的函数声明位置决定,而不是在运行时决定。这与许多其他语言(如Python)的动态作用域不同。

词法作用域:函数的作用域在定义时就锁死了

1. 词法作用域的核心概念

词法作用域(Lexical Scope)是静态的作用域,由代码在编译阶段的函数声明位置决定。作用域链(Scope Chain)也称为词法作用域链,是静态的,只和函数声明的位置有关,在编译阶段就决定了,与函数调用没有关系。

2. 包租婆与斧头帮的故事

function bar(){
 console.log(myName);
}

function foo(){
 var myName = "斧头帮";
 bar();
 console.log(myName);
}

var myName = '包租婆';
foo();

执行结果

包租婆
斧头帮

看到这个结果我们不禁有个疑问:打印结果不应该是两个斧头帮吗?

分析

  • bar函数是在全局中定义的,它的词法作用域链指向了全局作用域
  • bar()被调用时,它会查找myName变量,首先在自己的作用域中查找,未找到,然后向上查找,直到全局作用域,找到'包租婆'
  • foo函数内部的myName'斧头帮',所以console.log(myName)输出'斧头帮'

关键点:函数的作用域在定义时就锁死了,不会因为其他函数的调用而改变。bar函数内部的myName始终指向全局作用域的myName,而不是foo函数内部的myName

3. 斧头帮的胜利

那我们想要bar函数中的console.log(myName)也输出'斧头帮'该怎么做呢? 来看下面这段代码

function foo1(){
 function bar1(){
   console.log(myName1);
 }
 var myName1 = "斧头帮";
 bar1();
 console.log(myName1);
}
var myName1 = '包租婆';
foo1();

执行结果

斧头帮
斧头帮

分析

  • bar1函数在foo1函数内部定义,所以它的词法作用域链指向了foo1的执行上下文
  • bar1()被调用时,它会先在自己的作用域中查找myName1,未找到,然后向上查找,找到foo1函数内部的myName1,即'斧头帮'
  • 这与前面的bar函数不同,bar函数在全局作用域定义,而bar1foo1内部定义,所以作用域链不同

关键点:词法作用域链指作用域是由代码中的函数声明的位置来决定的,而不是函数被调用的位置。

词法作用域链的深入理解:通过代码示例

function bar () { 
  var myName = "极客世界"; 
  let test1 = 100; 
  if (1) { 
    let myName = "Chrome 浏览器" 
    console.log(test) 
  }
}

function foo() { 
  var myName = "极客邦"; 
  let test = 2; 
  { 
    let test = 3; 
    bar() 
  } 
}

var myName = "极客时间";  
let test = 1; 
let myAge = 10;
foo();

执行结果

1

为什么结果会是1呢相信你在了解了上面所讲的词法作用域后一定明白了bar函数被定义在全局作用域中,当需要使用test时,会沿着创建的执行上下文逐层查找变量test,而这样的查找路径也就是作用域链

lQLPKHIJLY1ZLgPNAnrNBHawi45ov3eSr18JAvLiVN8GAA_1142_634.png 而 JS 在设计执行上下文的时候,给其加入了一个outer指针,它会指向当前执行上下文的外层 ,直到全局执行上下文,而全局执行上下文的outer指针指向NULL

lQLPJw6VUwAYGkPNA2DNBHawJeGjxRWBf8QJAu6GR8voAA_1142_864.png

闭包:函数记住并访问它被创建时的上下文

1. 闭包的定义

闭包(Closure)是JavaScript中一个高级概念,简单说:当一个函数可以记住并访问它被创建时的上下文,即使这个函数在外部执行,就形成了闭包。

简单说:闭包 = 函数 + 它创建时所处的环境(词法环境)

2. 通过代码理解闭包

function foo(){
  var myName = '极客时间';
  let test1 = 1;
  const test2 = 2;
  var innerBar = {
    getName: function(){
      console.log(test1);
      return myName;
    },
    setName: function(newName){
      myName = newName;
    }
  }
  return innerBar;
}

var bar = foo();
bar.setName('极客邦');
console.log(bar.getName());

执行结果

1
极客邦

分析

  • foo函数执行完毕后,其执行上下文应该从调用栈弹出
  • bar.setName('极客邦')bar.getName()仍然能够访问foo函数内部的变量myNametest1
  • 这是因为innerBar对象中的方法(getNamesetName)形成了闭包,它们"记住"了foo函数创建时的词法环境

3. 闭包形成条件

闭包形成的条件:

  1. 函数嵌套函数
  2. 被闭包的函数要在外部可以访问到(通常通过return返回)
  3. 被闭包的函数执行的时候能找到定义它的时候的执行上下文中的变量

4. 闭包发生时的调用栈与执行上下文变化

让我们详细分析3.js代码执行过程中调用栈和执行上下文的变化:

步骤1:全局执行上下文创建
  • 创建全局执行上下文(Global Execution Context)
  • 变量环境:barfoomyNametest1test2等变量
  • 词法环境:outernull
步骤2:调用foo函数
  • 创建foo函数的执行上下文并压入调用栈
  • 变量环境:myNametest1test2innerBar
  • 词法环境:outer指向全局执行上下文的词法环境
  • foo函数执行,创建innerBar对象
步骤3:返回innerBar
  • foo函数执行完毕,其执行上下文从调用栈弹出
  • innerBar对象中的方法(getNamesetName)仍然可以访问foo函数内部的变量,因为它们形成了闭包
  • 闭包的"背包":getNamesetName方法"背"着foo函数内部的词法环境,包括myNametest1
步骤4:调用bar.setName('极客邦')
  • 创建setName方法的执行上下文
  • 变量环境:newName
  • 词法环境:outer指向foo函数的词法环境(即使foo执行上下文已弹出)
  • myName被更新为'极客邦'
步骤5:调用bar.getName()
  • 创建getName方法的执行上下文
  • 变量环境:无
  • 词法环境:outer指向foo函数的词法环境
  • test1myName被访问,输出1'极客邦'

关键点:闭包的变量不会被垃圾回收,直到闭包被销毁。foo函数执行完毕后,其执行上下文从调用栈弹出,但innerBar对象中的方法仍然保留对foo函数词法环境的引用,所以myNametest1仍然存在于内存中。

简单来说就是foo 函数执行完后,其执行上下文从栈顶弹出了,但是由于返回的setNamegetName使用了foo函数内部的变量myNametest1,这两个变量依然在内存中,有点像给getNamesetName 方法背的一个专属背包。 这个背包闭包就叫做闭包,这个闭包里面的变量叫做自由变量

下面给你一张图助你理解

lQLPJwCC0KWlAbPNA03NBHawINj2y-qMdT0JAv8hJbJKAA_1142_845.png

闭包的深入分析

1. 闭包的实现机制

闭包的实现依赖于JavaScript的词法作用域执行上下文机制。当一个函数被创建时,它会保存一个指向其创建时的词法环境的引用,这个引用就是闭包。

在V8引擎中,闭包的实现是通过闭包对象(Closure Object)来实现的。当函数被创建时,V8引擎会为函数创建一个闭包对象,这个闭包对象包含了函数的代码和创建时的词法环境。

2. 闭包的内存管理

闭包会阻止垃圾回收器回收被闭包引用的变量。在刚刚的例子中,即使foo函数执行完毕,myNametest1仍然被innerBar对象中的方法引用,所以它们不会被垃圾回收。

注意:过度使用闭包可能会导致内存泄漏,因为被闭包引用的变量会一直存在于内存中,直到闭包被销毁。

闭包的常见误区

  1. 误解闭包的形成

    • 闭包不是因为函数被外部调用而形成的,而是因为函数在内部创建了引用外部作用域的函数
    • 例如,bar函数在全局作用域定义,所以它不能形成闭包,因为它没有引用外部作用域的变量
  2. 误解闭包的生命周期

    • 闭包的生命周期与闭包对象的生命周期一致,而不是与创建闭包的函数的生命周期一致
    • 一旦闭包对象被销毁,被闭包引用的变量才会被垃圾回收
  3. 过度使用闭包

    • 过度使用闭包会导致内存泄漏,因为被闭包引用的变量会一直存在于内存中
    • 应该在不需要时及时销毁闭包

最后提醒:在实际开发中,不要过度使用闭包,注意内存管理,避免不必要的内存泄漏。同时,理解词法作用域和作用域链,能帮助你更好地理解JavaScript的执行机制,写出更高效的代码。

总结

通过今天的深入学习,我们理解了:

  1. 词法作用域:函数的作用域在定义时就锁死了,不会因为其他函数的调用而改变
  2. 词法作用域链:作用域是由代码中函数声明的位置决定的,是静态的,只和函数声明的位置有关
  3. 闭包:当一个函数可以记住并访问它被创建时的上下文,即使这个函数在外部执行,就形成了闭包

闭包是JavaScript中一个强大而重要的概念,它基于词法作用域的理解形成。在实际开发中,正确理解和使用闭包可以帮助我们编写更优雅、更模块化的代码。

记住:闭包 = 函数 + 它创建时所处的环境(词法环境)。理解了这一点,你就能更好地掌握JavaScript的词法作用域和闭包机制。