作用域、作用域链、闭包

151 阅读9分钟

作用域

函数在执行时会产生执行上下文,每个上下文都有一个关联的变量对象,这个上下文中定义的所有变量和函数都包含在这个关联的变量对象中,虽然无法通过代码控制这个对象,但是引擎会自动利用它。上下文与关联的变量对象会在其所有代码执行后被销毁,包括其包含的所有的变量和函数。(在浏览器中,全局作用域window会在关闭标签后被销毁)

作用域分为全局作用域和局部作用域

  • 全局作用域:根据不同的宿主环境,全局作用域所指的对象可能不一样,在浏览器环境中指的是window,在Node环境中指的是Global。var和function定义的变量或者函数会挂在window身上。但是let和const定义的变量不会挂在window身上,而是由js引擎单独拉出一个作用域,但是在作用域链解析效果是一样的。
  • 局部作用域:局部作用域指的是函数执行时产生的上下文。

作用域链

函数在定义时,函数对象上会有一个 [[scopes]] 指针来指向它的上层作用域链,然后在真正执行时,将函数执行产生的执行上下文对象推入到这个作用域链的最前端。

局部作用域的作用域链决定了这个局部作用域内部访问变量的查找顺序与规则。

局部作用域内的变量查找会从当前作用域开始查找,顺着 [[scopes]] 对应的作用域链按顺序查找,直到全局作用域window上停止。在任何一个作用域中找到就停止查找,如果找到全局作用域还没找到,则报错。

var a = 1
function fn1() {
  var b = 2
  var d = 4
}
debugger;
console.dir(fn1)
fn1()

这段代码中fn1的 [[scopes]] 图:

作用域链-1.jpg

这里可以看到fn1这个函数上的 [[scopes]] 属性对应的“链”,它的上层作用域链只有Window。

之后看fn1()执行时的scope图:

作用域链-2.jpg

可以看到fn1执行时的作用域链,函数本身执行的作用域会推到定义在fn1上的作用域链的最前端。

当查找d时会用fn1的作用域中的d。

当查找a时会先在fn1的作用域查a,查不到则去上层作用域(这个例子是全局作用域)中查找,查找则停止查找。

当查找z时会先在fn1的作用域查z,查不到则去上层作用域(这个例子是全局作用域)中查找,找不到,由于上层没有作用域可查找了,会报错。

闭包

闭包是指引用了另一个函数作用域中变量的函数,通常是在嵌套函数中实现的。

代码:

var a = 1
function fn1() {
  var b = 2
  var d = 4
  function fn2() {
    var c = 3
    var e = d
  }
  return fn2
}
var ret = fn1()
console.dir(ret)
debugger;
ret()

代码中可以看到fn1中定义的fn2函数作为fn1()调用的结果,并用ret变量保存了这个结果,其实就是ret变量保存的就是fn2的内存地址。

来看ret(fn2)函数:

闭包-1.jpg

图中可以看出,fn2函数上的 [[scopes]] 包含了:

  • fn1函数执行时所关联的变量对象(不同浏览器可能对这个对象做了优化,chrome中显示是fn1执行时所关联的变量对象只保存了被fn1执行作用域外部引用的变量)
  • 全局作用域对象

这样就形成了闭包,本身fn1函数执行完,fn1函数执行时的作用域所关联的变量对象应该被回收,但是由于有其它函数内部引用着这个变量对象中的某个变量,所以无法被完全回收,从而形成了闭包。

嵌套函数的内部函数一定要被外部接收才能产生闭包吗?

上面讲的闭包的概念:一个引用了另一个函数作用域中的变量的函数。这句话是红宝书中所写的概念。

我也有疑问?到底内部函数用不用被外部接收?

看这段代码以及内部函数fn2的 [[scopes]] 属性以及fn2执行时的scope

var a = 1
function fn1() {
  var b = 2
  var d = 4
  function fn2() {
    var c = 3
    var e = d
  }
  console.dir(fn2)
  fn2()
}
debugger;
fn1()

fn2:

闭包-2.jpg

可以看到,fn2并没有被外部接收,而fn2定义后,就已经产生了fn2函数的上层作用域链:

  • fn1函数执行时所关联的变量对象(不同浏览器可能对这个对象做了优化,chrome中显示是fn1执行时所关联的变量对象只保存了被fn1执行作用域外部引用的变量)
  • 全局作用域对象

另外我们再看fn2执行时

fn2执行时scope:

闭包-3.jpg

可以看到,fn2执行时,scope中也是在定义时作用域链的基础上,将fn2执行时的作用域关联对象推入到定义时作用域链的最前端。

