阅读 1429

【JS】详解ES6执行时词法环境/作用域/执行上下文/执行栈和闭包|牛气冲天新年征文

这篇文章我们想探讨一下,当JS代码执行过程中涉及到的内容。厘清各个概念名词的含义。 本文是在阅读ECMA文档以及站在各个大神们所写文章的肩膀上,结合自己理解的基础上完成的,不准确之处,大家指正。

Get started

在编程语言当中,代码里面的变量都有其生效的范围,这个范围叫做作用域。

作用域

作用域是指程序源代码中定义变量的区域。

作用域规定了如何查找变量,也就是确定当前执行代码对变量的访问权限。

作用域分为两种:静态作用域(词法作用域)和动态作用域

静态作用域与动态作用域

静态作用域的特点是,函数的作用域在函数定义的时候就决定了

静态表示一个标识符所在的作用域和它的含义在程序解析阶段(parsing stage)确定。也就是说,在程序运行前通过变量被定义的位置,决定它被约束于某个作用域,这就是静态作用域

而相对的是动态作用域,函数的作用域是在函数调用的时候才决定的

JavaScript 采用词法作用域(lexical scoping),也就是静态作用域。

如果想深入了解这两种作用域的差异,可以查看下面的连接


在ES6中是它的作用域怎样去实现的呢?

首先,在ES6当中,我们看看executable-code-and-execution-contexts这一章节中的第一个概念Environment Records

环境记录项(Environment Record)

ES6中的定义

ES6原文:A Environment Records is a specification type used to define the association of Identifiers to specific variables and functions based upon the lexical nesting structure of ECMAScript code. A Lexical Environment consists of an Environment Record and a possibly null reference to an outer Environment Records.

环境记录项是基于ES代码词法嵌套的语法结构下,用于定义标识符和具体的变量和函数值之前的关联关系的特定结构。词法环境一般包含了环境记录项(Environment Record)和可能为null的outer属性,属性指向外部的词法环境。

sec-environment-records

从定义中,可以看出环境记录项中包含了变量标识符和其具体内容(比如定义的变量名,函数名) (标识符可以理解为var和function等定义的变量名字)

环境记录项的分类

  • 声明式环境记录项(declarative environment records,DER):储存了其作用范围内变量(variable),const,let,class,module,import,function的声明,相当于记录了变量名和其对应的声明值。
var foo = 42;
function bar() { };
复制代码

对应的DER形如:

  name        value
----------------------
  foo          42
  bar    <function object>
复制代码
  • 函数环境记录项(Function environment records):也是declarative environment records。用于保存外层function的定义,如果不是箭头函数,就给function提供this绑定,还提供了super关键字。且包含了arguments对象

    ecma262/#sec-function-environment-records

  • 对象式环境记录项(Object environment records,OER):至少有一个OER,因为global environmentenv record就是OER。OER中保存了Obejct中属性名称和其值的绑定绑定。

var obj = {
   answer: 42,
   name: 'apple'
};
复制代码

对应的OER:

    name        value
------------------------
    answer       42
    name         apple
复制代码
  • 全局环境记录项(global environment records):表示最外层script标签所包裹的代码环境,这个环境记录项目里面包含了JS内置对象的属性,全局对象的属性以及所有在script中的顶级声明。

    global-environment-records

  • 模块环境(module environment records):表示的就是ES6 Module环境中的变量信息。 sec-module-environment-records

[[OuterEnv]] outer指针:指向包含当前词法环境最近的外部词法环境的指针,如果是全局词法环境,该值为null。就是通过outer引用,不同的代码段才能形成链式结构。

outer指针引用了函数外部的环境记录项,通过一层一层的向外引用,所以形成了链式结构。这个是形成作用域链的核心。

环境记录项的作用

登记了对应作用域内的所有标识符和其具体的取值。

可以理解为,词法环境像个字典,存储了标识符和实际引用之间的映射,js引擎能够在具体的执行阶段,遇到函数名就到这个字典中查找并调用里面的词。

我们可以写一段代码,然后自己演练下构建一下环境记录项:

