zl程序教程

您现在的位置是:首页 >  其它

当前栏目

作用域和闭包

闭包 作用域
2023-09-11 14:15:02 时间


第一章 作用域是什么

编译原理

javascript是一门编译语言,但与传统的编译语言不同,它不是提前编译的,编译结果也不能在分布式系统中进行移植。
在传统编译语言的流程中,程序中的一段源代码在执行之前会经历三个步骤,统称为“编译”。
  • 分词/词法分析(Tokenizing/Lexing)
    这个过程会将由字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代码块被称为词法单元(token)。例如,考虑程序var a = 2;。这段程序通常会被分解成为下面这些词法单元:var、a、=、2 、;空格是否会被当作词法单元,取决于空格在这门语言中是否具有意义。
  • 解析/语法分析(Parsing)
    这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree,AST)。var a = 2; 的抽象语法树中可能会有一个叫作VariableDeclaration 的顶级节点,接下来是一个叫作Identifier(它的值是a)的子节点,以及一个叫作AssignmentExpression的子节点。AssignmentExpression 节点有一个叫作NumericLiteral(它的值是2)的子节点。
  • 代码生成
    AST 转换为可执行代码的过程称被称为代码生成。这个过程与语言、目标平台等息息相关。

比起那些编译过程只有三个步骤的语言的编译器,JavaScript 引擎要复杂得多。

对于JavaScript 来说,大部分情况下编译发生在代码执行前的几微秒(甚至更短!)的时间内。在我们所要讨论的作用域背后,JavaScript 引擎用尽了各种办法(比如JIT,可以延迟编译甚至实施重编译)来保证性能最佳。

理解作用域

将这个过程模拟成几个人物之间的对话

演员表

引擎:从头到尾负责整个JavaScript 程序的编译及执行过程。

编译器:引擎的好朋友之一,负责语法分析及代码生成等脏活累活

作用域:引擎的另一位好朋友,负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。
对话
var a = 2;

编译器首先会将这段程序分解成词法单元,然后将词法单元解析成一个树结构。但是当编译器开始进行代码生成时,它对这段程序的处理方式会和预期的有所不同。

事实上编译器会进行如下处理。

  1. 遇到var a,编译器会询问作用域是否已经有一个该名称的变量存在于同一个作用域的集合中。如果是,编译器会忽略该声明,继续进行编译;否则它会要求作用域在当前作用域的集合中声明一个新的变量,并命名为a。

  2. 接下来编译器会为引擎生成运行时所需的代码,这些代码被用来处理a = 2 这个赋值操作。引擎运行时会首先询问作用域,在当前的作用域集合中是否存在一个叫作a 的变量。如果是,引擎就会使用这个变量;如果否,引擎会继续查找该变量

总结:变量的赋值操作会执行两个动作,首先编译器会在当前作用域中声明一个变量(如果之前没有声明过),然后在运行时引擎会在作用域中查找该变量,如果能够找到就会对它赋值。

编译器有话说

在我们的例子中,引擎会为变量a 进行LHS 查询。另外一个查找的类型叫作RHS
我打赌你一定能猜到“L”和“R”的含义,它们分别代表左侧和右侧。
什么东西的左侧和右侧?是一个赋值操作的左侧和右侧

RHS 查询与简单地查找某个变量的值别无二致,而LHS 查询则是试图找到变量的容器本身,从而可以对其赋值。

console.log( a );

其中对a 的引用是一个RHS 引用,因为这里a 并没有赋予任何值。相应地,需要查找并取得a 的值,这样才能将值传递给console.log(..)。

a = 2;

这里对a 的引用则是LHS 引用,因为实际上我们并不关心当前的值是什么,只是想要为=2 这个赋值操作找到一个目标。

LHS 和RHS 的含义是“赋值操作的左侧或右侧”并不一定意味着就是“=赋值操作符的左侧或右侧”。赋值操作还有其他几种形式,因此在概念上最好将其理解为“赋值操作的目标是谁(LHS)”以及“谁是赋值操作的源头(RHS)”。

function foo(a) {
    console.log( a ); // 2
}
foo( 2 );

其中既有LHS 也有RHS 引用,foo(..) 函数的调用需要对foo 进行RHS 引用,为了给参数a(隐式地)分配值,需要进行一次LHS 查询。

这里还有对a 进行的RHS 引用, 并且将得到的值传给了console.log(..)。console.log(..) 本身也需要一个引用才能执行,因此会对console 对象进行RHS 查询。

作用域嵌套

当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。因此,在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量,或抵达最外层的作用域(也就是全局作用域)为止。

遍历嵌套作用域链的规则很简单:引擎从当前的执行作用域开始查找变量,如果找不到,就向上一级继续查找。当抵达最外层的全局作用域时,无论找到还是没找到,查找过程都会停止。

异常

在变量还没有声明(在任何作用域中都无法找到该变量)的情况下,LHS 和RHS这两种查询的行为是不一样的。

function foo(a) {
  console.log( a + b );
  b = a;
}
foo( 2 );

第一次对b 进行RHS 查询时是无法找到该变量的。如果RHS 查询在所有嵌套的作用域中遍寻不到所需的变量,引擎就会抛出ReferenceError异常。

小结

作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。如果查找的目的是对变量进行赋值,那么就会使用LHS 查询;如果目的是获取变量的值,就会使用RHS 查询。

LHS 和RHS 查询都会在当前执行作用域中开始,如果有需要(也就是说它们没有找到所需的标识符),就会向上级作用域继续查找目标标识符,这样每次上升一级作用域(一层楼),最后抵达全局作用域(顶层),无论找到或没找到都将停止。

不成功的RHS 引用会导致抛出ReferenceError 异常。不成功的LHS 引用会导致自动隐式地创建一个全局变量(非严格模式下),该变量使用LHS 引用的目标作为标识符,或者抛出ReferenceError 异常(严格模式下)。

第二章 词法作用域

词法阶段

词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变(大部分情况下是这样的)。

function foo(a) {
  var b = a * 2;
  function bar(c) {
    console.log( a, b, c );
  }
  bar( b * 3 );
}
foo( 2 ); // 2, 4, 12

1包含着整个全局作用域,其中只有一个标识符:foo。
2包含着foo 所创建的作用域,其中有三个标识符:a、bar 和b。
3包含着bar 所创建的作用域,其中只有一个标识符:c。
 

无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处的位置决定。

欺骗词法

欺骗词法作用域会导致性能下降。

eval

不推荐使用eval()

function foo(str, a) {
  eval( str ); // 欺骗!
  console.log( a, b );
}
var b = 2;
foo( "var b = 3;", 1 ); // 1, 3
这段代码实际上在foo(..) 内部创建了一个变量b,并遮蔽了外部(全局)作用域中的同名变量。
在严格模式的程序中,eval(..) 在运行时有其自己的词法作用域,意味着其中的声明无法修改所在的作用域。
function foo(str) {
  "use strict";
  eval( str );
  console.log( a ); // ReferenceError: a is not defined
}
foo( "var a = 2" );
new Function(..) 函数的行为也很类似,最后一个参数可以接受代码字符串,并将其转化为动态生成的函数(前面的参数是这个新生成的函数的形参)。这种构建函数的语法比eval(..) 略微安全一些,但也要尽量避免使用。
with
不推荐使用with
var obj = {
  a: 1,
  b: 2,
  c: 3
};
// 单调乏味的重复"obj"
obj.a = 2;
obj.b = 3;
obj.c = 4;
// 简单的快捷方式
with (obj) {
  a = 3;
  b = 4;
  c = 5;
}

再看下面的例子:

function foo(obj) {
  with (obj) {
    a = 2;
  }
}
var o1 = {
  a: 3
};
var o2 = {
  b: 3
};
foo( o1 );
console.log( o1.a ); // 2
foo( o2 );
console.log( o2.a ); // undefined
console.log( a ); // 2——不好,a 被泄漏到全局作用域上了!

