阅读 1560

进击的 JavaScript(四) 之 闭包与作用域

更改记录:

19年 11月27日,修改!

上一节说了执行上下文,这节咱们就乘胜追击来搞搞闭包!头疼的东西让你不再头疼!

名词解释:

变量对象

变量对象是根据(Variable Object) 来翻译过来的,也可以翻译成可变对象, 就是保存变量的对象,活动对象,闭包对象都保存着变量,因此也可以称为变量对象。

注:这里解释下,是因为各个书中对这几个名词的使用,搞的好远。

执行上下文

是根据(Execution Context)翻译过来的,也可译为执行环境。 在函数执行时,就会首先创建执行上下文来运行代码。

活动对象

是根据(Activation Object)翻译过来的,也可译为激活的对象。 在函数执行时,创建的变量对象,不仅含有变量,还有特殊的this,arguments。

闭包

是根据(closure) 翻译过来的,也可译为 闭合,使结束等,我认为可以理解为 封闭的环境。 把当前作用域外的环境封闭起来,以备 其他作用域环境使用。

总结:好多名词都是英译过来的,看原著或根据上下文来理解才是这些单词真正的意思,只可意会不可言传。。。

一、函数创建一个参数,就相当于在其内部声明了该变量

function f(arg){
    console.log(arg)
}

f();

//undefined

function f(arg){
    arg = 5;
    console.log(arg);
}
f();

//5
复制代码

二、参数传递,就相当于变量复制(值的传递)

基本类型时,变量保存的是数据,引用类型时,变量保存的是内存地址。参数传递,就是把变量保存的值 复制给 参数。

var o = { a: 5 };

function f(arg){
    arg.a = 6;
}

f(o);

console.log(o.a);
//6
复制代码

三、垃圾收集机制

JavaScript 具有自动垃圾收集机制,执行环境会负责管理代码执行过程中使用的内存。函数中,正常的局部变量和函数声明只在函数执行的过程中存在,当函数执行结束后,就会释放它们所占的内存(销毁变量和函数)。

而js 中 主要有两种收集方式:

  1. 标记清除(常见) //给变量标记为“进入环境” 和 “离开环境”,回收标记为“离开环境”的变量。
  2. 引用计数 // 一个引用类型值,被赋值给一个变量,引用次数加1,通过变量取得引用类型值,则减1,回收为次数为0 的引用类型值。

知道个大概情况就可以了,《JavaScript高级程序设计 第三版》 4.3节 有详解,有兴趣,可以看下。.


四、作用域

在 JavaScript 中, 作用域(scope,或译有效范围)顾名思义就是变量和函数的作用范围(可访问范围)。

  • 作用域可以实体化为一个 可变对象(Variable Object 变量对象)

  • JavaScript中的作用域有:全局作用域局部作用域(函数作用域)。ES6 新增了块级作用域

全局作用域(Global Scope)

(1)不在任何函数内定义的变量就具有全局作用域。(非严格模式下)

(2)实际上,JavaScript默认有一个全局对象window,全局作用域的变量实际上被绑定到window的一个属性。

局部作用域(Local Scope)

(1)JavaScript的作用域是通过函数来定义的,在一个函数中定义的变量只对这个函数内部可见,称为函数(局部)作用域。

块级作用域 块级作用域指在If语句,switch语句,循环语句等语句块中定义变量,这意味着变量不能在语句块之外被访问。

五、函数跟作用域链间的关系

每个函数都有一个[[scope]] 的内部属性(可以通过console.dir(fn),来查看),它保存着作用域链(一个对象数组),而作用域链中是一个个可变对象(Variable Object 变量对象)(一个保存当前作用域中用到的变量,函数等的对象)。当函数创建时,一个代表全局环境的可变对象会被插入到作用域的第一个位置。该全局可变对象保存着window,navigator,document 等。

例如如下 声明一个全局函数

function add(num1, num2) {
    return num1 + num2
}
复制代码

UTOOLS1575003062214.png

当函数执行时,会创建执行上下文(执行环境),随后创建一个执行上下文对象,它有自己的作用链。刚开始,它会用函数自身的 [[scope]] 中的作用域链初始化自己(也就是复制)。

随后一个活动对象被创建(也可以说是变量对象,可变对象),它保存着当前函数作用域里的变量,arguments,this 等。最后,该活动对象会被推到执行上下文的作用域链的最前端。

_Blank UML (1).png

注:执行上下文(执行环境)在函数执行完毕后就会被销毁,里面的作用域链,变量,函数,活动对象,this 等也会一同销毁。

六、作用域链查找

在函数执行过程中,每遇到一个变量,都会经历一次标识符解析过程以决定从那里获取存储数据。该过程搜索执行环境的作用域链,查找同名的标识符。搜索过程从作用域链头部开始也就是当前运行的作用域。如果找到,就使用这个标识符对应的变量;如果没找到,继续搜索作用域链中的下一个对象。搜索过程会持续进行,直到找到标识符,若无法搜索到匹配的对象,那么标识符将被视为未定义的。

