JavaScript执行(二):闭包和执行上下文到底是怎么回事?

你好,我是winter。

在上一课,我们了解了JavaScript执行中最粗粒度的任务:传给引擎执行的代码段。并且,我们还根据“由JavaScript引擎发起”还是“由宿主发起”,分成了宏观任务和微观任务,接下来我们继续去看一看更细的执行粒度。

一段JavaScript代码可能会包含函数调用的相关内容,从今天开始,我们就用两节课的时间来了解一下函数的执行。

我们今天要讲的知识在网上有不同的名字,比较常见的可能有:

  • 闭包;
  • 作用域链;
  • 执行上下文;
  • this值。

实际上,尽管它们是表示不同的意思的术语,所指向的几乎是同一部分知识,那就是函数执行过程相关的知识。我们可以简单看一下图。

看着也许会有点晕,别着急,我会和你共同理一下它们之间的关系。

当然,除了让你理解函数执行过程的知识,理清这些概念也非常重要。所以我们先来讲讲这个有点复杂的概念:闭包。

闭包

闭包翻译自英文单词closure,这是个不太好翻译的词,在计算机领域,它就有三个完全不相同的意义:编译原理中,它是处理语法产生式的一个步骤;计算几何中,它表示包裹平面点集的凸多边形(翻译作凸包);而在编程语言领域,它表示一种函数。

闭包这个概念第一次出现在1964年的《The Computer Journal》上,由P. J. Landin在《The mechanical evaluation of expressions》一文中提出了applicative expression和closure的概念。

在上世纪60年代,主流的编程语言是基于lambda演算的函数式编程语言,所以这个最初的闭包定义,使用了大量的函数式术语。一个不太精确的描述是“带有一系列信息的λ表达式”。对函数式语言而言,λ表达式其实就是函数。

我们可以这样简单理解一下,闭包其实只是一个绑定了执行环境的函数,这个函数并不是印在书本里的一条简单的表达式,闭包与普通函数的区别是,它携带了执行的环境,就像人在外星中需要自带吸氧的装备一样,这个函数也带有在程序中生存的环境。

这个古典的闭包定义中,闭包包含两个部分。

  • 环境部分
    • 环境
    • 标识符列表
  • 表达式部分

当我们把视角放在JavaScript的标准中,我们发现,标准中并没有出现过closure这个术语,但是,我们却不难根据古典定义,在JavaScript中找到对应的闭包组成部分。

  • 环境部分
    • 环境:函数的词法环境(执行上下文的一部分)
    • 标识符列表:函数中用到的未声明的变量
  • 表达式部分:函数体

至此,我们可以认为,JavaScript中的函数完全符合闭包的定义。它的环境部分是函数词法环境部分组成,它的标识符列表是函数中用到的未声明变量,它的表达式部分就是函数体。

这里我们容易产生一个常见的概念误区,有些人会把JavaScript执行上下文,或者作用域(Scope,ES3中规定的执行上下文的一部分)这个概念当作闭包。

实际上JavaScript中跟闭包对应的概念就是“函数”,可能是这个概念太过于普通,跟闭包看起来又没什么联系,所以大家才不自觉地把这个概念对应到了看起来更特别的“作用域”吧(其实我早年也是这么理解闭包,直到后来被朋友纠正,查了资料才改正过来)。

执行上下文:执行的基础设施

相比普通函数,JavaScript函数的主要复杂性来自于它携带的“环境部分”。当然,发展到今天的JavaScript,它所定义的环境部分,已经比当初经典的定义复杂了很多。

JavaScript中与闭包“环境部分”相对应的术语是“词法环境”,但是JavaScript函数比λ函数要复杂得多,我们还要处理this、变量声明、with等等一系列的复杂语法,λ函数中可没有这些东西,所以,在JavaScript的设计中,词法环境只是JavaScript执行上下文的一部分。

JavaScript标准把一段代码(包括函数),执行所需的所有信息定义为:“执行上下文”。

因为这部分术语经历了比较多的版本和社区的演绎,所以定义比较混乱,这里我们先来理一下JavaScript中的概念。

执行上下文在ES3中,包含三个部分。

  • scope:作用域,也常常被叫做作用域链。
  • variable object:变量对象,用于存储变量的对象。
  • this value:this值。

在ES5中,我们改进了命名方式,把执行上下文最初的三个部分改为下面这个样子。

  • lexical environment:词法环境,当获取变量时使用。
  • variable environment:变量环境,当声明变量时使用。
  • this value:this值。