当我们将o2 作为作用域时,其中并没有a 标识符,因此进行了正常的LHS 标识符查找

o2 的作用域、foo(..) 的作用域和全局作用域中都没有找到标识符a,因此当a=2 执行时,自动创建了一个全局变量(因为是非严格模式)。

小结

词法作用域意味着作用域是由书写代码时函数声明的位置来决定的。编译的词法分析阶段基本能够知道全部标识符在哪里以及是如何声明的,从而能够预测在执行过程中如何对它们进行查找。

第三章 函数作用域和块作用域

函数中的作用域

function foo(a) {
  var b = 2;
  // 一些代码
  function bar() {
    // ...
  }
  // 更多的代码
  var c = 3;
}

在这个代码片段中,foo(..) 的作用域气泡中包含了标识符a、b、c 和bar。

由于标识符a、b、c 和bar 都附属于foo(..) 的作用域气泡,因此无法从foo(..) 的外部对它们进行访问。

函数作用域的含义是指,属于这个函数的全部变量都可以在整个函数的范围内使用及复用(事实上在嵌套的作用域中也可以使用)。

隐藏内部实现

从所写的代码中挑选出一个任意的片段,然后用函数声明对它进行包装,实际上就是把这些代码“隐藏”起来了。
实际的结果就是在这个代码片段的周围创建了一个作用域气泡,也就是说这段代码中的任何声明(变量或函数)都将绑定在这个新创建的包装函数的作用域中,而不是先前所在的作用域中。换句话说,可以把变量和函数包裹在一个函数的作用域中,然后用这个作用域来“隐藏”它们。

规避冲突

“隐藏”作用域中的变量和函数所带来的另一个好处,是可以避免同名标识符之间的冲突。

1. 全局命名空间

第三方库通常会在全局作用域中声明一个名字足够独特的变量,通常是一个对象。这个对象被用作库的命名空间,所有需要暴露给外界的功能都会成为这个对象(命名空间)的属性,而不是将自己的标识符暴漏在顶级的词法作用域中。

2. 模块管理

使用模块管理器无需将标识符加入到全局作用域中,而是通过依赖管理器的机制将库的标识符显式地导入到另外一个特定的作用域中。

函数作用域

如果函数不需要函数名(或者至少函数名可以不污染所在作用域),并且能够自动运行,这将会更加理想。

var a = 2;
(function foo(){ // <-- 添加这一行
  var a = 3;
  console.log( a ); // 3
})(); // <-- 以及这一行
console.log( a ); // 2

(function foo(){ .. }) 作为函数表达式意味着foo 只能在.. 所代表的位置中被访问,外部作用域则不行。foo 变量名被隐藏在自身中意味着不会非必要地污染外部作用域。

匿名和具名

函数表达式可以匿名也可以具名。

匿名函数表达式有几个缺点:

1. 匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难。

2. 如果没有函数名,当函数需要引用自身时只能使用已经过期的arguments.callee 引用,比如在递归中。另一个函数需要引用自身的例子,是在事件触发后事件监听器需要解绑自身。
3. 匿名函数省略了对于代码可读性/ 可理解性很重要的函数名。一个描述性的名称可以让代码不言自明。

立即执行函数表达式

var a = 2;
(function foo() {
  var a = 3;
  console.log( a ); // 3
})();
console.log( a ); // 2

由于函数被包含在一对( ) 括号内部,因此成为了一个表达式,通过在末尾加上另外一个( ) 可以立即执行这个函数,比如(function foo(){ .. })()。第一个( ) 将函数变成表达式,第二个( ) 执行了这个函数。

这种模式很常见,几年前社区给它规定了一个术语:IIFE,代表立即执行函数表达式(Immediately Invoked Function Expression);

相较于传统的IIFE 形式,很多人都更喜欢另一个改进的形式:(function(){ .. }())。仔细观察其中的区别。第一种形式中函数表达式被包含在( ) 中,然后在后面用另一个() 括号来调用。第二种形式中用来调用的() 括号被移进了用来包装的( ) 括号中。这两种形式在功能上是一致的。选择哪个全凭个人喜好。

