作用域与闭包

127 阅读18分钟

作用域

作用域(英文:scope)是据名称来查找变量的一套规则,可以把作用域通俗理解为一个封闭的空间,这个空间是封闭的,不会对外部产生影响,外部空间不能访问内部空间,但是内部空间可以访问将其包裹在内的外部空间。

说白了就是一门语言如果声明的变量都放在全局,程序规模小还行如果规模一大肯定就不行了。所以就会采用各种方案来确定函数的作用域。

image-20220114233134114

静态作用域与动态作用域

静态作用域(static scope) 与 词法作用域(lexical scope)

其实就是指的词法作用域,所谓静态作用域,也就是说在程序编译期通过对源代码的词法分析就可以确定某个标识符属于哪个作用域、作用域的嵌套关系(作用域链),在书写源代码时这些关系就已经确立了。

词法分析是编译中不可或缺的一环。

image-20220114204244894

// 静态作用域:
var a = 10;
function fn() {
	var b = 1;
	console.log(a + b);
}

fn(); // 11
复制代码

动态作用域(dynamic scope)

动态作用域是在运行时确定的,词法作用域关注函数在何处声明,而动态作用域关注函数从何处调用,其作用域链是基于运行时的调用栈的。

js语言中的变现为this 也就是上下文环境

function say() {
  debugger
  console.log('我的家乡:' + this.name)
}
var china = {
  name: '中国',
  say,
  beijing :{
  	name: '北京',
  	say
}
}
setInterval(() => Math.random() > 0.5 ? china.say() : china.beijing.say() , 1000)

复制代码

因为 this 是指向的是函数运行时所在的环境,也就是说只有到了执行时才能确定。

扩展

其实动态与静态的问题在每种语言中都存在,

比如:

  • C++ 动态联编与静态联编 - 虚函数
  • Java 动态编译与动态加载

函数作用域 与 块级作用域

下面我们细致的说一下函数作用域和块级作用域。

对于JS这种函数式语言,函数是一等公民,甚至有人想过用函数解决所有问题。

所以我们首先说说静态作用域的基础函数作用域。

函数作用域:指在函数内声明的所有变量在函数体内始终是可见的,可以在整个函数的范围内使用及复用。

var a = 'a'
function f1() {
    var b = 'b'
    function f2() {
        var c = 'c'
        function f3() {
          if(true) {
          	var d = 'd'
        	}
          console.log(a, b, c, d)
          debugger
        }
      	f3()
    }
  	f2()
}
f1()
复制代码

作用域链

ECMA-262标准第三版定义,该内部属性包含了函数被创建的作用域中对象的集合,这个集合被称为函数的作用域链

image-20220114224032095

块级作用域

块作用域是一个用来对之前的最小授权原则进行扩展的工具,将代码从在函数中隐藏信息扩展为在块中隐藏信息。

为了与其他主流语言靠近,块级作用域是ES6推出了let、const实现块级作用域。

var a = 'a'
function f1() {
    var b = 'b'
    function f2() {
        var c = 'c'
        function f3() {
          if(true) {
            debugger
          	let d = 'd' // var 改为 let
        	}
          debugger
          console.log(a, b, c, d)
        }
      	f3()
    }
  	f2()
}
f1()
复制代码

上面只是将变量d改为使用let声明

image-20220115002552157

但是运行结果就发生了变化

image-20220114233714015

发生的原因就是使用var声明时,变量d的作用域在函数内。

而使用let声明时,作用域只在if代码块内。

面试攻略

  • 这道题其实是一个基础题,没有一个人不回答。回答的关键是在描述的系统性上面。比如你硬说词法作用域和动态作用域组成了JS的作用域体系就很奇怪。不在一个维度的描述会让人觉得描述不够系统和全面。

image-20220114233134114


作者:全栈然叔
链接:juejin.cn/post/705308… 来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

1.什么是作用域

简单来说,作用域(英文:scope)是据名称来查找变量的一套规则,可以把作用域通俗理解为一个封闭的空间,这个空间是封闭的,不会对外部产生影响,外部空间不能访问内部空间,但是内部空间可以访问将其包裹在内的外部空间。

2.[[Scopes]]属性

在javascript中,每个函数都是一个对象,在对象中有些属性我们可以访问,有些我们是不能自由访问的, [[Scopes]]属性就是其中之一,这个属性只能被JavaScript引擎读取。 其实[[scope]]就是我们常说的作用域,其中存储了作用域运行期的上下文集合。

在这里因为func.prototype.constructor和func指向同一个函数,所以在这里我们通过访问函数func的原型对象来查看[[Scopes]]属性

W@%XDZ@N%ER$(OJWY)H7CX1.png

3.作用域链