从调试中,可以看出,嵌套函数的内部函数内部如果引用了外层函数的变量,就已经形成了闭包,与这个内部函数被外部接收与否无关。

为了验证这一点,下面我们来看这段代码:

var a = 1
function fn1() {
  var b = 2
  var d = 4
  function fn2() {
    var c = 3
  }
  console.dir(fn2)
  fn2()
}
debugger;
fn1()

这段代码中fn2并没有引用fn1中的变量

fn2身上 [[scopes]] 属性:

闭包-4.jpg

从这段可以看出,其并没有包括fn1的作用域所关联的变量对象,也就没有形成闭包

fn2执行时:

闭包-5.jpg

从执行时也可以看到,执行时Scope只包括了fn2本身的作用域关联变量对象和全局作用域对象。

这两种形式应该验证了这一点:闭包的形成不需要内部函数被外部变量接收(不过也有可能用的浏览器是最新的,也可能是ECMAScript更新产生的这个区别,这个我其实并不敢下结论,只是从红宝书上看,以及从实际调试结果来看得出的这个结论)

闭包与内部函数被外部变量接收的关系

首先在嵌套函数中,如果内部函数内部引用了外部函数中定义的变量,会形成闭包。

但是会有两种情况影响着闭包的释放:

  • 如果内部函数被外部接收引用了,那么闭包在外部函数执行上下文弹出函数执行栈时,不会被完全释放
  • 如果内部函数没有被外部接收引用,那么内部函数在外部函数执行上下文弹出函数执行栈时,内部函数会被释放,而内部函数被释放,其对应的 [[scopes]] 的引用也会切断,那么外部函数执行时所关联的变量对象就没有被任何其它变量引用,所以这个闭包就会被释放

所以区别就是:如果内部函数被外部变量接收,闭包不会被释放,如果没被接收,闭包就会在执行后被释放

由以上的结论,可以了解到形成闭包时,内部函数的 [[scopes]] 所引用的闭包其实是外部函数执行时所关联的变量对象,而这个变量对象在外部函数每次执行时,都会产生个新的变量对象,也会新定义一个内部函数。所以依靠这个来看下下面的代码执行结果:

function outer() {
  var num = 1
  return function inner() {
    return num++
  }
}
​
var counter = outer()
var counter2 = outer()
console.log(counter())
console.log(counter())
console.log(counter2())

由上面的结论来看,其实counter和counter2两个函数的 [[scopes]] 对应的闭包对象是不一样的。

所以当counter()执行时,所操作的num是第一次执行outer()时的作用域所关联的变量对象中的num

当counter2()执行时,所操作的num是第二次执行outer()时的作用域所关联的变量对象中的num

所以什么是闭包?

闭包:引用了函数A作用域中变量的函数B,就已经形成了闭包,通常在嵌套函数中实现,而只要函数B没有被回收,那么这个闭包就无法被回收。

由这个定义是不是可以想到,闭包会产生内存问题?

闭包引起的内存问题
  • 不合理的闭包

    function outer() {
      var person = {
        id: '1',
        name: 'ccc',
        age: 18,
        ...
      }
      return function() {
        ...
        getInfo(person.id)
      }
    }
    var fn = outer()
    

    这种情况,其实内部函数只需要访问person.id这个变量,但是我们通过person访问,person这个对象就无法被释放。所以这种其实是不合理的,我们需要将这个id提出来:

    function outer() {
      var person = {
        id: '1',
        name: 'ccc',
        age: 18,
        ...
      }
      var id = person.id
      person = null
      return function() {
        ...
        getInfo(id)
      }
    }
    var fn = outer()
    

    这样person这个变量就会被释放了,但是getInfo依然可以传递正确的id

  • 内存泄漏

    function bindHandler() {
      var ele = document.getElementById("ele")
      ele.onclick = function fn() {
        console.log(ele.id)
      } 
    }
    

    以上这个情况,因为fn中引用着ele这个对象。所以fn不释放,ele也就无法释放。

    而当dom树中id = ele这个dom节点从dom树中删除时,其实这个dom对象就应该被释放了,但是由于fn中还引用着,所以无法被准确释放,这就造成了不必要的内存泄漏。

    解决:

    function bindHandler() {
      var ele = document.getElementById("ele")
      var id = ele.id
      ele.onclick = function fn() {
        console.log(id)
      } 
      ele = null
    }
    

以上两个例子中,person 和 ele在最后都被赋值为null,是为了外部函数所关联的变量对象没有被释放,有可能这个变量对象中还引用着person和ele,所以将它俩赋值为null,消除掉引用关系。(这个情况可能在每个浏览器版本中表现不一致,所以这种最好写上,避免有的浏览器没有做闭包的优化)

如果理解有误,希望能指出!!~~