七、闭包函数 与 闭包对象

当函数嵌套时,例如有一个A函数,内部有个v1 的变量,有一个B函数,B 中使用了v1 变量。这时,为了让 B 执行时,能访问 v1(其实就是为了形成作用域链),会有以下两个变化:

  • 形成一个闭包函数,生成一个闭包对象 A,包含了 B 中用到 v1 变量
  • 在B 闭包函数的[[scope]] 属性中 推入 闭包对象A。
function A(){
    var v1 = 666
    
    function B() {
        return v1
    }
    
    console.dir(B)
    
    B()
}

A()
复制代码

执行结果,看函数的[[scope]] 属性: UTOOLS1575253977522.png

注:可以通过 debugger 来在谷歌浏览器控制台里看。具体怎么用,可以自行百度。

现在来分析一下过程:

1、首先,A 函数执行,一开始 它的[[scope]] 内的作用域链中只有全局的可变对象,然后 创建一个执行上下文对象,有一个作用域链,根据 [[scope]] 复制来 来初始化自己。

_Blank UML.png

2、创建 A 函数的活动对象,并推到 执行上下文对象的作用域链中。

UTOOLS1575253109483.png

3、当发现 B 中用到 v1 时,B 就会变成一个封闭的函数(闭包函数),然后,生成一个关于A 函数的封闭对象(闭包对象),保存着 v1(因为它存在于A,在B中使用)。随后,把这个封闭的对象推到 B 函数 的[[scope]] 作用域链中。

UTOOLS1575250075918.png

注:这时,B 函数还没有执行。至于什么机制导致js 能够发现未执行的函数内使用了 A 函数内的变量,这里牵扯到v8引擎的执行机制,会有个预解析的过程。

4、当B 函数执行时,创建执行上下文,创建执行上下文对象,初始化执行上下文对象的作用域链(复制B 函数的[[scope]] 属性)。

_Blank UML.png

5、随后创建一个活动对象,并推到 执行上下文对象的作用域链中。

_Blank UML.png

这样,B 在执行时,就可以访问 v1 了,因为在一个作用域链中。

下面来总结下作用链的变化过程:

  1. 全局下的 A函数执行时,内部的[[scope]] 保存的作用域链只有一个全局的变量对象。创建A 函数的执行上下文对象,根据 [[scope]] 复制初始化 A函数的 执行上下文对象的作用域链。
  2. 创建 A 函数的活动对象,推到 A函数执行上下文对象的作用域链前端。
  3. 当发现 A 函数内部(不管层级多深)有 一个函数使用了 A函数内变量或函数
  4. A 内(不管层级多深)所有函数 都会形成 闭包函数
  5. 然后创建一个关于 A 的闭包对象,对象内含有被使用的变量或函数(通过复制)。
  6. 最后把该闭包对象 推到 所有闭包函数的 [[scope]] 内

可以得出以下结论:

  • 有两个作用域链,一个存与函数的[[scope]] 中,用来保存作用域,以备执行上下文对象初始化自身作用域链。
  • 执行上下文对象中的作用域链,会添加活动函数,作用域链的查找,查的就是这条作用域链。(一般我们说的作用域链就是指这条)
  • 活动函数只会存在于执行上下文对象的作用域链中。
  • 有闭包函数和闭包对象,闭包函数的[[scope]] 保存闭包对象,而闭包对象,封闭的是 父或祖级函数作用域中的变量或对象
  • 闭包函数的存在是因为 执行上下文环境 会在执行完后销毁,而其中的作用域链,活动对象,变量等等就丢失了,通过闭包函数 就可以保存着作用域链,而链中的变量对象又保存着变量,函数等。

来一个难一点的例子,大家可以先自己分析分析。

function A(){
  var va = 'aaa'
  
  function B() {
    var vb = 'bbb'

    function C() {
      var vc1 = 'ccc'

      return va
    }

    function D() {
      var vd = 'ddd'
      
      return vb
    }

    console.dir(C)
    console.dir(D)

    C()

  }
  
  console.dir(B)

  function E () {
    var vd = 'eee'
  }

  console.dir(E)

  B()
}

console.dir(A)

A()
复制代码

根据上面分析,可以得出各个函数的[[scope]]:

  • A 只有一个全局变量对象
  • B 和 E 有两个变量对象,关于 A 的闭包对象,全局变量对象。
  • C 和 D 有三个变量对象,关于 B 的闭包对象,关于 A 的闭包对象,全局变量对象。

控制台: 未标题-1.png

通过 debugger 来单步调试,无非就是能看到每个执行环境内的作用域链中 含有 活动对象。

有兴趣的可以试试。

至于闭包的内存泄漏,这里面牵扯到 js 的垃圾回收机制。不过可以看到,[[scope]] 中保存着 变量,如果 该变量 占的内存不被释放,一旦这样的情况过多,内存占用过大,就会造成内存泄漏 和 性能问题。