[[scope]]中存储的执行期的上下文对象的集合,这个集合呈链式连接,我们把这种链式连接叫做作用域链。JavaScript正是通过作用域链来查找变量的,其查找方式是沿着作用域链的顶端依次向下查询(在哪个函数内部查找对象,就在哪个函数作用域链中查找)

4.图解查找变量原理

//以如下代码为例说明JavaScript通过作用域链查找变量的原理**
function a() {
  function b() {
      var b = 123;
  }
  var a = 123;
  b();
}
var glob = 100;
复制代码

1.当全局函数a()被定义时,作用域链如下

01.png 函数的[[Scopes]]属性指向作用域链对象,此时作用域链只有一个键值对,这个键值对指向全局对象,全局对象存储了全局下可以访问的东西,也就是最外层作用域,大家都可以访问的。

2.当全局函数a()被激活调用时,作用域链如下

02.png 此时作用域链能够第一个访问的是Activation Object中的键值对,如果没有才访问全局对象

3.函数a()中函数b被定义时,b的作用域链如下

04.png 当b只是被定义没有被调用时,b的作用域链和a是相同的

4.当函数a()中的函数b被激活调用时,作用域链如下

03.png

作用域链最先指向函数b()的Activation Object,查找变量也是按作用域链顺序访问,找到就停止

5.总结

之所以外部作用域不能访问内部作用域的原因是外部作用域的作用域链没有内部作用域的Activation Object,所以无法访问内部变量,内部作用域访问变量的顺序是按照作用域链,先从里面查找,没有就沿着作用域链向外找,外部是全局作用域。

闭包

闭包怎么设计?

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

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

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

怎么让子函数打包带走?

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

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

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

image.png

这个 func3 需不需要打包一些东西? 会不会有闭包?

image.png

其实还是有闭包的,闭包最少会包含全局作用域。

但是为啥 guang、ssh、suzhe 都没有 ? suzhe是因为不是外部的,只有外部变量的时候才会生成,比如我们改动下代码,打印下这 3 个变量。

image.png

再次查看 [[Scopes]] (打包带走的闭包环境):

image.png

这时候就有俩闭包了,为什么呢? suzhe 哪去了?

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

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

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

调用 func3 的时候,JS 引擎 会取出 [[Scopes]] 中的打包的 Closure + Global 链,设置成新的作用域链, 这就是函数用到的所有外部环境了,有了外部环境,自然就可以运行了。

这里思考一个问题: 调试代码的时候为什么遇到过某个变量明明在作用域内能访问到,但就是没有相关信息呢?

image.png

这个 traverse,明明能访问到的,为啥就是不显示信息呢?是 debugger 做的太烂了么?

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

所以我们只要访问一下,就能在调试的时候访问到了。

image.png

是不是突然知道为啥调试的时候不能看一些变量的信息了,能解释清楚这个现象,就算理解闭包了。

eval

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

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

验证一下:

image.png

这个就像上面所说的,会把外部引用的打包成闭包

image.png

这个就是 eval 的实现,因为没法静态分析动态内容所以全部打包成闭包了,本来闭包就是为了不保存全部的作用域链的内容,结果 eval 导致全部保存了,所以尽量不要用 eval。会导致闭包保存内容过多。

image.png

但是 JS 引擎只处理了直接调用,也就是说直接调用 eval 才会打包整个作用域,如果不直接调用 eval,就没法分析引用,也就没法形成闭包了。

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

给闭包下个定义

用我们刚刚的试验来给闭包下个定义:

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

过滤规则:

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

闭包的缺点

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

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

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

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

image.png

每个栈帧的执行上下文包含函数执行需要访问的所有环境,包括 local 环境、作用域链、this等。

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

首先父函数的栈帧会销毁,子函数这个时候其实还没有被调用,所以还是一个堆中的对象,没有对应的栈帧,这时候父函数把作用域链过滤出需要用到的,形成闭包链,设置到子函数的 [[Scopes]] 属性上。

image.png

父函数销毁,栈帧对应的内存马上释放,用到的 ssh Obj 会被 gc 回收,而返回的函数会把作用域链过滤出用到的引用形成闭包链放在堆中。 这就导致了一个隐患: 如果一个很大的对象被函数引用,本来函数调用结束就能销毁,但是现在引用却被通过闭包保存到了堆里,而且还一直用不到,那这块堆内存就一直没法使用,严重到一定程度就算是内存泄漏了。所以闭包不要乱用,少打包一点东西到堆内存。

总结

作用域链

除了环境记录外,还需要保存对外部词法环境的引用。这里使用一个指向变量对象的指针列表——作用域链来记录。

作用域链本质上是一个列表对象,线性、有次序的保存着变量对象的引用。

作用域链的作用是保证对执行环境有权访问的所有变量和函数的有序访问。

