β

从作用域链谈闭包

淡忘~浅思 102 阅读

闭包(closure)是Javascript语言的一个难点,也是它的特色,很多高级应用都要依靠闭包实现。

神马是闭包

关于闭包的概念,是婆说婆有理。因而,我就翻阅了红皮书(p178)上对于闭包的陈述:

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

这概念有点绕,拆分一下。从概念上说,闭包有两个特点:
1、函数
2、能访问另外一个函数作用域中的变量
在ES 6之前,Javascript只有函数作用域的概念,没有块级作用域( 但catch捕获的异常 只能在catch块中访问 )的概念(IIFE可以创建局部作用域)。每个函数作用域都是封闭的,即外部是访问不到函数作用域中的变量。


function getName() {
  var name = "美女的名字";
  console.log(name);     //"美女的名字"
}
function displayName() {
    console.log(name);  //报错
}

但是为了得到美女的名字,不死心的单身汪把代码改成了这样:


function getName() {
  var name = "美女的名字";
  function displayName() {
    console.log(name);   
  }
  return displayName;
}
var 美女 = getName();  
美女()  //"美女的名字"

这下, 美女 是一个闭包了,单身汪想怎么玩就怎么玩了。(但并不推荐单身汪用中文做变量名的写法,大家不要学)。

关于闭包呢,还想再说三点:
1、闭包可以访问当前函数以外的变量


function getOuter(){
  var date = '815';
  function getDate(str){
    console.log(str + date);  //访问外部的date
  }
  return getDate('今天是:'); //"今天是:815"
}
getOuter();

getDate 是一个闭包,该函数执行时,会形成一个作用域A,A中并没有定义变量 date ,但它能在父一级作用域中找到该变量的定义。

2、即使外部函数已经返回,闭包仍能访问外部函数定义的变量


function getOuter(){
  var date = '815';
  function getDate(str){
    console.log(str + date);  //访问外部的date
  }
  return getDate;     //外部函数返回
}
var today = getOuter();
today('今天是:');   //"今天是:815"
today('明天不是:');   //"明天不是:815"

3、闭包可以更新外部变量的值


function updateCount(){
  var count = 0;
  function getCount(val){
    count = val;
    console.log(count);
  }
  return getCount;     //外部函数返回
}
var count = updateCount();
count(815); //815
count(816); //816

作用域链

为毛闭包就能访问外部函数的变量呢?这就要说说Javascript中的作用域链了。
Javascript中有一个 执行环境(execution context) 的概念,它定义了变量或函数有权访问的其它数据,决定了他们各自的行为。每个执行环境都有一个与之关联的 变量对象 ,环境中定义的所有变量和函数都保存在这个对象中。你可以把它当做Javascript的一个普通对象,但是你只能修改它的属性,却不能引用它。

变量对象也是有父作用域的。当访问一个变量时,解释器会首先在当前作用域查找标示符,如果没有找到,就去父作用域找,直到找到该变量的标示符或者不再存在父作用域了,这就是作用域链。

作用域链和原型继承有点类似,但又有点小区别:如果去查找一个普通对象的属性时,在当前对象和其原型中都找不到时,会返回 undefined ;但查找的属性在作用域链中不存在的话就会抛出 ReferenceError

作用域链的顶端是全局对象。对于全局环境中的代码,作用域链只包含一个元素:全局对象。所以,在全局环境中定义变量的时候,它们就会被定义到全局对象中。当函数被调用的时候,作用域链就会包含多个作用域对象。

全局环境

关于作用域链讲得略多(红皮书上有关于作用域及执行环境的详细解释),看一个简单地例子:


// my_script.js
"use strict";

var foo = 1;
var bar = 2;

在全局环境中,创建了两个简单地变量。如前面所说,此时变量对象是全局对象:

js_closure_1
执行上述代码, my_script.js 本身会形成一个执行环境,以及它所引用的变量对象。

Non-nested functions

改动一下代码,创建一个没有函数嵌套的函数:


"use strict";
var foo = 1;
var bar = 2;

function myFunc() {
  //-- define local-to-function variables
  var a = 1;
  var b = 2;
  var foo = 3;

  console.log("inside myFunc");
}

console.log("outside");

//-- and then, call it:
myFunc();