var a = 1;
function foo() {
  var a = 2;
  console.log(c);//4
  function bar() {
    let c = 6;
    console.log(a);//2
    console.log(c);//6    console.log(d);//5
  }

  function eat() {
    console.log(JSON.stringify(obj));//undefined
    var obj = {
      a:'haha'
    }
    console.log(JSON.stringify(obj));//a obj
  }

  function drink() {
    console.log(JSON.stringify(obj));//key1 obj
  }
  bar();
  eat();
  drink();
}
let c = 4;
const d = 5;
var obj = {
  key1: 'a',
  key2: 'b'
}
foo();
复制代码

对应的词法环境的大概结构如下:

globalEnvironment = {
    a:1,
    foo:foo#FunctionEnvironment,
    c:4,
    d:5,
    obj:obj#ObjectEnvironmentRef,
    outer:null
}

obj#ObjectEnvironment= {
    key1:'a',
    key2:'b',
    outer:globalEnvironment
}

foo#FunctionEnvironment = {
    a:2,
    bar:bar#FunctionEnvironment,
    eat:eat#FunctionEnvironment,
    outer:globalEnvironment
}

bar#FunctionEnvironment  = {
    c:6,
    outer:foo#FunctionEnvironment
}

eat#FunctionEnvironment  = {
    obj: obj#ObjectEnvironment,
    outer:foo#FunctionEnvironment
}

drink#FunctionEnvironment  = {
    outer:foo#FunctionEnvironment
}

eatObj#ObjectEnvironment = {
  d:argument
  a:d
}
复制代码

什么时候会创建环境记录项

词法环境的创建一般跟以下四种代码相关:

  • global code:全局的源代码文件开始执行script,会初始化一个环境记录项。
  • function声明:函数块执行会创建一个新词法环境。
  • blockstatement(yield,await,return):块语句执行也会创建一个新环境记录项。
  • try-catch中catch语句:catch执行也会创建环境记录项。

注意:环境记录项是在这四种类型代码被执行的时候才会创建

sec-environment-records


根据ES6中的描述,环境记录项是在代码执行的时候被创建的,说明在JS代码执行过程当中,环境记录项肯定参与了,那环境记录项具体是怎么参与到JS代码的执行呢?

当可执行代码被执行的时候,JS中会创建对应的执行上下文,就是通过对于执行上下文的调度,保证了JS代码的有序执行。

执行环境/执行上下文(Execution Context)

执行上下文是什么

ES6亿原文的定义:An execution context is a specification device that is used to track the runtime evaluation of code by an ECMAScript implementation.执行上下文是一种特定的结构,用于追踪ES6代码执行的情况。sec-execution-contexts

JS代码在执行阶段的时候会创建一个执行上下文数据,里面定义了变量和函数有权访问的数据(即拷贝了当前执行代码对应的环境记录项)和执行时代码对应的具体的行为。

个人通俗的理解就是,要准备上场开卷考试了,执行上下文就是你能够参考到的资料,从小资料库里面获取变量们的取值根据代码执行流程对变量们进行处理。

执行上下文中包含什么

  • 基础状态
阶段描述
代码执行状态(code evaluation state)需要执行或者暂停状态的代码
Function所执行的是函数代码则会标记这个对象
RealmRealm环境里面的代码执行
Script/ModuleModule和script标签对应的执行代码

词法环境(LexicalEnvironment)

包含了当前上下文的变量函数标识符所对应的数值,ES6中letconst声明的变量会挂载到词法环境当中。

ES6定义:Identifies the Environment Record used to resolve identifier references made by code within this execution context. 通俗的理解,其实词法环境就是在执行的时候专门为执行上下文服务的环境记录项。所以词法环境和变量环境都是environment records 【sec-execution-contexts】

变量环境(VariableEnvironment)

这个也是个环境记录项目,但是保存的是上下文中通过var声明的变量的和它的数值。

AO&VO与词法环境的关系

在很多JS执行的学习资料当中,我们经常见到的并非Lexical Environments而是variable objectactive object

其实是在ES5当中把variable object改为了Lexical Environments

