[JS之作用域和闭包1-3]词法作用域和动态作用域,作用域作用域链以及执行上下文

448 阅读12分钟

作用域(scope)

作用域是指在程序中定义变量的区域,该位置决定了变量的生命周期。通俗地理解,作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。

作用域共有两种主要的工作模型

  • 第一种是被大多数编程语言所采用的词法作用域
  • 第二种是仍被一些编程语言(如Bash脚本)在使用的动态作用域

词法作用域(Lexical scope)

简单地说,词法作用域就是定义在词法阶段的作用域。

编译的词法分析阶段基本能够知道全部标识符在哪里以及是如何声明的,从而能够预测在执行过程中如何对它们进行查找。

词法作用域

从图中可以看出,词法作用域就是根据代码的位置来决定的,其中 main 函数包含了 bar 函数,bar 函数中包含了 foo 函数,因为 JavaScript 作用域链是由词法作用域决定的,所以整个词法作用域链的顺序是:foo 函数作用域 —> bar 函数作用域 —> main 函数作用域 —> 全局作用域。

动态作用域(Dynamic scope)

动态作用域在执行时确定,其生存周期到代码片段执行为止

动态变量存在于动态作用域中,任何给定的绑定的值,在确定调用其函数之前,都是不可知的。

举个例子,把下面的脚本存成例如 scope.bash,然后进入相应的目录,用命令行执行 bash ./scope.bash

value=1
function foo () {
    echo $value;
}
function bar () {
    local value=2;
    foo;
}
bar

区别

  • 词法作用域是在写代码或者说定义时确定的,而动态作用域是在运行时确定的。(this 也是!)
  • 词法作用域关注函数在何处声明,而动态作用域关注函数从何处调用。

JavaScript 并不具有动态作用域,它只有词法作用域。但是 this 机制某种程度上很像动态作用域。

作用域分类

JavaScript 作用域有三种:

  • 全局作用域中的对象在代码中的任何地方都能访问,其生命周期伴随着页面的生命周期。
    • 最外层函数以及最外层定义的变量;
    • 在任何位置不使用varletconst声明的变量;
    • 所有window对象的属性。
  • 函数作用域就是在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部定义的变量会被销毁。
  • 块级作用域就是使用一对大括号包裹的一段代码,比如函数、判断语句、循环语句,甚至单独的一个{}都可以被看作是一个块级作用域。
    • ES6 引入了letconst关键字,从而使 JavaScript 也能像其他语言一样拥有了块级作用域。
// 全局作用域 包含 count 变量 和 main 函数
let count = 1 
function main(){ 
    // 函数作用域
    // 块作用域
    let count = 2 
    function bar(){
        // 函数作用域
        // 块作用域
        let count = 3
        function foo(){
            let count = 4 
        }
    }
}

//块作用域
//if块
if(1){}

//while块
while(1){}

