JavaScript之作用域和闭包

JavaScript之作用域和闭包

  • 1. 作用域和作用域链
    • 1.1 作用域
    • 1.2 作用域链
  • 2. 闭包
    • 2.1 闭包怎么设计?
    • 2.2 eval
    • 2.3 给闭包下个定义
    • 2.4 闭包的缺点
      • 2.4.1 概述
      • 2.4.2 详细分析
    • 2.5 总结

1. 作用域和作用域链

1.1 作用域

变量保存和访问的范围。在JavaScript中,函数,块,模块都可以形成作用域。
作用域全称是词法作用域,是在代码编写时就确定了作用域的。

1.2 作用域链

函数之间的嵌套会形成作用域链。

比如说在全局作用域中定义了一个函数f1,又在f1中定义了一个内部函数f2,f2内部能够访问f2的作用域中的变量和f1的作用域中的变量,也能访问到全局变量。

2. 闭包

函数在其所在作用域以外的地方被调用就会形成闭包。而闭包就是函数能够记住并访问其所在词法作用域链中的变量。

function func() {
  const guang = 'guang';
  function func2() {
    const ssh = 'ssh';
    function func3 () {
      const suzhe = 'suzhe';
    }
    return func3;
  }
  return func2;
}

const func2 = func();

当调用 func2 的时候 func 已经执行完了,这时候销不销毁 ?于是 JavaScript 就设计了闭包的机制。

2.1 闭包怎么设计?

先不看答案,考虑一下我们解决这个静态作用域链中的父作用域先于子作用域销毁怎么解决。

首先,父作用域要不要销毁? 是不是父作用域不销毁就行了?

不行的,父作用域中有很多东西与子函数无关,为啥因为子函数没结束就一直常驻内存。这样肯定有性能问题,所以还是要销毁。 但是销毁了父作用域不能影响子函数,所以要再创建个对象,要把子函数内引用(refer)的父作用域的变量打包里来,给子函数打包带走。

怎么让子函数打包带走?

设计个独特的属性,比如 [[Scopes]] ,用这个来放函数打包带走的用到的环境。并且这个属性得是一个栈,因为函数有子函数、子函数可能还有子函数,每次打包都要放在这里一个包,所以就要设计成一个栈结构,就像饭盒有多层一样。

我们所考虑的这个解决方案:销毁父作用域后,把用到的变量包起来,打包给子函数,放到一个属性上。这就是闭包的机制。

我们来试验一下闭包的特性:

function func() {
  const guang = 'guang';
  function func2() {
    const ssh = 'ssh';
    function func3 () {
      const suzhe = 'suzhe';
    }
    return func3;
  }
  return func2;
}

const func2 = func();
const func3 = func2();
func3();

这个 func3 需不需要打包一些东西? 会不会有闭包?
JavaScript之作用域和闭包_第1张图片
其实还是有闭包的,闭包最少会包含全局作用域。

但是为啥 guang、ssh、suzhe 都没有 ? suzhe是因为不是外部的,只有外部变量的时候才会生成,比如我们改动下代码,打印下这 3 个变量。
JavaScript之作用域和闭包_第2张图片
再次查看 [[Scopes]] (打包带走的闭包环境):
JavaScript之作用域和闭包_第3张图片
这时候就有俩闭包了,为什么呢? suzhe 哪去了?

首先,我们需要打包的只是环境内没有的,也就是闭包只保存外部引用。然后是在创建函数的时候保存到函数属性上的,创建的函数返回的时候会打包给函数,但是 JS 引擎怎么知道它要用到哪些外部引用呢,需要做 AST 扫描,很多 JS 引擎会做 Lazy Parsing,这时候去 parse 函数,正好也能知道它用到了哪些外部引用,然后把这些外部用打包成 Closure 闭包,加到 [[scopes]] 中。

所以,闭包是返回函数的时候扫描函数内的标识符引用,把用到的本作用域的变量打成 Closure 包,放到 [[Scopes]] 里