在ES2018中,执行上下文又变成了这个样子,this值被归入lexical environment,但是增加了不少内容。

  • lexical environment:词法环境,当获取变量或者this值时使用。
  • variable environment:变量环境,当声明变量时使用。
  • code evaluation state:用于恢复代码执行位置。
  • Function:执行的任务是函数时使用,表示正在被执行的函数。
  • ScriptOrModule:执行的任务是脚本或者模块时使用,表示正在被执行的代码。
  • Realm:使用的基础库和内置对象实例。
  • Generator:仅生成器上下文有这个属性,表示当前生成器。

我们在这里介绍执行上下文的各个版本定义,是考虑到你可能会从各种网上的文章中接触这些概念,如果不把它们理清楚,我们就很难分辨对错。如果是我们自己使用,我建议统一使用最新的ES2018中规定的术语定义。

尽管我们介绍了这些定义,但我并不打算按照JavaScript标准的思路,从实现的角度去介绍函数的执行过程,这是不容易被理解的。

我想试着从代码实例出发,跟你一起推导函数执行过程中需要哪些信息,它们又对应着执行上下文中的哪些部分。

比如,我们看以下的这段JavaScript代码:

var b = {}
let c = 1
this.a = 2;

要想正确执行它,我们需要知道以下信息:

  1. var 把 b 声明到哪里;
  2. b 表示哪个变量;
  3. b 的原型是哪个对象;
  4. let 把 c 声明到哪里;
  5. this 指向哪个对象。

这些信息就需要执行上下文来给出了,这段代码出现在不同的位置,甚至在每次执行中,会关联到不同的执行上下文,所以,同样的代码会产生不一样的行为。

在这两篇文章中,我会基本覆盖执行上下文的组成部分,本篇我们先讲var声明与赋值,let,realm三个特性来分析上下文提供的信息,分析执行上下文中提供的信息。

var 声明与赋值

我们来分析一段代码:

var b = 1

通常我们认为它声明了b,并且为它赋值为1,var声明作用域函数执行的作用域。也就是说,var会穿透for 、if等语句。

在只有var,没有let的旧JavaScript时代,诞生了一个技巧,叫做:立即执行的函数表达式(IIFE),通过创建一个函数,并且立即执行,来构造一个新的域,从而控制var的范围。

由于语法规定了function关键字开头是函数声明,所以要想让函数变成函数表达式,我们必须得加点东西,最常见的做法是加括号。

(function(){
    var a;
    //code
}());


(function(){
    var a;
    //code
})();

但是,括号有个缺点,那就是如果上一行代码不写分号,括号会被解释为上一行代码最末的函数调用,产生完全不符合预期,并且难以调试的行为,加号等运算符也有类似的问题。所以一些推荐不加分号的代码风格规范,会要求在括号前面加上分号。

;(function(){
    var a;
    //code
}())


;(function(){
    var a;
    //code
})()

我比较推荐的写法是使用void关键字。也就是下面的这种形式。

void function(){
    var a;
    //code
}();

这有效避免了语法问题,同时,语义上void运算表示忽略后面表达式的值,变成undefined,我们确实不关心IIFE的返回值,所以语义也更为合理。

值得特别注意的是,有时候var的特性会导致声明的变量和被赋值的变量是两个b,JavaScript中有特例,那就是使用with的时候:

var b;
void function(){
    var env = {b:1};
    b = 2;
    console.log("In function b:", b);
    with(env) {
        var b = 3;
        console.log("In with b:", b);
    }
}();
console.log("Global b:", b);

在这个例子中,我们利用立即执行的函数表达式(IIFE)构造了一个函数的执行环境,并且在里面使用了我们一开头的代码。

可以看到,在Global function with三个环境中,b的值都不一样,而在function环境中,并没有出现var b,这说明with内的var b作用到了function这个环境当中。

var b = {} 这样一句对两个域产生了作用,从语言的角度是个非常糟糕的设计,这也是一些人坚定地反对在任何场景下使用with的原因之一。

let

let是 ES6开始引入的新的变量声明模式,比起var的诸多弊病,let做了非常明确的梳理和规定。

为了实现let,JavaScript在运行时引入了块级作用域。也就是说,在let出现之前,JavaScript的 if for 等语句皆不产生作用域。

我简单统计了下,以下语句会产生let使用的作用域:

  • for;
  • if;
  • switch;
  • try/catch/finally。

Realm

在最新的标准(9.0)中,JavaScript引入了一个新概念Realm,它的中文意思是“国度”“领域”“范围”。这个英文的用法就有点比喻的意思,几个翻译都不太适合JavaScript语境,所以这里就不翻译啦。

我们继续来看这段代码:

var b = {}

在 ES2016 之前的版本中,标准中甚少提及{}的原型问题。但在实际的前端开发中,通过iframe等方式创建多window环境并非罕见的操作,所以,这才促成了新概念Realm的引入。