作此变动的原因有:

  • ES5的Lexical Environments中的 declarative environment records 可以支持不可变的绑定。以便于支持后来的const关键字特性。
  • declarative records使用了lexical addressing 技术,可以提高作用域链中的变量查找效率。

Why variable object was changed to lexical environment in ES5?

执行上下文分类

  • 全局执行环境(Global Execution Context):最外层的执行环境。在web浏览器当中,全局执行环境默认为window对象,只有关闭网页或浏览器才会被销毁。全局执行环境总是在栈底

全局执行上下文伪代码: ecma262/#sec-global-environment-records

GlobalExectionContext = {  // 全局执行上下文
  VariableEnvironment: {    	  // 词法环境
    GlobalEnvironmentRecord: {   		// 环境记录
      [[ObjectRecord]]: {
                  Type: "ObjectEnvironmentRecord",    
                  //绑定了全局对象(如window)
                 // 全局下var、function、generator、async声明的标识符可以保存到window下
      }, 
      [[GlobalThisValue]]:<Object>,
      [[DeclarativeRecord]]:{
                  Type: "DeclarativeEnvironmentRecord",    
                 // var、function、generator、async声明的标识符保存在这里 
      }, 
      [[VarNames]]:[string list]//DeclarativeRecord声明的标识符的名字
  }  
      outer: <null>  	   		   // 对外部环境的引用为null
}
}
复制代码

从全局执行上下文的内容我们会发现,对于let或者const声明的对象,在全局环境记录里面是没有出现的。 所以let和const声明的对象不会挂载到全局对象下面(浏览器中为window)

console.log(a);//报错,a is not defined
let a = 1;
console.log(a);//1
console.log(window.a);//undefined
console.log(window.c);//3
var c = 3;
复制代码
  • 函数执行环境(Functional Execution Context ),当执行流进入一个函数时,其执行环境就会被推入环境栈中。
FunctionExectionContext = { // 函数执行上下文
   LexicalEnvironment: {    	  // 词法环境
    FunctionEnvironmentRecord: {   		// 环境记录
      [[FunctionObject]]: {
                  Type: "ObjectEnvironmentRecord",    
                  ..window对象中的属性和值保存在这里
                 // let和const声明的标识符保存在这里 
      }, 
      [[ThisValue]]:<Object>,
      [[HomeObject]]:{
                  Type: "DeclarativeEnvironmentRecord",    
                 // var、function、generator、async声明的标识符保存在这里 
      }, 
      [[VarNames]]:[string list]//DeclarativeRecord声明的标识符的名字
  }  
      outer: <null>  	   		   // 对外部环境的引用为null
}
}
复制代码

作用域链与执行上下文

上面我们知道了,执行上下文里面包含了两种Env Record,而Env Record本身就会通过outer指针一直指向自己的父级形成链式结构。

所以其实我们可以理解为,作用域链就是执行上下文中的词法环境和变量环境可以访问到的所有记录项的总和。

执行上下文栈(Execution context stack,ECS)

执行上下文栈是什么

执行上下文栈就是使用后进先出(FILO)来调度执行上下文的栈结构。

当JS开始执行代码的时候,会先初始化全局执行环境,将其推入栈底,所以全局执行环境总是在栈底。

然后根据代码的调用顺序,各个函数的执行上下文会不断压入栈中,在栈顶端的执行上下文,会被置为running excution context被执行,执行完之后,这个上下文就被移出栈。然后周而复始,直到执行栈被清空,这个时候JS代码就执行完毕,程序会让出线程。

执行栈运作的具体过程可以参考bitsrc blog中的示意图

let a = 'Hello World!';
function first() {
  console.log('Inside first function');
  second();
  console.log('Again inside first function');
}
function second() {
  console.log('Inside second function');
}
first();
console.log('Inside Global Execution Context');
复制代码

执行栈.png

非常建议大家看一下这篇博客,动态的展示了代码执行过程当中,执行栈的一个运行顺序,理解执行栈,可以手写一段代码,然后自己去绘制这样的执行栈执行是顺序图。

understanding-execution-context-and-execution-stack-in-javascript