//函数块
function foo(){
 
//for循环块
for(let i = 0; i<100; i++){}

//单独一个块
{}

为什么引入块级作用域

在回答这个问题之前,我们先了解下变量提升是什么。

变量提升(Hoisting)

所谓的变量提升,是指在 JavaScript 代码执行过程中,JavaScript 引擎把变量的声明部分和函数的声明部分提升到代码开头的“行为”。变量被提升后,会给变量设置默认值,这个默认值就是我们熟悉的 undefined

看一下下面这段代码:

showName()
console.log(myname)
var myname = 'JavaScript'
function showName() {
    console.log('函数showName被执行')
}

分析下上面的代码:

  • 第1行和第2行,由于这两行代码不是声明操作,所以 JavaScript 引擎不会做任何处理;
  • 第3行,由于这行是经过 var 声明的,因此 JavaScript 引擎将在环境对象中创建一个名为 myname 的属性,并使用 undefined 对其初始化;
  • 第4行,JavaScript 引擎发现了一个通过 function 定义的函数,所以它将函数定义存储到堆(HEAP)中,并在环境对象中创建一个 showName 的属性,然后将该属性值指向堆中函数的位置。 这样就生成了变量环境对象。

经过编译后,会生成两部分内容:执行上下文(Execution context)和可执行代码。

//执行上下文的变量环境保存了变量提升的内容,也就是 myname 变量和 showName()。
var myname = undefined
function showName() {
    console.log('函数showName被执行')
}
//可执行代码
showName()
console.log(myname) // undefined
myname = 'JavaScript'

JavaScript引擎开始执行“可执行代码”,按照顺序一行一行地执行。

  • 当执行到 showName 函数时,JavaScript 引擎便开始在变量环境对象中查找该函数,由于变量环境对象中存在该函数的引用,所以 JavaScript 引擎便开始执行该函数,并输出“函数 showName 被执行”结果;
  • 接下来打印“ myname ”信息,JavaScript 引擎继续在变量环境对象中查找该对象,由于变量环境存在 myname 变量,并且其值为 undefined,所以这时候就输出 undefined
  • 接下来执行第3行,把 JavaScript 赋给 myname 变量,赋值后变量环境中的 myname 属性值改变为 “ JavaScript ” 。

变量提升所带来的问题

1. 变量容易在不被察觉的情况下被覆盖掉

var myname = "JavaScript"
function showName(){
  console.log(myname)
  if(0){
   var myname = "CSS"
  }
  console.log(myname)
}
showName() //undefined undefined 原因是优先使用函数声明的var myname,提升后是 undefined ,覆盖掉了全局同名变量。

2. 本应销毁的变量没有被销毁

function foo(){
  for (var i = 0; i < 7; i++) {
  }
  console.log(i)
}
foo() //7,因为变量提升,for循环结束的时候 i 没有被销毁。

有了块级作用域以后

还是上面两个了例子:

var myname = "JavaScript"
function showName(){
  console.log(myname)
  if(0){
   let myname = "CSS"
  }
  console.log(myname)
}
showName() // JavaScript JavaScript

function foo() {
    for (let i = 0; i < 7; i++) {
        console.log(i)
    }
}
foo()// 0 1 2 3 4 5 6 

结果就非常符合我们的编程习惯了:作用块内声明的变量不影响块外面的变量

块级作用域中的变量查找

function foo(){
    var a = 1
    let b = 2
    {
      let b = 3
      var c = 4
      let d = 5
      console.log(a) // 1
      console.log(b) // 3
    }
    console.log(b) // 2
    console.log(c) // 4
    console.log(d) // Uncaught ReferenceError: d is not defined 因为 d 变量声明在块作用域内,外部无法访问。
}   
foo()

06c06a756632acb12aa97b3be57bb908

  1. 函数内部的 var 声明的变量 a = undefinedc = undefined放在变量环境,let声明的变量 b = undefined放在词法环境;
  2. 接下来,继续执行代码,当执行到代码块里面时,变量环境中的a = 1,词法环境中的b = 2
  3. 进入块作用域后,块作用域内部的变量b = undefinedd = undefined被压到词法环境栈顶;
  4. 当执行到块作用域中的console.log(a)这行代码时,变量环境中的c = 4,词法环境的块作用域内部的变量b = 3,d = 5
  5. 这时就需要在词法环境和变量环境中查找a的值了,具体的方式是:沿着词法环境的栈顶向下查询,如果在词法环境中的某个块查找到了,就直接返回给Javascript引擎,如果没有查找到,那么继续在变量环境中查找。
  6. 这样一个变量查找的过程就完成了。当块作用域执行结束之后,其内部定义的变量就会从词法环境的栈顶弹出。

作用域链(Scope chain)

在每个执行上下文的词法(变量)环境中,都包含了一个外部引用,用来指向外部的执行上下文,我们把这个外部引用称为 outer

看下面这段代码:

function bar() {
    console.log(myName)
}
function foo() {
    var myName = " CSS "
    bar()
}
var myName = " JavaScript "
foo() // JavaScript

从图中可以看出,bar 函数和 foo 函数的 outer 都是指向全局上下文的,这也就意味着如果在 bar 函数或者 foo 函数中使用了外部变量,那么 JavaScript 引擎会去全局执行上下文中查找。我们把这个查找的链条就称为作用域链

foo 函数调用的 bar 函数,那为什么 bar 函数的外部引用是全局执行上下文,而不是 foo 函数的执行上下文?

这是因为根据词法作用域,foobar 的上级作用域都是全局作用域,所以如果 foo 或者 bar 函数使用了一个它们没有定义的变量,那么它们会到全局作用域去查找。也就是说,词法作用域是代码阶段就决定好的,和函数是怎么调用的没有关系。

执行上下文(Execution Context)

简而言之,执行上下文就是当前 JavaScript 代码被解析和执行时所在环境的抽象概念, JavaScript 中运行任何的代码都是在执行上下文中运行。

执行上下文总共有三种类型:

  • 当JavaScript执行全局代码的时候,会编译全局代码并创建全局执行上下文,而且在整个页面的生存周期内,全局执行上下文只有一份。
  • 当调用一个函数的时候,函数体内的代码会被编译,并创建函数执行上下文,一般情况下,函数执行结束之后,创建的函数执行上下文会被销毁。
  • 当使用 eval 函数的时候,eval 的代码也会被编译,并创建执行上下文

创建阶段

在任意的 JavaScript 代码被执行前,执行上下文处于创建阶段。在创建阶段中总共发生了两件事情:

  1. LexicalEnvironment(词法环境) 组件被创建。
  2. VariableEnvironment(变量环境) 组件被创建。

因此,执行上下文可以在概念上表示如下:

ExecutionContext = {  
  LexicalEnvironment = { ... },  
  VariableEnvironment = { ... }, 
}

词法环境(Lexical Environment)

官方 ES6 文档将词法环境定义为:

词法环境是一种规范类型,基于 ECMAScript 代码的词法嵌套结构来定义标识符与特定变量和函数的关联关系。词法环境由环境记录(environment record)和可能为空引用(null)的外部词法环境以及 this binding 组成。

简而言之,词法环境是一个包含标识符变量映射的结构。(这里的标识符表示变量/函数的名称,变量是对实际对象【包括函数类型对象】或原始值的引用)。

例如:

var a = 20;
var b = 40;

function foo() {
    console.log('bar');
}

上面的词法环境看起来像这样:

lexicalEnvironment = {
    a: 20,
    b: 40,
    foo: <ref. to foo function>
  }

在词法环境中,有三个组成部分:

  1. 环境记录(environment record)
  2. 对外部环境(Outer Environment)的引用
  3. This binding

环境记录

是存储变量和函数声明的实际位置。

环境记录 同样有两种类型(如下所示):

  • 声明性环境记录 存储变量、函数声明。function code的词法环境包含一个声明性环境记录。
  • 对象环境记录 global code的词法环境包含一个对象环境记录。除了变量和函数声明外,对象环境记录还存储一个global binding object(在浏览器中是 window 对象)。因此,对于每一个绑定对象属性(在浏览器中,它包含浏览器窗口对象提供的属性和方法),在记录中创建一个新条目。

对于函数代码,环境记录该对象包含了索引和传递给函数的参数之间的映射以及传递给函数的参数的长度(数量)。例如,下面函数的 arguments 对象如下所示:

function foo(a, b) {
  var c = a + b;
}
foo(2, 3);
// argument object
Arguments: {0: 2, 1: 3, length: 2},

对外部环境的引用

对外部环境的引用意味着它可以访问其父级词法环境(作用域)。这意味着如果在当前词法环境找不到变量,JavaScript引擎就会在父级词法作用域寻找。

This Binding

在全局执行上下文中,this 的值指向全局对象(在浏览器中,this 的值指向 window 对象)。

在函数执行上下文中,this 的值取决于函数的调用方式。如果它被一个对象引用调用,那么 this 的值被设置为该对象,否则 this 的值被设置为全局对象或 undefined(严格模式下)。例如:

const person = {
  name: 'peter',
  birthYear: 1994,
  calcAge: function() {
    console.log(2018 - this.birthYear);
  }
}
person.calcAge(); 
// 'this' refers to 'person', because 'calcAge' was called with //'person' object reference
const calculateAge = person.calcAge;
calculateAge();
// 'this' refers to the global window object, because no object reference was given

抽象来看,词法环境看起来像这样的伪代码:

GlobalExectionContext = {
  LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Object",
      // Identifier bindings go here
    }
    outer: <null>,
    this: <global object>
  }
}
FunctionExectionContext = {
  LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Declarative",
      // Identifier bindings go here
    }
    outer: <Global or outer function environment reference>,
    this: <depends on how function is called>
  }
}

详细可以看之前[JavaScript作用域和闭包-4]this的原理以及几种不同使用场景的取值

变量环境(Variable Environment)

它也是一个词法环境,其 EnvironmentRecord 包含了由 VariableStatements 在此执行上下文创建的绑定。

如上所述,变量环境也是一个词法环境,因此它具有上面定义的词法环境的所有属性。

在 ES6 中,LexicalEnvironment 组件和 VariableEnvironment 组件的区别在于前者用于存储函数声明和变量( letconst )绑定,而后者仅用于存储变量( var )绑定。

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

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

执行阶段

在这个阶段,将完成所有变量的赋值操作,然后执行代码。

例子

我们看几个例子来理解上面的概念:

let a = 20;
const b = 30;
var c;
function multiply(e, f) {
 var g = 20;
 return e * f * g;
}
c = multiply(20, 30);

当上面的代码被执行的时候,Javascript 引擎会创建一个全局执行上下文去执行全局的代码。所以全局执行上下文在创建阶段看起来会像下面这样:

GlobalExectionContext = {
  LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Object",
      // Identifier bindings go here
      a: < uninitialized >,
      b: < uninitialized >,
      multiply: < func >
    }
    outer: <null>,
    ThisBinding: <Global Object>
  },
  VariableEnvironment: {
    EnvironmentRecord: {
      Type: "Object",
      // Identifier bindings go here
      c: undefined,
    }
    outer: <null>,
    ThisBinding: <Global Object>
  }
}