所以上面的函数会在 func3 返回的时候扫描函数内的标识符,把 guang、ssh 扫描出来了,就顺着作用域链条查找这俩变量,过滤出来打包成两个 Closure(因为属于两个作用域,所以生成两个 Closure),再加上最外层 Global,设置给函数 func3 的 [[scopes]] 属性,让它打包带走。

调用 func3 的时候,JS 引擎 会取出 [[Scopes]] 中的打包的 Closure + Global 链,设置成新的作用域链, 这就是函数用到的所有外部环境了,有了外部环境,自然就可以运行了。
这里思考一个问题: 调试代码的时候为什么遇到过某个变量明明在作用域内能访问到,但就是没有相关信息呢?
JavaScript之作用域和闭包_第4张图片
这个 traverse,明明能访问到的,为啥就是不显示信息呢?是 debugger 做的太烂了么?

不是的,如果你不知道原因,那是因为你还不理解闭包,因为这个 FunctionDeclaration 的函数是一个回调函数,明显是在另一个函数内调用的,就需要在创建的时候打包带走这个环境内的东西,根据只打包必要的环境的原则(不浪费内存),traverse 没有被引用(refer),自然就不打包了。并不是 debugger 有 bug 了。

所以我们只要访问一下,就能在调试的时候访问到了。
JavaScript之作用域和闭包_第5张图片
是不是突然知道为啥调试的时候不能看一些变量的信息了,能解释清楚这个现象,就算理解闭包了。

2.2 eval

再来思考一个问题: 闭包需要扫描函数内的标识符,做静态分析,那 eval 怎么办,他有可能内容是从网络记载的,从磁盘读取的等等,内容是动态的。用静态去分析动态是不可能没 bug 的。怎么办?

没错,eval 确实没法分析外部引用,也就没法打包闭包,这种就特殊处理一下,打包整个作用域就好了。

验证一下:
JavaScript之作用域和闭包_第6张图片
这个就像上面所说的,会把外部引用的打包成闭包
JavaScript之作用域和闭包_第7张图片
这个就是 eval 的实现,因为没法静态分析动态内容所以全部打包成闭包了,本来闭包就是为了不保存全部的作用域链的内容,结果 eval 导致全部保存了,所以尽量不要用 eval。会导致闭包保存内容过多。
JavaScript之作用域和闭包_第8张图片
但是 JS 引擎只处理了直接调用,也就是说直接调用 eval 才会打包整个作用域,如果不直接调用 eval,就没法分析引用,也就没法形成闭包了。

这种特殊情况有的时候还能用来完成一些黑魔法,比如利用不直接调用 eval 不会生成闭包,会在全局上下文执行的特性。

2.3 给闭包下个定义

用我们刚刚的试验来给闭包下个定义:
闭包是在函数创建的时候,让函数打包带走的根据函数内的外部引用来过滤作用域链剩下的链。它是在函数创建的时候生成的作用域链的子集,是打包的外部环境。evel 因为没法分析内容,所以直接调用会把整个作用域打包(所以尽量不要用 eval,容易在闭包保存过多的无用变量),而不直接调用则没有闭包。

过滤规则:

  1. 全局作用域不会被过滤掉,一定包含。所以在何处调用函数都能访问到。
  2. 其余作用域会根据是否内部有变量被当前函数所引用而过滤掉一些。不是每个返回的子函数都会生成闭包。
  3. 被引用的作用域也会过滤掉没有被引用的 binding (变量声明)。只把用到的变量打个包。

2.4 闭包的缺点

2.4.1 概述

  1. 可能导致内存泄露。闭包使得函数所引用到的变量一直保存在内存在中,如果引用过多不及时释放的话,就可能造成内存泄漏。
  2. 函数能够改变闭包中的变量的值,如果再次调用这个函数,闭包中变量的值是改变之后的值,不再是初始值。
function fn() {
  var name = 'gaoli';
  return function(cusName) {
    name += cusName;
    console.log('name:', name);
  }
}
var fnTest = fn();
fnTest('&&&');
fnTest('111');
fnTest('&&&');
fnTest('222');

控制台:
在这里插入图片描述
返回值如果是一个包含了两个方法的对象,两个方法的闭包是互相独立的。

function fn() {
  var obj = {
    count: 1
  };
  var name = 'gaoli';
  return {
    reName: function(cusName) {
      name += cusName;
      console.log('name:', name);
      console.log('count in reName:', obj.count);
    },
    reCount: function(cName) {
      obj.count += 1;
      name += cName;
      console.log('count:', obj.count);
      console.log('name in reCount:', name);
    }
  }
}
var fnTest = fn().reName;
var fnCount = fn().reCount;
fnTest('&&&');
fnTest('111');
fnTest('&&&');
fnTest('222');
console.log('----------------------');
fnCount('***');
fnCount('999');
fnCount('***');
fnCount('888');
console.log('-----------------------')
fnTest('&&&');

控制台:
JavaScript之作用域和闭包_第9张图片

2.4.2 详细分析

JavaScript 是静态作用域的设计,闭包是为了解决子函数晚于父函数销毁的问题,我们会在父函数销毁时,把子函数引用到的变量打成 Closure 包放到函数的 [[Scopes]] 上,让它计算父函数销毁了也随时随地能访问外部环境。

这样设计确实解决了问题,但是有没有什么缺点呢?

其实问题就在于这个 [[Scopes]] 属性上

我们知道 JavaScript 引擎会把内存分为函数调用栈、全局作用域和堆,其中堆用于放一些动态的对象,调用栈每一个栈帧放一个函数的执行上下文,里面有一个 local 变量环境用于放内部声明的一些变量,如果是对象,会在堆上分配空间,然后把引用保存在栈帧的 local 环境中。全局作用域也是一样,只不过一般用于放静态的一些东西,有时候也叫静态域。
JavaScript之作用域和闭包_第10张图片
每个栈帧的执行上下文包含函数执行需要访问的所有环境,包括 local 环境、作用域链、this等。

那么如果子函数返回了会发生什么呢?

首先父函数的栈帧会销毁,子函数这个时候其实还没有被调用,所以还是一个堆中的对象,没有对应的栈帧,这时候父函数把作用域链过滤出需要用到的,形成闭包链,设置到子函数的 [[Scopes]] 属性上。
JavaScript之作用域和闭包_第11张图片
父函数销毁,栈帧对应的内存马上释放,用到的 ssh Obj 会被 gc 回收,而返回的函数会把作用域链过滤出用到的引用形成闭包链放在堆中。 这就导致了一个隐患: 如果一个很大的对象被函数引用,本来函数调用结束就能销毁,但是现在引用却被通过闭包保存到了堆里,而且还一直用不到,那这块堆内存就一直没法使用,严重到一定程度就算是内存泄漏了。所以闭包不要乱用,少打包一点东西到堆内存。

2.5 总结

我们从静态作用域开始聊起,明确了什么是作用域,通过 babel 静态分析了一下作用域,了解了下静态和动态作用域,然后引入了子函数先于父函数销毁的问题,思考了下方案,然后引入了闭包的概念,分析下闭包生成的流程,保存的位置。我们还用闭包的特性分析了下为什么有时候调试的时候查看不了变量信息,之后分析了下 eval 为什么没法精确生成闭包,什么时候全部打包作用域、什么时候不生成闭包, eval 为什么会导致内存占用过多。之后分析了下带有闭包的函数在内存中的特点,解释了下为啥可能会内存泄漏。

闭包是在返回一个函数的时候,为了把环境保存下载,创建的一个快照,对作用域链做了tree shking,只留下必要的闭包链,保存在堆里,作为对象的 [[scopes]] 属性,让函数不管走到哪,随时随地可访问用到的外部环境。在执行这个函数的时候,会利用这个“快照”,恢复作用域链。

因为还没执行函数,所以要静态分析标识符引用。静态分析动态这件事情被无数个框架证明做不了,所以返回的函数有eval 只能全部打包或者不生成闭包。类似webpack 的动态import没法分析一样。

作者:zxg_神说要有光
链接:https://juejin.cn/post/6957913856488243237
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

你可能感兴趣的:(转载,javascript,ssh)