从作用域到闭包:JS 的”俄罗斯套娃“到底套了几层?

172 阅读6分钟

前言

本文适合:

  1. 被闭包折磨了 10086 次仍没彻底明白的小伙伴;
  2. 准备面试,却说不清作用域链到底链了个啥的同学;
  3. 想写高性能代码,但担心内存泄漏的务实派。

作用域:变量的“户口本”

在 js 里,变量不是想在哪用就在哪用,他得先“上户口”——这就是作用域(在程序中定义变量的区域,该位置决定了变量的生命周期。通俗来讲,作用域就是变量和函数的可访问的范围,即作用域控制着变量和函数的可见性和生命周期)。

// 国家户口——全局作用域
var a = '中国'
function fn() {
// 省级户口——函数作用域
  var b = '江西'
  console.log(a);  // 读得到中国
}
fn()
console.log(b);   // 读不到江西

我们可以把国家户口比作全局作用域,把省级户口比作函数作用域,你是中国人那么你就有中国户口,祖籍在江西省那么就附带了江西户口,但是中国一共有 34 个省级行政区,你和别人说你是个中国人他不一定知道你是哪个省的,就像最后一行代码显示的结果是读不到,但是如果别人告诉你他是一个江西人,那么你一定知道他是中国人,就像第六行代码展示的这样能正常读到中国,这恰恰验证了作用域只能由内到外访问。
之前 ES6 只有 全局 和 函数 两级户口;现在 ES6 多了 块级 —— let / const

{
  let a = 1  // const
}

只要是由 {}let / const 组成,那他就会形成块级作用域,注意一定是和 let / const ,如果里面用的是 var 定义变量那么就不存在块级说法,为什么要把块级单拉出来讲呢?因为块级的话比较特殊,它在全局和函数作用域的访问基础上还加了一些规则,具体我们就交给代码来展示出来

function foo() {
  var a = 1
  let b = 2
  { 
    let b = 3
    var c = 4
    let d = 5
    console.log(a);   //  输出 1
    console.log(b);   //  输出 3
  }
  console.log(b);    //   输出 2
  console.log(c);    //   输出 4
  console.log(d);    //   直接报错
}
foo()

a 和 b 的输出完全符合我们上面讲的作用域只能由内到外访问,然而 c 和 d 的输出验证了上面黑体部分注意的说法。然而我们要讲的最重要的一点是 选择性死区 如下展示

 let b = 4
 { 
    console.log(b);    // 直接显示报错
    let b = 3
  }

上面代码最后输出会直接报错,这就是选择性死区,虽然作用域只能由内到外访问,但是在块级作用域内只要和外层作用域内有一样的变量声明那就不能向外访问。这就好比你家里有一个老婆,你现在在外面工作老婆没有在你身边,那你也不能在外面找一个女朋友对吧,这是不道德的。

作用域链:变量的“寻亲之旅”

v8 在当前作用域中查找一个变量,如果找不到,就会去上一级作用域中查找,再找不到,继续去上一级,层层往上,像极了寻亲节目,这种查找关系,就叫作用域链。话不多说上寻亲代码

function bar() {
  console.log(myName);
}
function foo() {
  var myName = '刘大婶'
  bar()
}
var myName = '李妈妈'
foo()

闭包4.png 你虽然一眼能看出输出值但是你不一定清楚 v8 悄悄咪咪都干了些什么?直接上图片

闭包3.png 每一段执行上下文用栈的方式存储,再用调用栈的方式取出,一个函数被编译时,一定会用一个 outer 指针来记录该作用域的外层作用域是谁,以此来上演寻亲节目,要知道该函数的外层作用域在哪里,就得看他的词法作用域(函数声明的位置)是哪个,很明显 foobar 函数都在全局作用域内,寻亲的最终结果也是李妈妈而不是刘大婶.

闭包:把“外部户口”揣兜里的“时空胶囊”

  1. 一个函数执行完毕后,它的执行上下文会从调用栈中被销毁
  2. 一个函数的内部函数一定有权利访问该外部函数中的变量(作用域的规则)
function foo() {
  var myname = '佳颖'
  var age = 18
  function bar() {
    console.log(myname);
  }
  return bar
}
var baz = foo()
baz()

闭包5.png

总结出来当调用一个外部函数中返回的内部函数后,即使外部函数已经执行结束,但是内部函数依然引用了外部函数中的变量,那么外部函数的执行上下文就不能被完全销毁,而是会保留一个集合,用来装内部函数需要引用的变量,我们把该集合称之为 闭包foo 函数先被调用,用完就从调用栈中被销毁,但是由于 myname 在内部函数还会被调用,所以只有 myname 被保存了起来,输出结果如下

闭包6.png
我们来一道题看看闭包能怎么用

var arr = []

for (var i = 1; i <= 5; i++) {
    arr.push(function() {
      console.log(i);
    })
  }
}
for (let n = 0; n < arr.length; n++) {
  arr[n]()
}

他的输出结果为 5 个 6 ,这是因为 var 只有 全局 和 函数 作用域,i 全程只有一个。 定时器回调执行时,循环已跑完,i 定格在 6 。那如果要保留每一次加上的 i 那该怎么办,跟着我来看

var arr = []
for (var i = 1; i <= 5; i++) {
  function foo(j) {
    arr.push(function() {
      console.log(j);
    })
  }
  foo(i)
}

for (let n = 0; n < arr.length; n++) {
  arr[n]()
}

这就是你想要的 12345 答案,这就是用了闭包,把每一次的 i 保留了下来。 当然能解决某些事,我们把它理解为其优点:定义私有变量,封装模块;同样的他也有缺点:内存泄漏。我们可以理解为闭包过多导致调用栈的内存满了而导致爆栈。

结语

让我们把今天的旅程再回溯一遍,像把套娃一个个重新装回盒子:

  1. 作用域是最外层的那只大娃娃,它先划定边界,告诉变量你只属于这里;
  2. 当我们打开它,里面掉出第二只娃娃——作用域链,它把外层边界串成一条寻亲路线图,让引擎总能找到回家的路;
  3. 最后,最小却也最神奇的那只娃娃——闭包,把外层边界「揣进自己的口袋」随身携带,即使大娃娃已被扔进回收站,它仍能让里面的变量起死回生。

于是,JavaScript 的魔法就这样完成: 作用域给出边界,作用域链提供方向,而闭包,把本该消失的世界悄悄珍藏。
下次再被面试官追问什么是闭包时,不妨微笑着回答: 它不过是一只偷偷把家揣在兜里的俄罗斯套娃。 感谢阅读,如果这篇文章帮你拆开了心中的某只套娃,点个赞把它收进你的技术口袋吧——我们评论区见!