myFunc 被定义的时候, myFunc 的标识符(identifier)就被加到了当前的作用域对象中(在这里就是全局对象),并且这个标识符所引用的是一个函数对象(function object)。函数对象中所包含的是函数的源代码以及其他的属性。其中一个我们所关心的属性就是内部属性 [[scope]] [[scope]] 所指向的就是当前的作用域对象。也就是指的就是函数的标识符被创建的时候,我们所能够直接访问的那个作用域对象(在这里就是全局对象)。

js_closure_2.png

比较重要的一点是: myFunc 所引用的函数对象,其本身不仅仅含有函数的代码,并且还含有 指向其被创建的时候的作用域对象

myFunc 函数被调用的时候,一个新的作用域对象被创建了。新的作用域对象中包含myFunc函数所定义的本地变量,以及其参数(arguments)。这个新的作用域对象的父作用域对象就是在运行myFunc时我们所能直接访问的那个作用域对象。

所以,当myFunc被执行的时候,对象之间的关系如下图所示:

js_closure_3.png

Nested functions

如前面所说,当函数返回没有被引用的时候,就会被垃圾回收器回收。但是对于闭包(函数嵌套是形成闭包的一种简单方式)呢,即使外部函数返回了,函数对象仍会引用它被创建时的作用域对象。


"use strict";

function createCounter(initial) {
  var counter = initial;

  function increment(value) {
    counter += value;
  }

  function get() {
    return counter;
  }

  return {
    increment: increment,
    get: get
  };
}

var myCounter = createCounter(100);

console.log(myCounter.get());   // 返回 100
myCounter.increment(5);
console.log(myCounter.get());   // 返回 105

当调用 createCounter(100) 时,对象之间的关系如下图所示:

js_closure_4

内嵌函数 increment get 都有指向 createCounter(100) scope 的引用。如果 createCounter(100) 没有任何返回值,那么 createCounter(100) scope 不再被引用,于是就可以被垃圾回收。但是因为 createCounter(100) 实际上是有返回值的,并且返回值被存储在了 myCounter 中,所以对象之间的引用关系变成了如下图所示:

js_closure_5

需要用点时间思考的是:即使 createCounter(100) 已经返回,但是其作用域仍在,并能且只能被内联函数访问。可以通过调用 myCounter.increment() myCounter.get() 来直接访问 createCounter(100) 的作用域。

myCounter.increment() myCounter.get() 被调用时,新的作用域对象会被创建,并且该作用域对象的父作用域对象会是当前可以直接访问的作用域对象。此时,引用关系如下:

js_closure_6

当执行到 return counter; 时,在 get() 所在的作用域并没有找到对应的标示符,就会沿着作用域链往上找,直到找到变量 counter ,然后返回该变量。

调用 increment(5) 则会更有意思:

js_closure_6

当单独调用 increment(5) 时,参数 value 会存贮在当前的作用域对象。函数要访问 value ,能马上在当前作用域找到该变量。但是当函数要访问 counter 时,并没有找到,于是沿着作用域链向上查找,在 createCounter(100) 的作用域找到了对应的标示符, increment() 就会修改 counter 的值。除此之外,没有其他方式来修改这个变量。闭包的强大也在于此,能够存贮私有数据。

Similar function objects, different scope objects

对于上面的 counter 示例,再说点扩展的事。看代码:


//myScript.js
"use strict";

function createCounter(initial) {
  /* ... see the code from previous example ... */
}

//-- create counter objects
var myCounter1 = createCounter(100);
var myCounter2 = createCounter(200);

myCounter1 myCounter2 创建之后,关系图是酱紫的:

js_closure_7

在上面的例子中, myCounter1.increment myCounter2.increment 的函数对象拥有着一样的代码以及一样的属性值(name,length等等),但是它们的 [[scope]] 指向的是不一样的作用域对象。

这才有了下面的结果:


var a, b;
a = myCounter1.get();   // a 等于 100
b = myCounter2.get();   // b 等于 200

myCounter1.increment(1);
myCounter1.increment(2);

myCounter2.increment(5);

a = myCounter1.get();   // a 等于 103
b = myCounter2.get();   // b 等于 205

作用域和 this

作用域会存储变量,但 this 并不是作用域的一部分,它取决于函数调用时的方式。关于 this 指向的总结,可以看这篇文章: JavaScript面试问题:事件委托和this

参考文章:
How do JavaScript closures work under the hood

转载请注明: 淡忘~浅思 » 从作用域链谈闭包

作者:淡忘~浅思
Web开发,LAMP分享,记录生活
原文地址:从作用域链谈闭包, 感谢原作者分享。