在执行阶段,将完成变量的赋值操作,因此在执行阶段全局执行上下文看起来会像下面这样:

GlobalExectionContext = {
LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Object",
      // Identifier bindings go here
      a: 20,
      b: 30,
      multiply: < func >
    }
    outer: <null>,
    ThisBinding: <Global Object>
  },
VariableEnvironment: {
    EnvironmentRecord: {
      Type: "Object",
      // Identifier bindings go here
      c: undefined,
    }
    outer: <null>,
    ThisBinding: <Global Object>
  }
}

当调用multiply(20, 30)时,将为该函数创建一个函数执行上下文,该函数执行上下文在创建阶段像下面这样:

FunctionExectionContext = {
LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Declarative",
      // Identifier bindings go here
      Arguments: {0: 20, 1: 30, length: 2},
    },
    outer: <GlobalLexicalEnvironment>,
    ThisBinding: <Global Object or undefined>,
  },
VariableEnvironment: {
    EnvironmentRecord: {
      Type: "Declarative",
      // Identifier bindings go here
      g: undefined
    },
    outer: <GlobalLexicalEnvironment>,
    ThisBinding: <Global Object or undefined>
  }
}

然后,执行上下文进入执行阶段,这时候已经完成了变量的赋值操作。该函数上下文在执行阶段像下面这样:

FunctionExectionContext = {
LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Declarative",
      // Identifier bindings go here
      Arguments: {0: 20, 1: 30, length: 2},
    },
    outer: <GlobalLexicalEnvironment>,
    ThisBinding: <Global Object or undefined>,
  },
VariableEnvironment: {
    EnvironmentRecord: {
      Type: "Declarative",
      // Identifier bindings go here
      g: 20
    },
    outer: <GlobalLexicalEnvironment>,
    ThisBinding: <Global Object or undefined>
  }
}