概念总结示意图

个人总结的各个概念之间的关系


闭包(closure)

闭包定义

  • 从理论角度:所有的函数。因为它们都在创建的时候就将上层上下文的数据保存起来了。哪怕是简单的全局变量也是如此,因为函数中访问全局变量就相当于是在访问自由变量,这个时候使用最外层的作用域。

  • 从实践角度:以下函数才算是闭包:即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回),在代码中引用了自由变量

我们从实践角度,实现一个简单的闭包

function clousreOutter(){
	var a = 1;
    let b = 2;
    b++;
    return function(){
    	var a = 3;
        let c = 4;
       	console.log('a',a);
        console.log('b',b);
        console.log('c',c);
    }
}

let inner = clousreOutter();
inner();

// output
// a 3
// b 3
// c 4
复制代码

这段代码当中的闭包现象其实就是体现在,clousreOutter函数执行完毕之后,按理说它的执行上下文已经被销毁了,但是clousreOutter的内部函数,在外部函数执行完毕之后还可以访问到外部函数定义的变量数值。

从执行上下文来解读闭包

我们现在已经了解了执行上下文的过程,为什么闭包函数能够访问到不是自己函数内部定义的变量呢?

从前面的知识,我们现在可以知道,clousreOutter和inner函数都有它自己的环境变量项。

从前文我们可以知道,clousreOutter的执行上下文以及环境记录项是在它被调用的时候创建的。

但是,inner函数的环境记录项是在什么时候创建的呢?

我们来看ES6当中对于函数体内含有闭包的Function evaluation是怎么处理的: sec-functiondeclarationinstantiation当中的27和28点:

functiondeclarationinstantiation

这个hasParameterExpressions当函数内部存在闭包的时候,应该是true,所以我们可以看到28点的Note里面所写到的:

当表达式当中存在闭包(closures)的时候,需要创建一个新的环境记录项,以防止闭包外的代码访问到闭包里面的变量。

所以其实我们可以得出结论:当clousreOutter代码执行之后,inner函数的环境记录项就已经被创建了。

如果说clousreOutter执行完之后,并没有另外把执行结果保存在变量里面,那么它执行的时候创建的这个inner函数的环境记录项也就没有被任何人引用,在合适的时间就会被JS的垃圾回收机制给回收掉了。

问题就在于它被返回出来并且保存在一个变量当中了,所以即使clousreOutter执行完了,因为inner函数的环境记录项的outer指针是指向clousreOutter的环境记录项的。

inner函数被返回并且调用了,所以inner函数的环境记录项又被执行上下文给引用了。

所以顺着outer指针,inner函数就可以访问到clousreOutter的变量信息。

我们可以画张图来加强理解:

clousreOutter 执行时

inner执行时

当我们理解了闭包在执行过程中的原理之后,我们再来看,我们常说的闭包如果使用不当会有什么问题。

  • 滥用闭包会导致内存泄露

就是因为当使用闭包的时候,只要闭包被保存下来,那么它外层函数的环境记录项就会被引用,JS垃圾回收机制处理的时候就不会把外层函数的环境记录项给销毁。这块数据就会意外存在于JS中,造成内存泄露。

那个经典的闭包题

for(var i = 0;i < 5; i++ ) {
    setTimeout(function(){
      console.log(i);
    },0)
}
复制代码

经典的闭包面试题当中涉及的并不仅仅是闭包,我在另外一篇文章中详细的解释了这道题目。 【ECMA学JS】总算能把闭包经典面试题真的解释清楚

执行时的This指向

JavaScript深入之从ECMAScript规范解读this

参考

推荐原文阅读参考区域

understanding-execution-context-and-execution-stack-in-javascript

其他参考

搞懂词法环境 wiki/ES5-词法环境 muyiy-执行上下文 ecma262/#sec-lexical-environments what-really-is-a-declarative-environment-record ECMA-262-5 词法环境 Dmitry Soshnikov ECMA-262-5 in detail. Chapter 3.2. Lexical environments: ECMAScript implementation. JavaScript深入之闭包

文章分类
前端
文章标签