(function(){ .. })()
(function(){ .. }())

IIFE 的另一个非常普遍的进阶用法是把它们当作函数调用并传递参数进去

var a = 2;
(function IIFE( global ) {
  var a = 3;
  console.log( a ); // 3
  console.log( global.a ); // 2
})( window );
console.log( a ); // 2

这个模式的另外一个应用场景是解决undefined 标识符的默认值被错误覆盖导致的异常(虽然不常见)。将一个参数命名为undefined,但是在对应的位置不传入任何值,这样就可以保证在代码块中undefined 标识符的值真的是undefined:

undefined = true; // 给其他代码挖了一个大坑!绝对不要这样做!
(function IIFE( undefined ) {
  var a;
  if (a === undefined) {
  console.log( "Undefined is safe here!" );
  }
})();

IIFE 还有一种变化的用途是倒置代码的运行顺序,将需要运行的函数放在第二位,在IIFE执行之后当作参数传递进去。

(function IIFE( def ) {
  def( window );
})(function def( global ) {
  var a = 3;
  console.log( a ); // 3
  console.log( global.a ); // 2
});

块作用域

try/catch

ES3 规范中规定try/catch 的catch 分句会创建一个块作用域,其中声明的变量仅在catch 内部有效。

try {
  undefined(); // 执行一个非法操作来强制制造一个异常
}
catch (err) {
  console.log( err ); // 能够正常执行!
}
console.log( err ); // ReferenceError: err not found

小结

函数是JavaScript 中最常见的作用域单元。本质上,声明在一个函数内部的变量或函数会在所处的作用域中“隐藏”起来,这是有意为之的良好软件的设计原则。
但函数不是唯一的作用域单元。块作用域指的是变量和函数不仅可以属于所处的作用域,也可以属于某个代码块(通常指{ .. } 内部)。
从ES3 开始,try/catch 结构在catch 分句中具有块作用域。
在ES6 中引入了let 关键字(var 关键字的表亲),用来在任意代码块中声明变量。if(..) { let a = 2; } 会声明一个劫持了if 的{ .. } 块的变量,并且将变量添加到这个块中。

第四章 提升

先有鸡还是先有蛋

a = 2;
var a;
console.log( a );

很多开发者会认为是undefined,但是,真正的输出结果是2。

考虑另外一段代码:

console.log( a );
var a = 2;

结果是undefined。

编译器再度来袭

正确的思考思路是,包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理

当你看到var a = 2; 时,可能会认为这是一个声明。但JavaScript 实际上会将其看成两个声明:var a; 和a = 2;。第一个定义声明是在编译阶段进行的。第二个赋值声明会被留在原地等待执行阶段。

这个过程就好像变量和函数声明从它们在代码中出现的位置被“移动”到了最上面。这个过程就叫作提升。

只有声明本身会被提升,而赋值或其他运行逻辑会留在原地。

函数声明会被提升,但是函数表达式却不会被提升。

foo(); // 不是ReferenceError, 而是TypeError!
var foo = function bar() {
  // ...
};

函数优先

函数会首先被提升,然后才是变量。

foo(); // 1
var foo;
function foo() {
  console.log( 1 );
}
foo = function() {
  console.log( 2 );
};

这个代码片段会被引擎理解为如下形式:

function foo() {
  console.log( 1 );
}
foo(); // 1
foo = function() {
  console.log( 2 );
};

应该尽可能避免在块内部声明函数。

foo(); // "b"
var a = true;
if (a) {
  function foo() { console.log("a"); }
}
else {
  function foo() { console.log("b"); }
}

第五章 作用域闭包

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

function foo() {
  var a = 2;
  function bar() {
    console.log( a );
  }
  return bar;
}
var baz = foo();
baz(); // 2 —— 朋友,这就是闭包的效果。

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(); // 妈妈快看呀,这就是闭包!
}

传递函数当然也可以是间接的。