作用域链的前端,始终都是当前执行的代码所在环境的变量对象。如果这个环境是函数,则将其“活动对象”作为变量对象(活动对象最开始包含一个对象,即arguments)。作用域链的下一个变量对象来自包含(外部)环境,层层延续到全局执行环境,全局执行环境的变量对象始终都是作用域链中的最后一个对象。

标识符解析是沿着作用域链一级一级地搜索标识符的过程,始终从作用域链的前端开始,然后逐级往后回溯,直到找到为止,如果找不到,通常会报错。

另外,在js中, 内部环境可以通过作用域链访问所有的外部环境,但外部环境不能访问内部环境中的任何变量和函数。因此,作用域链的线性和有序也体现在此,每个环境都可以向上搜索作用域链以查找变量和函数名,但不能往下。

执行环境、变量对象和作用域链的关系及内部原理

我们理解执行环境和变量对象、作用域链之间的大致关系,但是内部的实现原理是怎样的呢?

首先,变量对象和作用域链我们能理解:作用域链是一个有次序保存相应执行环境的变量对象的引用的列表。

而变量对象和执行环境也是一对一的,一个执行环境对应着一个变量对象(活动对象)。

那作用域链和执行环境呢?

理解这个很重要,因为我们知道执行环境是通过栈来管理的,在某一段代码执行完成后,顶部的环境就被出栈了,那既然外部环境执行完后都出栈了,但为什么闭包里又会提到说引用了该环境的作用域呢?且看下面的原理。

其实,在调用函数时,会为函数创建一个执行环境压入栈顶,同时会创建一个预先包含全局变量对象的作用域链,这个作用域链被保存在内部的 [[Scope]]属性 中。再执行时,会通过 复制函数的[[Scope]]属性中的对象构建起执行环境的作用域链,此后,又有一个活动对象被创建并被推入这个执行环境作用域链的顶端 。无论什么时候在函数中访问一个变量时,都会从作用域链中搜索具有相应名字的变量。

我们来看一段代码来说明这三者之间的关系:

function compare(value1, value2){
    if(value1 < value2){
        return -1;
    }else if(value1 > value2){
        return 1;
    }else{
        return 0;
    }
}
var res = compare(5, 10);
复制代码

以上代码先定义了fn函数,然后又在全局作用域链中调用了它。当调用compare函数时,会创建一个包含arguments、value1、value2的活动对象。

如下图所示,执行环境、作用域链、变量对象(活动对象)的关系:

闭包

一般来讲,当函数执行完毕后,局部活动对象就会被销毁,内存中仅保存全局作用域(全局执行环境的变量对象)。但是闭包的情况有所不同:

在另一个函数内部定义的函数会将包含函数(即外部函数)的活动对象添加到它的作用域链中。

如下面代码所示:

function createComparisonFunction(propertyName) {
    return function(object1, object2){
        var value1 = object1[propertyName];
        var value2 = object2[propertyName];
        if (value1 < value2){
            return -1;
        } else if (value1 > value2){
            return 1;
        } else {
            return 0;
        }
    };
}
// 创建函数
var compareNames = createComparisonFuncion('name');
// 调用函数
var result = compareNames({name: 'zs'},{name: 'ls'});
// 解除对匿名函数的引用(以便释放内存)
compareNames = null;
复制代码

在 createComparsionFunction 函数内部定义的匿名函数的作用域链中,实际上将会包含外部函数 createComparsionFunction 的活动对象。在匿名函数从 createComparsionFunction 中被返回后,它的作用域链被初始化为包含 createComparsionFunction 函数的活动对象和全局变量对象。

这样,匿名函数就可以访问在 createComparsionFunction 中定义的所有变量。更为重要的是,createComparsionFunction 函数在执行完毕后,其活动对象也不会被销毁,因为匿名函数的作用域链仍然在引用这个活动对象。换句话说,当 createComparsionFunction 函数返回后,其执行环境的作用域链会被销毁,但它的活动对象仍然会留在内存中;直至匿名函数被销毁后, createComparsionFunction 函数的活动对象才会被销毁。

创建的比较函数被保存在变量compareNames中。而通过将compareNames设置为等于null解除该函数的引用,就等于通知垃圾回收例程将其清除。随着匿名函数的作用域链被销毁(当初引用的包含外部函数的活动对象的引用被解除了),其他作用域(除了全局作用域)也都可以安全的销毁了。

下图展示了调用 compareNames 函数的过程中产生的作用域链之间的关系:

看完上面粗糙的梳理,再来看这两个不同的定义:

闭包是指有权访问另一个函数作用域中的变量的函数。——《js高程》

Closures are functions that have access to variables from another function’s scope.

闭包是函数和声明该函数的词法环境的组合。——《MDN》

A closure is the combination of a function and the lexical environment within which that function was declared.