八、闭包的概念

一般说的闭包指的都是闭包函数。

引用高程(《JavaScript高级程序设计》)中关于闭包说法:

闭包是指有权访问另一个函数作用域中变量的函数

通过上面说的那么多,你品,你细品。。。


九、闭包的本质

我认为就是为了形成作用域链。你品,你细品。。。

再来个有趣经典的例子:

function timer () {
  for (var i=1; i<=5; i++) {
    setTimeout(function(){
      console.log(i);
    },i*1000);
  }
}

timer()

//每隔一秒输出一个6,共5个。
复制代码

是不是跟你想的不一样?其实,这个例子重点就在setTimeout函数上,这个函数的第一个参数接受一个函数作为回调函数,这个回调函数并不会立即执行,它会在当前代码执行完,并在给定的时间后执行。这样就导致了上面情况的发生。

注:这里用一个函数包裹起来了,这样,你可以通过 debugger,会发现,这里也形成闭包了。闭包函数是每一个匿名函数,闭包对象是是关于timer 的,保存着变量 i

可以下面对这个例子进行变形,可以有助于你的理解把:

function timer () {
  var i = 1;
  while(i <= 5){
    setTimeout(function(){
      console.log(i);
    },i*1000)

    i = i+1;
  }
}

timer()

复制代码

正因为,setTimeout里的第一个函数不会立即执行,当这段代码执行完之后,i 已经 被赋值为6了(等于5时,进入循环,最后又加了1),所以 这时再执行setTimeout 的回调函数,读取 i 的值,回调函数作用域内没有i,向上读取,上级作用域内i的值就是6了。但是 i * 1000,是立即执行的,所以,每次读的 i 值 都是对的。

这时候,就需要再用个闭包函数来保存每个循环时 i 不同的值。

function makeClosures(i){     // 这个函数使用了 上级作用域中的 `i`,形成闭包函数。
    var i = i;    //这步是不需要的,为了让看客们看的轻松点
    return function(){
        console.log(i);     //匿名没有执行,它可以访问i 的值,保存着这个i 的值。
    }
}

function timer() {
  for (var i=1; i<=5; i++) {
    setTimeout(makeClosures(i),i*1000);  
    
    //这里简单说下,这里makeClosures(i), 是函数执行,并不是传参,不是一个概念
    //每次循环时,都执行了makeClosures函数,形成一个闭包函数,保存含有 `i` 的闭包对象(这个例子就是 5个 闭包函数保存各自的闭包对象)。
    //然后每次都返回了一个没有被执行的匿名函数,(这里就是返回了5个匿名函数)。
    //每个匿名函数都是一个局部作用域,它的上级作用域就是 makeClosures 闭包函数。
    //因此,每个匿名函数执行时,读取`i`值,都是上级作用域内保存的值,是不一样的。所以,就得到了想要的结果
  }
}

timer()

//1
//2
//3
//4
//5
复制代码

你可能在别处,或者自己想到了下面这种解法:

for (var i=1; i<=5; i++) {
    (function(i){
        setTimeout(function(){
            console.log(i);
        },i*1000);
    })(i);
}
复制代码

这个例子不仅利用了闭包,而且还利用了立即执行函数 来模拟 函数作用域 来解决的。

做下变形,你再看看:

for (var i=1; i<=5; i++) {
    function f(i){
        setTimeout(function(){
            console.log(i);
        },i*1000);
    };
    
    f(i);
}
复制代码


附录:

其实这道题,知道ES6let 关键词,估计也想到了另一个解法:

for (let i=1; i<=5; i++) {   //这里的关键就是使用的let 关键词,来形成块级作用域
    setTimeout(function(){
        console.log(i);
    },i*1000);
}
复制代码

我不知道,大家有没有疑惑啊,为啥使用了块级作用域就可以了呢。反正我当初就纠结了半天。

18年 11月 2日修正:

这个答案的关键就在于 块级作用域的规则了。它让let 声明的变量只在{}内有效,外部是访问不了的。

做下变形,这个是为了方便理解的,事实并非如此:

for (var i=1; i<=5; i++) {
    let j = i;
    setTimeout(function(){
        console.log(j);
    },j*1000);
}
复制代码

当for 的() 内使用 let时,for 循环就存在两个作用域,() 括号里的父作用域,和 {} 中括号里的 子作用域。

每次循环都会创建一个 子作用域。保存着父作用域传来的值,这样,每个子作用域内的值都是不同的。当setTimeout 的匿名函数执行时,自己的作用域没有i 的值,向上读取到了该 子作用域i 值。因此每次的值才会不一样。

你要是喜欢折腾,你会发现,块级作用域的表现跟函数作用域一样,子作用域中使用它的变量,它也会形成一个块级对象,被写入到 函数的 [[scope]] 中。

文章分类
前端
文章标签