var fn;
function foo() {
  var a = 2;
  function baz() {
    console.log( a );
  }
  fn = baz; // 将baz 分配给全局变量
}
function bar() {
  fn(); // 妈妈快看呀,这就是闭包!
}
foo();
bar(); // 2

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

 无论何时何地,如果将函数(访问它们各自的词法作用域)当作第一级的值类型并到处传递,你就会看到闭包在这些函数中的应用。在定时器、事件监听器、Ajax 请求、跨窗口通信、Web Workers 或者任何其他的异步(或者同步)任务中,只要使用了回调函数,实际上就是在使用闭包!

循环和闭包

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

正常情况下,我们对这段代码行为的预期是分别输出数字1~5,每秒一次,每次一个。
但实际上,这段代码在运行时会以每秒一次的频率输出五次6。

首先解释6 是从哪里来的。这个循环的终止条件是i 不再<=5。条件首次成立时i 的值是6。因此,输出显示的是循环结束时i 的最终值。
仔细想一下,这好像又是显而易见的,延迟函数的回调会在循环结束时才执行。事实上,当定时器运行时即使每个迭代中执行的是setTimeout(.., 0),所有的回调函数依然是在循环结束后才会被执行,因此会每次输出一个6 出来。

根据作用域的工作原理,实际情况是尽管循环中的五个函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个i。

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

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

我们使用IIFE 在每次迭代时都创建一个新的作用域。换句话说,每次迭代我们都需要一个块作用域。

let 声明,可以用来劫持块作用域,并且在这个块作用域中声明一个变量。

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

for 循环头部的let 声明还会有一个特殊的行为。这个行为指出变量在循环过程中不止被声明一次,每次迭代都会声明。随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。

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

模块

function CoolModule() {
  var something = "cool";
  var another = [1, 2, 3];
  function doSomething() {
    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.doSomething()。

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

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

  1. 必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例)。

  2. 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态。

可以对这个模式进行简单的改进来实现单例模式:

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

现代的模块机制

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

var MyModules = (function Manager() {
  var modules = {};
  function define(name, deps, impl) {
    for (var i=0; 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
  };
})();

这段代码的核心是modules[name] = impl.apply(impl, deps)。为了模块的定义引入了包装函数(可以传入任何依赖),并且将返回值,也就是模块的API,储存在一个根据名字来管理的模块列表中

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

MyModules.define( "bar", [], function() {
  function hello(who) {
    return "Let me introduce: " + who;
  }
  return {
    hello: hello
  };
} );
MyModules.define( "foo", ["bar"], function(bar) {
  var hungry = "hippo";
  function awesome() {
    console.log( bar.hello( hungry ).toUpperCase() );
  }
  return {
    awesome: awesome
  };
} );
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 成员。

基于函数的模块并不是一个能被稳定识别的模式(编译器无法识别),它们的API 语义只有在运行时才会被考虑进来。因此可以在运行时修改一个模块的API(参考前面关于公共API 的讨论)。
相比之下,ES6 模块API 更加稳定(API 不会在运行时改变)。由于编辑器知道这一点,因此可以在(的确也这样做了)编译期检查对导入模块的API 成员的引用是否真实存在。如果API 引用并不存在,编译器会在运行时抛出一个或多个“早期”错误,而不会像往常一样在运行期采用动态的解决方案。

bar.js
  function hello(who) {
    return "Let me introduce: " + who;
  }
  export hello;
foo.js
  // 仅从"bar" 模块导入hello()
  import hello from "bar";
  var hungry = "hippo";
  function awesome() {
    console.log(
      hello( hungry ).toUpperCase()
    );
  }
  export awesome;
baz.js
  // 导入完整的"foo" 和"bar" 模块
  module foo from "foo";
  module bar from "bar";
  console.log(
    bar.hello( "rhino" )
  ); // Let me introduce: rhino
  foo.awesome(); // LET ME INTRODUCE: HIPPO

小结

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

模块有两个主要特征:(1)为创建内部作用域而调用了一个包装函数;(2)包装函数的返回值必须至少包括一个对内部函数的引用,这样就会创建涵盖整个包装函数内部作用域的闭包。