函数执行完成后,返回值存储在变量c中,此时全局词法环境被更新。之后,全局代码执行完成,程序结束。

注意:你可能已经注意到上面代码,letconst定义的变量ab在创建阶段没有被赋值,但var声明的变量从在创建阶段被赋值为undefined

这是因为,在创建阶段,会在代码中扫描变量和函数声明,然后将函数声明存储在环境中,但变量会被初始化为undefined(var声明的情况下)和保持uninitialized(未初始化状态)(使用letconst声明的情况下)。

这就是为什么使用var声明的变量可以在变量声明之前调用的原因,但在变量声明之前访问使用letconst声明的变量会报错(TDZ)的原因。

这其实就是我们经常听到的变量声明提升。

注意:在执行阶段,如果 Javascript 引擎找不到letconst声明的变量的值,也会被赋值为undefined

执行栈(Execution Stack)

执行栈,在其他编程语言中也被叫做调用栈(Call stack),具有 LIFO(后进先出)结构,用于存储在代码执行期间创建的所有执行上下文。

当 JavaScript 引擎首次读取你的脚本时,它会创建一个全局执行上下文并将其推入当前的执行栈。每当发生一个函数调用,引擎都会为该函数创建一个新的执行上下文并将其推到当前执行栈的顶端。

引擎会运行执行上下文在执行栈顶端的函数,当此函数运行完成后,其对应的执行上下文将会从执行栈中弹出,上下文控制权将移到当前执行栈的下一个执行上下文。

让我们通过下面的代码示例来理解这一点:

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');

1_ACtBy8CIepVTOSYcVwZ34Q

当上述代码在浏览器中加载时,JavaScript 引擎会创建一个全局执行上下文并且将它推入当前的执行栈。当调用 first() 函数时,JavaScript 引擎为该函数创建了一个新的执行上下文并将其推到当前执行栈的顶端。

当在 first() 函数中调用 second() 函数时,Javascript 引擎为该函数创建了一个新的执行上下文并将其推到当前执行栈的顶端。当 second() 函数执行完成后,它的执行上下文从当前执行栈中弹出,上下文控制权将移到当前执行栈的下一个执行上下文,即 first() 函数的执行上下文。

first() 函数执行完成后,它的执行上下文从当前执行栈中弹出,上下文控制权将移到全局执行上下文。一旦所有代码执行完毕,Javascript 引擎把全局执行上下文从执行栈中移除。

如何利用浏览器查看调用栈的信息

当你执行一段复杂的代码时,你可能很难从代码文件中分析其调用关系,这时候你可以在你想要查看的函数中加入断点,然后当执行到该函数时,就可以查看该函数的调用栈了。

这么说可能有点抽象,这里我们拿上面的那段代码做个演示,你可以打开“开发者工具”,点击“Source”标签,选择 JavaScript 代码的页面,然后在第3行加上断点,并刷新页面。你可以看到执行到add函数时,执行流程就暂停了,这时可以通过右边“call stack”来查看当前的调用栈的情况,如下图:

c0d303a289a535b87a6c445ba7f34fa2

从图中可以看出,右边的“call stack”下面显示出来了函数的调用关系:栈的最底部是 anonymous,也就是全局的函数入口;中间是addAll函数;顶部是add函数。这就清晰地反映了函数的调用关系,所以在分析复杂结构代码,或者检查Bug时,调用栈都是非常有用的。

除了通过断点来查看调用栈,你还可以使用console.trace()来输出当前的函数调用关系,比如在示例代码中的add函数里面加上了console.trace(),你就可以看到控制台输出的结果,如下图:

abfba06cd23a7704a6eb148cff443ece

参考:

JavaScript深入之词法作用域和动态作用域

【译】理解 Javascript 执行上下文和执行栈

Understanding Execution Context and Execution Stack in Javascript

理解Javascript中的执行上下文和执行栈

javascript高级程序设计第4版

你不知道的javascript(上卷)

浏览器工作原理与实践

你不知道的Javascript动态作用域