Realm中包含一组完整的内置对象,而且是复制关系。

对不同Realm中的对象操作,会有一些需要格外注意的问题,比如 instanceOf 几乎是失效的。

以下代码展示了在浏览器环境中获取来自两个Realm的对象,它们跟本土的Object做instanceOf时会产生差异:

var iframe = document.createElement('iframe')
document.documentElement.appendChild(iframe)
iframe.src="javascript:var b = {};"

var b1 = iframe.contentWindow.b;
var b2 = {};

console.log(typeof b1, typeof b2); //object object

console.log(b1 instanceof Object, b2 instanceof Object); //false true

可以看到,由于b1、 b2由同样的代码“ {} ”在不同的Realm中执行,所以表现出了不同的行为。

结语

在今天的课程中,我帮你梳理了一些概念:有编程语言的概念闭包,也有各个版本中的JavaScript标准中的概念:执行上下文、作用域、this值等等。

之后我们又从代码的角度,分析了一些执行上下文中所需要的信息,并从varlet、对象字面量等语法中,推导出了词法作用域、变量作用域、Realm的设计。

最后留给你一个问题:你喜欢使用let还是var?听过今天的课程,你的想法是否有改变呢?为什么?

精选留言

  • Geek_56013e

    2019-02-27 00:49:57

    老师您的专业知识太强了,文中包含很多专业术语,在介绍某专业术语时带上了其他专业术语,而这些带上的专业术语部分在网上搜也是解释不清,导致很多地方看不懂、看起来比较费劲、只能猜测大意。比如对于「realm」的描述,只提了中文意思是“国度”“领域”“范围”和“包含一组完整的内置对象,而且是复制关系”,看完文章后,在js领域还是不清楚具体「realm」是什么含义,只能大概猜测。希望老师后续文章如果解释某专业术语时带上的其他专业术语时,能以日常常见代码为例解释。
  • James Bond

    2019-03-12 12:33:00

    说了半天闭包是什么呢?跟普通函数有什么区别呢!
  • wingsico

    2020-04-07 12:32:17

    在JS中,函数其实就是闭包,不管该函数内部是否使用外部变量,它都是一个闭包。如闭包定义的那样,由环境和表达式组成,作为js函数,环境为词法环境,而表达式就是函数本身。而词法环境是执行上下文的一部分,执行上下文包括 this 绑定, 词法环境和变量环境。词法环境是随着执行上下文一起创建的,在函数/脚本/eval执行时创建。

    理解闭包,首先需要理解闭包是什么类型的东西,闭包实际上指的是函数,搞清楚问题的对象究竟是谁,而很多人会把环境/作用域等其他的东西当做闭包,是对闭包的概念类型的错误理解。那么知道了闭包是函数,那么闭包应该是什么样的函数呢?也就是含有环境的函数,很明显,在js中,任何一个函数都有着自己的环境,这个环境让我们可以去找到定义的变量内部的this、外部作用域。

    很多人认为,要让一个函数能去访问某个应该被回收的内存空间,但由于函数存在对该内存空间的变量的引用而不可回收,这样才形成了闭包。那么试问一下,这里你到底是把这个内存空间当做闭包呢?还是引用这块内存空间的函数当闭包呢?假如是前者,则和把环境当闭包的人犯了同样的错误,假如是后者,现在的这个函数实际上和你定义的普通函数本质上没有区别,都含有自己的环境,只不过这个函数的环境多了一些,但本质没有区别。理解了这点,你才能从上面的错误理解中解脱出来。
  • _(:з」∠)_

    2019-04-27 17:43:24

    let 和 var 都不好用,98%的情况都是用 const
  • 麦哲伦

    2019-02-27 22:43:16

    老师能解释下这个么?
    var b = 10;
    (function b(){
    b = 20;
    console.log(b); // [Function: b]
    })();
    作者回复

    这个地方比较特殊,"具有名称的函数表达式"会在外层词法环境和它自己执行产生的词法环境之间产生一个词法环境,再把自己的名称和值当作变量塞进去,所以你这里的b = 20 并没有改变外面的b,而是试图改变一个只读的变量b。

    这块知识有点偏,随便看看就好。

    2019-03-01 17:34:58

  • 水瓶瓶盖盖

    2019-03-17 13:18:32

    希望讲解能给通俗易懂一些。专业词汇太多,生涩
  • K4SHIFZ

    2019-04-20 23:42:09

    凡是{}包裹的代码都会产生let/const作用域吧?除了文中提到的for等,还有while,do while,代码块等
  • Zach

    2019-04-25 10:16:06

    老师,关于realm最后一个示例

    var iframe = document.createElement('iframe')
    document.documentElement.appendChild(iframe)
    iframe.src="javascript:var b = {};"
    var b1 = iframe.contentWindow.b;
    var b2 = {};
    console.log(typeof b1, typeof b2); //object object
    console.log(b1 instanceof Object, b2 instanceof Object); //false true

    应该有点问题,typeof b1 的结果在chrome和Firefox中都显示为 undefined object
  • Geek_f51da4

    2019-06-12 15:20:10

    老师,闭包我是这样理解的,函数里边的函数,这样的理解对吗
    作者回复

    完全错了

    2019-11-20 12:02:39

  • 张祥儒

    2019-02-26 10:19:38

    winter大大,我觉得应该用global object,和active object 来解释这个闭包,作用域,执行器上下文。
    作者回复

    这是ES3里的解释法,现在已经解释不了很多语法了。

    2019-03-01 17:20:01

  • Geeker

    2019-04-14 18:03:06

    老师,可否稍加解释一下执行上下文的分类? 网络上的文章说“ JS 中可执行的代码可分为三种类型:全局代码、函数代码、eval 代码,对应三种执行上下文(全局执行上下文、函数执行上下文、eval 执行上下文),在 ECMAScript 2018 中没有找到这种说法的依据。我的意思是,我不太清楚这些文章的说法是否正确,是否不够全面。
  • 乃乎

    2019-02-26 18:49:27

    更喜欢 const 哈哈
  • 半橙汁

    2020-10-23 14:46:20

    闭包指的是那些引用了另一个函数作用域中变量的函数,通常是在嵌套函数中实现的。---高程4中的解释
    好多问题,结合着书籍来看,就没那么晦涩难懂了~
    书中自有黄金屋,书中自有颜如玉~
  • Tokiomi

    2020-07-15 14:52:23

    看晕了。。
    http://www.ruanyifeng.com/blog/2009/08/learning_javascript_closures.html
    阮一峰的闭包定义:
    "各种专业文献上的"闭包"(closure)定义非常抽象,很难看懂。我的理解是,闭包就是能够读取其他函数内部变量的函数。
    由于在Javascript语言中,只有函数内部的子函数才能读取局部变量,因此可以把闭包简单理解成"定义在一个函数内部的函数"。
    所以,在本质上,闭包就是将函数内部和函数外部连接起来的一座桥梁。"
    所以Winter大佬的定义和阮一峰的定义哪个对。。。
  • beilunyang

    2019-10-21 21:13:41

    闭包其实是一个绑定了执行环境的函数。
    var foo = 'foo';
    function printFoo() {
    console.log(foo);
    }
    printFoo();

    所以printFoo这个函数是一个闭包,对吗
    作者回复

    对,其实JS里任何函数都是闭包......

    2019-11-20 11:13:53

  • 比利利

    2019-03-26 23:39:47

    我认为在目前的环境下,var已经没有存在的必要了,所有以前用var的情况都可以通过let和const代替,而且let和const更加符合大多数编程语言的习惯,而且现在有babel的话,写ES6语法也非常安全。
  • Snow同學

    2019-03-23 15:29:20

    函数就是闭包,这个理解对吗?
  • CC

    2019-02-26 02:54:00

    今天是自己第一次结构性整理清楚 JavaScript 的函数部分。原来它除了函数体之外,还包括了函数所处的环境,而其中的词法环境,其实只是执行上下文七个部分中的的一支。

    个人感觉 var 声明在不同的执行上下文中相对 let 更容易出错,同时也会增加冗余的临时变量。比如在 for loop 中,会遇到需要为不同的 loop 声明 i、j、k 变量。

    代码不仅是写给机器看,也是写给同行看的。let 会使代码更加简洁易读。
  • 疯羊先生

    2020-12-29 18:21:54

    看完评论,我觉得闭包待分成广义的和狭义的了,简直了,婆说婆有理的感觉。。。广义--闭包就是函数函数就是闭包,狭义闭包就是引用另一个函数内部变量的函数。。。懵逼树上懵逼果,懵逼树下你和我
  • 霍光传不可不读

    2019-04-11 10:15:35

    仔细看了下维基百科上面闭包的定义:闭包是一个record,它存储了一个函数和它的环境,这个环境存储了该函数的自由变量,js的函数完全符合这个定义,所以说js的函数其实就是闭包。倒是普通函数有点特别,我自己理解,这样的函数才是普通函数:
    function(a, b) {
    const c = 10
    return a + b + c;
    }
    这个函数只访问了自己的函数作用域内部的变量和参数,这样的函数才是所谓的普通函数,不知道这样理解对不对?