从浏览器工作原理探闭包

时一个多月,终于学习完了早就应该掌握的浏览器工作原理知识[1],受益匪浅的地方主要在于两点:

  1. 从原理上理解了编程中常见的技巧及误区(知其然也知其所以然)
  2. 扩展了视野,能从更高更广的维度审视页面(百尺竿头更进一步)

闭包这个概念在前端眼中丝毫不陌生,倒不是因为它的使用率有多么高,而是它的运作和原理关联到了 ECMAScript 的多个概念,如作用域、作用域链、GC[2] 等,恰恰如此,它也是前端面试中必不可少的考点,同时也是被大家讲烂了的东西。那为什么我还要写?因为从底层的角度来窥探闭包的真面目简直不要太清晰😎。

作用域

要说闭包就要说到作用域链,而要说作用域链就要说说作用域了。

作用域指在程序中定义变量的区域,而这个区域决定了变量的生命周期。换句话说,作用域控制着变量和函数的可见性和生命周期

ES6 之后 JavaScript 有三种作用域:

1. 全局作用域

全局作用域即使用 var 定义的变量[3],在代码中任何地方都可以访问到,它的生命周期就是整个页面的生命周期。

2. 函数作用域

函数作用域就是定义在函数内部的变量或函数,而且定义的变量和函数只能在函数内部访问,函数执行结束后这些变量都会被销毁。

3. 块级作用域

块级作用域就是使用一对大括号包裹的区域,如函数、判断语句、循环语句等都是块级作用域,块级作用域中的变量和函数只能在其内部访问使用。而 ES6 之前只能使用 var 定义变量,并不支持块级作用域:

1
2
3
4
5
var a = 1;
{
  var a = 2;
}
console.log(a); // 输出2

ES6 中加入了 letconst 关键字,JavaScript 才终于有了块级作用域,才得以解决变量提升带来的种种问题✌:

1
2
3
4
5
var a = 1;
{
  let a = 2;
}
console.log(a); // 输出1

作用域链

在我最早学习 JavaScript 的时候看到了一个很贴切的例子,至今还躺在我的笔记本里,是这么解释作用域链的:

假如你家四世同堂,你、你爸爸、你爷爷、你太爷爷,这时候你需要一笔钱买辆车,当然你自己是没有的😑,于是你问你老爸要,你老爸没有,然后又问你爷爷要,你爷爷也没有,最后问你太爷爷要,你太爷爷也没有,那就是真没有了。

例子中需要的「钱」就是作用域中的变量,而一代一代的关系指的是内外嵌套的作用域关系,递进的链式结构即是作用域链。的确,对于最粗浅的函数嵌套函数式的作用域链来说以这个例子来辅助理解是合理的,但是如果碰到了这样的情况就需要结合底层原理解释了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
function bar() {
  console.log(myName);
}

function foo() {
  var myName = "Tom";
  bar();
}
var myName = "Zander";
foo();

来分析一下当执行到 bar() 函数时整段代码的调用栈[4]情况:

call-stack.png◎ 调用栈情况

可以看到执行到 bar() 函数内部时两个地方存在 myName 变量——foo() 函数的执行上下文[5]中和全局执行上下文中,那么 bar() 中要输出的 myName 应该选择哪个呢?

当代码中使用一个变量时,会先在当前的执行上下文中查找该变量,比如上例中会先在 bar() 函数内部查找是否存在 myName,显然是没有的。但是,在每个执行上下文的变量环境中都存在一个外部引用 outer,outer 总是指向当前执行上下文的外部执行上下文,如果在当前的执行上下文中没有找到需要的变量会沿着 outer 所指向的执行上下文继续查找,上例中的「外部执行上下文」即全局上下文,所以 myName 即为 Zander,而这个通过 outer 连接的链条就是作用域链

但为什么 bar() 是在 foo() 函数内部执行的还访问不到 foo() 函数中的变量呢?这是因为词法作用域的存在:作用域是由代码中函数声明的位置决定的,和函数在什么时候调用是无关的,按照声明时的结构,内部函数可以访问外部函数中的变量

为了更进一步理解,改变一下代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
{
  function bar() {
    console.log(myName);
  }

  function foo() {
    var myName = "Tom";
    bar();
  }
  let myName = "Bob";
}

var myName = "Zander";
foo();

这里通过一对大括号创建了一个块级作用域,块级作用域中通过 let 关键字又定义了一个 myName,此时,bar()foo() 的函数执行上下文中的 outer 指向的就是这个外部的块级作用域的执行上下文了,块级作用域的执行上下文中的 outer 才指向的是全局执行上下文,所以 bar() 的输出结果为 Bob(不信你删掉 let 那行试试结果是不是 Zander😛~)。

需要注意的是通过 letconst 定义的变量会存放于执行上下文的词法环境中,而在一个执行上下文中查找变量的规则是:先沿着词法环境的栈顶向下查询,找到则返回,找不到则继续在变量环境中查找

闭包

先来看段代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
function foo() {
  var name = 'Zander';
  function bar() {
    console.log(name);
  }
  return bar;
}

console.log(name); // 报错:name is not defined

var hello = foo();
hello(); // 输出:Zander

根据作用域及作用域链的概念就可知 foo() 函数外获取不到函数内定义的 name,所以第一个输出会报错,那为什么同样是在函数外部的 hello() 可以获取到 name?因为**闭包(closure)**的存在。

宏观角度

从宏观角度来看,产生闭包的本质有两点——词法作用域函数当作值传递,函数当作值传递很简单,就像上面代码中 foo() 函数将 bar() 这个内部函数当作值返回了,此时,这个返回的值就相当于一个可以访问这个函数【bar()】词法作用域中的变量【name】的通道,通过这个通道获取到的所有变量就是闭包

换言之,当一个外部函数返回一个内部函数后,即使外部函数执行结束了,内部函数中使用的外部函数的变量依然保存在内存中,把这些变量的集合就叫做闭包。

举个例子,我家花钱的方式是我妈让我去买菜,而且我家只买菜,也就是说如果别人想拿到我家的钱,就得我妈让我去买菜,然后从我这里拿到我家的钱。

「我家」就是一个局部作用域,「钱」就是作用域中的内部变量,外部只能通过「我」这个作用域中返回的函数来获取作用域中的内部变量。

微观角度

上面的文字可能还是比较晦涩,那就从底层原理的角度再分析,当执行到 return bar 时调用栈是这样的:

return-call-stack.png◎ 执行到 return bar 时的调用栈情况

词法作用域规定内部函数总是可以访问外部函数的变量,所以任何时候在 bar() 函数中都可以获取到 name,正因为如此,当把 foo() 函数的执行结果 bar 赋值给 hello 时,虽然 foo() 函数已经执行完毕,但其内的变量 name 并没有被销毁掉,仍可以通过 bar 直接或间接访问。foo() 函数执行完后调用栈情况:

closure-call-stack.png◎ foo 函数执行完后的调用栈情况

foo() 函数执行完后其执行上下文就会从调用栈弹出,但 name 变量还保存于内存中,特殊的是,只能通过 foo 返回的 bar 才能访问它。可以通过 Chrome 开发者工具的 Soures 面板打断点来查看闭包:

chrome-debug.png◎ Chrome 开发者工具中的闭包

闭包在作用域链中所处的位置也很明显:Local(当前执行上下文)➡️Closure(闭包)➡️Global(全局执行上下文)。

内存泄漏

与闭包常常一同谈起的还有一个词——内存泄漏,内存泄漏是指不再使用的变量没有及时地释放,内存没有合理地被回收。但其实在现代浏览器中闭包通常不会导致内存泄漏,只有滥用闭包使得很多变量都被保存在内存中无法释放才容易导致内存泄漏。

合理使用闭包,从容应对面试~


  1. 具体的学习内容是极客时间的浏览器工作原理与实践课程,需要资源请留言。

  2. Garbage Collecation,垃圾回收机制。

  3. 虽然不使用任何关键字定义的隐式全局变量也存在于全局作用域,但这种“野路子”并不在考虑范畴之内,原因参 MDN

  4. Call stack,用来管理函数调用关系的一种数据结构。

  5. 执行上下文是 JavaScript 执行一段代码时的运行环境。

updatedupdated2020-06-302020-06-30
docs: 删除"前言"标题
加载评论
点击刷新