CategoryResourceRepost/极客时间专栏/重学前端/模块一:JavaScript/JavaScript执行(二):闭包和执行上下文到底是怎么回事?.md
louzefeng d3828a7aee mod
2024-07-11 05:50:32 +00:00

12 KiB
Raw Permalink Blame History

你好我是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执行上下文或者作用域ScopeES3中规定的执行上下文的一部分这个概念当作闭包。

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

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

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

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

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

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

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

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

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

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

        在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声明与赋值letrealm三个特性来分析上下文提供的信息分析执行上下文中提供的信息。

        var 声明与赋值

        我们来分析一段代码:

        var b = 1
        
        

        通常我们认为它声明了b并且为它赋值为1var声明作用域函数执行的作用域。也就是说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的特性会导致声明的变量和被赋值的变量是两个bJavaScript中有特例那就是使用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做了非常明确的梳理和规定。

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

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

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

        Realm

        在最新的标准9.0JavaScript引入了一个新概念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听过今天的课程你的想法是否有改变呢为什么