Skip to content

作用域闭包 #10

@HecateDK

Description

@HecateDK

作用域闭包

当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行的。

// 下面代码并不是闭包,但bar()对a的引用方法是词法作用域的查找规则是闭包很重要的一部分
function foo(){
  var a = 2;
  function bar(){
    console.log(a);   // 2
  }
  bar();
}
foo();

什么是闭包呢?

function foo(){
  var a = 2;
  function bar(){
    console.log(a);
  }
  return bar;
}
var baz = foo();
baz();            // 这就是闭包
// 函数bar()的词法作用域能够访问foo()内部作用域,然后我们将bar()函数本身当作一个值类型传递
// 在foo()执行后,其返回值(也就是内部的bar()函数)赋值给变量baz,实际上只是通过不同标识符引用了内部的函数bar()
// bar()在其定义的词法作用域以外的地方执行

在foo()执行后,通常会期待foo()的整个内部作用域都被销毁,因为引擎有垃圾回收器用来释放不再使用的内存空间。由于看上去foo()的内容不会再被使用,所以很自然地会考虑将其回收。

但是闭包就能阻止这种回收,但是事实上内部作用域依然存在,没有被回收。那是谁在使用这个内部作用域呢?

是bar()。因为bar()声明的位置,使得它拥有涵盖foo()内部作用域的闭包,使得该作用域能够一直存活,以供bar()在之后任何时间进行引用。

bar()依然持有对该作用域的引用,这个引用就叫做“闭包”。

无论使用何种方式对函数类型的值进行传递,当函数在别处被调用的时候都可以观察到闭包:

function foo(){
  var a = 2;
  function baz(){
    console.log(a);           // 2
  }
  bar(baz);
}
function bar(fn){
  fn();                // 这就是闭包(把内部函数baz传递给bar,当调用这个内部函数时(也就是fn),它涵盖的foo()内部作用域的闭包就可以观察到了)
}

传递函数也可以是间接的:

var fn;
function foo(){
  var a = 2;
  function baz(){
    console.log(a);           // 2
  }
  fn = baz;       // 将baz分配给全局变量
}
foo();
bar();                

无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。

function wait(message){
  setTimeout(function timer(){
    console.log(message);
  },1000);
}
wait('XXX');

上面的代码把一个内部函数timer传递给setTimeout()。timer具有涵盖wait()作用域的闭包,因此还保持有对变量message的引用。

wait()执行1000毫秒后,它的内部作用域并不会消失,timer函数依然保持有wait()作用域的闭包。

在引擎内部,内置的工具函数setTimeout()持有对一个参数的引用,这个参数可能叫做fn或者func,或者其他类似的名字。引擎会调用这个函数,在例子中就是内部的timer函数,而词法作用域在这个过程中保持完整。

这就是闭包。

本质上无论何时何地,如果将(访问它们各自词法作用域的)函数当作第一级的值类型并到处传递,就会看到闭包在这些函数中的应用。

在事件监听、定时器、Ajax请求、跨窗口通信、Web Workers或者任何的异步(或者同步)任务中,只要使用了回调函数,实际上就是在使用闭包。

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

我们尝试假设循环中每个迭代在运行时都会给自己“捕获”一个i副本。但是根据作用域的工作原理,实际上是尽管循环中的五个函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个i。

// 如果作用域是空的,那么仅仅将它们进行封闭是不够的。IFEE只是一个什么都没有的空作用域,它需要一点实质内容才能为我们所用
// 它需要有自己的变量,用来在每个迭代中存储i的值
for(var i = 1; i <= 5;i++){
  (function(){
    var j = i;
    setTimeout(function timer(){
      console.log(j);
    },j*1000);
  })();
}

// 上诉代码的改进
for(var i=1;i<=5;i++){
  (function(j){
    setTimeout(function timer(){
      console.log(j);
    },j*1000);
  })(i);
}

在迭代内使用IIFE会为每一个迭代都生成一个新的作用域,使得延迟函数的回调可以将新的作用域封闭在每个迭代内部,每个迭代中都会含有一个具有正确值的变量供我们访问。

块作用域+闭包

我们使用IIFE在每次迭代时都创建一个新的词法作用域,也就是说,每次迭代都需要一个块级作用域。

本质上,这是将一个块转换成一个可以被关闭的作用域。

for(var i=1;i<=5;i++){
   let j = i;          // 闭包的作用域
   setTimeout(function timer(){
      console.log(j);
    },j*1000);
}

// 改进
for(let i = 1;i<=5;i++){
  setTimeout(function timer(){
    console.log(i);
  },i*1000);
}
模块
function foo(){
  var something = 'cool';
  var another = [1,2,3];
  function dosomthing(){
    console.log(something);
  }
  function doAnother(){
    console.log(another.join("!"));
  }
}

上面这段代码,没有明显的闭包,只有两个私有数据变量something和another,以及doSomething()和doAnother()两个内部函数,它们的词法作用域(而这就是闭包)也就是foo()的内部作用域。

请看下面代码:

function CoolModule(){
  var something = 'cool';
  var another = [1,2,3];
  function doSomthing(){
    console.log(something);
  }
  function doAnother(){
    console.log(another.join("!"));
  }
  return {
    doSomething:doSomething,
    doAnother:doAnother
  };
}
var foo = CoolModule();
foo.doSomething();   // "cool"
foo.doAnother();     // 1!2!3 

这个模式在javascript中被称为模块。最常见的实现模块模式的方法通常被称为模块暴露,这里是其变体。

首先,CoolModule()只是一个函数,必须通过调用它来创建一个模块实例,如果不执行外部函数,内部作用域和闭包都无法被创建。

其次,CoolModule()返回一个用对象字面量语法{ key:value,... }来表示的对象,这个返回的对象中含有对内部函数而不是内部数据变量的引用,我们保持内部数据变量是隐藏且私有的状态。可以把这个对象类型的返回值看作本质上是模块的公共API。

这个对象类型的返回值最终被赋值给外部的变量foo,然后就可以通过它来访问API中的属性方法,比如foo.soSomething()。

从模块中返回一个实际的对象并不是必须的,也可以直接返回一个内部函数。jQuery和$标识符就是jQuery模块的公共API,但它们本身都是函数(由于函数也是对象,所以它们本身也可以拥有属性)。

doSomething()和doAnother()函数具有涵盖模块实例内部作用域的闭包(通过调用CoolModule()实现)。

当通过返回一个含有属性引用的对象的方式的对象的方式来将函数传递到词法作用域外部时,我们已经创造了可以观察和实践闭包的条件。

模块模式需要具备两个必要条件:

  • 必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例)
  • 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态

上一个实例代码中有一个叫作CoolModule()的独立模块创建器,可以被调用任意多次,每次调用都会创建一个新的模块实例。当只需要一个实例的时候,可以像如下代码那样对这个模式改进:

var foo = (function CoolModule(){    // 将模块函数转换成IIFE,立即调用这个函数并将返回值直接赋值给单例的模块实例标识符foo
  var something = 'cool';
  var another = [1,2,3];
  function doSomthing(){
    console.log(something);
  }
  function doAnother(){
    console.log(another.join("!"));
  }
  return {
    doSomething:doSomething,
    doAnother:doAnother
  };
})();
foo.doSomething();   // "cool"
foo.doAnother();     // 1!2!3 

模块也是普通的函数,所以可以接受参数:

function CoolModule(id){
  function identify(){
    console.log(id);
  }
  return {
    identify:identify
  };
}
var foo1 = CoolModule("foo1");
var foo2 = CoolModule("foo2");
foo1.identify();   // "foo1"
foo2.identify();   // "foo2"
模块模式另一个强大的用法是命名将要作为公共API返回的对象:
var foo = (function CoolModule(id){
  function change(){
    // 修改公共API
    pubilcAPI.identify = identify2;
  }
  function identify1(){
    console.log(id);
  }
  function identify2(){
    console.log(id.toUpperCase());
  }
  var publicAPI = {
    change:change,
    identify:identify1
  };
  return publicAPI;
}("foo module");
foo.identify();     // foo module
foo.change();
foo.identify();      // FOO MODULE

通过在模块实例的内部保留对公共API对象的内部引用,可以从内部对模块实例进行修改,包括添加或者删除属性和方法,以及修改它们的值。

现在的模块机制

大多数模块依赖加载器/管理器本质上都是将这个模块定义封装进一个友好API:

var MyModules = (function Manager(){
  var modules = {};
  function define(name,deps,impl){
    for(var i = 1; i <deps.length; i++ ){
      deps[i] = modules[deps[i]];
    }
    modules[name] = impl.apply(impl,deps);
  }
  function get(name){
    return modules[name];
  }
  return {
    define:define,
    get:get
  }
})();

下面展示如何使用它来定义模块:

MyModules.define("bar",[],function(){
  function hello(who){
    return "Let me introduce:" + who;
  }
  return{
    hello:hello
  };
});
MyModules.define("foo",["bar"],function(bar)){   // "foo"和"bar"模块都是通过一个返回公共API的函数来定义的,"foo"甚至还接受"bar"的实例作为依赖参数,并能相应地使用它
  var hungry = 'hippo';
  function awesome(){
    console.log( bar.hello( hungry ).toUpperCase() );
  }
  return{
    awesome:awesoem
  };
});
var bar = MyModules.get("bar");
var foo = MyModules.get("foo");
console.log(
  bar.hello("hippo")
);        // Let me introduce:hippo
foo.awesome();   // LET ME INTRODUCE:HIPPO

它们符合模块模式的两个特点:调用包装了函数定义的包装函数,并且将返回值作为该模块的API。

ES6中的模块机制

在通过模块系统进行加载时,ES6会将文件当作独立的模块来处理。每个模块都可以导入其他模块或特定的API成员,同样也可以导出自己的API成员。

ES6的模块没有“行内”格式,必须被定义在独立的文件中(一个文件一个模块)。浏览器或引擎有一个默认的“模块加载器”可以在导入模块的同时同步地加载模块文件。

// bar.js
function hello(who){
  return "Let me introduce:" + who;
}
export hello;  // export:导出功能:公开在模块中声明的内容,并让其它模块加以使用

// foo.js
// 仅从"bar"模块导入hello()
import hello from "bar";
var hungry = "hippo";
function awesome(){
    console.log( bar.hello( hungry ).toUpperCase() );
}
export awesome;

// baz.js
// 导入完整的"foo"和"bar"模块
module foo from "foo";
module bar from "bar";
console.log(
  bar.hello("thino")
);    // Let me introduce:thino
foo.awesome();   //LET ME INTRODUCE:THINO

import可以将一个模块中的一个或多个API导入到当前作用域中,并分别绑定在一个变量上;
module会将整个模块的API导入并绑定到一个变量上;
export会将当前模块的一个标识符(变量、函数)导出为公共API。

模块文件中的内容会被当作好像包含在作用域闭包中一样来处理,就和前面介绍的函数闭包模块一样。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions