执行上下文和作用域

本文最后更新于:1 年前

执行上下文和作用域

执行上下文是一个描述代码运行时所在环境的抽象概念,JavaScript 引擎再开始执行代码前,会创建全局执行上下文 ,全局代码(不属于任何函数的代码)在全局执行上下文中执行。 全局执行上下文 在每个 JS 程序中只有一个。

每个函数都有自己的执行环境。当执行流进入一个函数时,函数的环境就会被推入一个环境栈中(execution stack)。在函数执行完后,栈将其环境弹出,把控制权返回给之前的执行环境。ECMAScript 程序中的执行流正是由这个便利的机制控制着。

执行上下文

因为 JavaScript 是个单线程语言,这意味着同一时间只能执行一个任务,而 JavaScript 内部原理又会经过分词(tokenize)预解析(preparse)解析(parse),这里不展开讲。解析执行的时候有三种情况生成执行上下文(execution context):全局代码、函数代码,eval 代码。

在 ES3 中,执行上下文中包含:

  • 作用域链(scope)
  • 变量对象(variable object)
  • this

在 ES5 中,变成了包含:

  • 词法环境(lexical environment)(作用域)
  • 变量环境(variable environment)
  • this

可参考ECMA262 规范

再后来(ES8),this 值被归入了词法环境中:

  • 词法环境,包含this,获取变量时使用(lexical environment)(作用域)
  • 变量环境,声明变量时使用(variable environment)
  • 代码执行状态(code evaluation state)
  • 正在执行的函数(Function)
  • 正在被执行的代码(ScriptOrModule)
  • 内置对象实例(Realm)
  • 当前生成器,只有生成器上下文才有(Generator)

VO 和 AO

变量对象(variable object)的属性由变量和函数声明构成,每一个执行上下文都会被分配一个变量对象,而在函数上下文情况下,参数列表也会被加入到变量对象中。

活动对象(activation object)会在函数被激活时就会被创建并分配给执行上下文。

在函数执行上下文中,VO 是不能直接访问的,而 AO 是可以访问的。

可以用以下代码举例说明:

function outerFun(arg1, arg2) {
  var value1 = 1;
  var value2 = 2;
  function func1() {
    var innerV1 = 3;
    var innerV2 = 4;
    return '1';
  }
  function func2() {
    return '2';
  }
  function value2() {
    return '3';
  }
}
outerFun();

他的变量对象创建是这样的:

key value 备注
arguments Arguments Object 检查当前执行上下文的参数列表,创建 arguments 对象
func1 <func1() reference> 检查到 function 声明,属性值指向函数所在的内存地址
func2 <func2() reference>
value1 undefined 检查到执行上下文的所有 var 声明 并建立属性
value2 undefined

词法环境和变量环境

  • 变量环境(variable environment):用于记录 var,function 声明的绑定。
  • 词法环境(lexical environment):用于记录其他声明的绑定(如 let、const、class 等)。

而词法环境中又包含两个组成部分:

  • 环境记录(environment record):记录相应代码块的标识符绑定,可理解为代码块内变量、函数等都绑定于此,对应 ES3 中的 VO(变量对象)和 AO(活动对象)。
  • 外部词法环境引用(outer environment):用于形成多个词法环境在逻辑上的嵌套结构,以实现可以访问外部词法环境变量的能力,对应 ES3 中的作用域链。

环境记录包含了:

  • 声明式环境记录(Declarative Environment Record):定义直接将标识符绑定的元素,例如 let、const、class、module、import 等
  • 对象式环境记录(Object Environment Record):用于记录与某些对象属性绑定的元素,例如 with 语句、var 声明和函数声明
  • 全局环境记录(Global Environment Record):就是存放 globalThis 上的属性元素

可以用如下代码大概解释是怎么回事:

let a = 1;
const b = 2;
var c = 3;
function test(d, e) {
  var f = 10;
  return f * d * e;
}
c = test(a, b);

简要地抽象成看得懂的结构的话,预解析阶段的全局环境内:

GlobalLexicalEnvironment = {
  LexicalEnvironment: {
    OuterReference: null,
    EnvironmentRecord: {
      a: <uninitialized> ,
      b: <uninitialized>,
    },
  },
  VariableEnvironment: {
    EnvironmentRecord: {
      test: <func reference>,
      c: undefined,
    }
  }
}

执行到最后时的环境:

GlobalLexicalEnvironment = {
  LexicalEnvironment: {
    OuterReference: null,
    EnvironmentRecord: {
      a: 1 ,
      b: 2 ,
    },
  },
  VariableEnvironment: {
    EnvironmentRecord: {
      test: <func reference>,
      c: 20,
    }
  }
}

FunctionLexicalEnvironment = {,
  LexicalEnvironment: {
    OuterReference:  <GlobalLexicalEnvironment>,
    EnvironmentRecord: {
      arguments: {0: 1, 1: 2, length: 2},
    },
  },
  VariableEnvironment: {
    EnvironmentRecord: {
      f:10,
    }
  }
}

为什么要有两个环境

主要是为了实现块级作用域的同时不影响var声明和函数声明。因为在 ES6 之前并没有块级作用域的概念,而 ES6 后又可以使用 let 和 const 实现,那这时候就需要不影响 var 声明和函数声明了。

原理是当正在执行的上下文,运行到块级代码时:

console.log(foo); // 输出:undefined
{
  function foo() {
    console.log('hello');
  }
}
console.log(foo); // 输出: ƒ foo() {console.log('hello')}

会将此时的词法环境记录下来,并且在块级内部创建新的词法环境,然后将外部词法环境指向上一层被记录的词法环境中。然后块级中的 let 和 const 都会被记录到当前的词法环境,而 var 和函数声明就还是绑定在变量环境上了。

作用域

作用域指的源代码中定义变量的区域,它规定了执行代码对变量的访问权限。而常见的作用域有词法作用域(lexical scope)动态作用域(dynamic scope)。而 Javascript 使用的是词法作用域,也就是说无论函数在哪里被调用,也无论它如何被调用,它的作用域都只由函数被声明时所处的位置决定

词法作用域和动态作用域

词法作用域即定义时就决定作用域了,而动态作用域即在执行调用时才能确定作用域。

在《JavaScript 权威指南》中的例子就是个典型的作用域问题:

var scope = 'global scope';
function checkscope() {
  var scope = 'local scope';
  function f() {
    return scope;
  }
  return f();
}
checkscope();
// local scope
var scope = 'global scope';
function checkscope() {
  var scope = 'local scope';
  function f() {
    return scope;
  }
  return f;
}
checkscope()();
// local scope

上述两个例子执行结果都会返回 local scope,因为 JavaScript 采用词法作用域,而函数 fcheckscope 内部被定义,所以此时已经确定了他所在的作用域了。

可能会好奇什么是动态作用域,可以将如下代码保存,并 bash 执行,因为 bash 是动态作用域:

c=1;

function func1 () {
  echo $c;
}

function func2() {
  local c=2;
  func1;
}

func2

这里会输出 2,而在 JavaScript 中会打印 1:

var c = 1;

function func1() {
  console.log(c);
}

function func2() {
  var c = 2;
  func1();
}

func2();

这里就是体现词法作用域和动态作用域的区别。

闭包

在计算机术语中,函数体作用域内包含了其变量环境,这个函数就是闭包(clourse),通俗点就是拥有自由变量并处在这些自由变量外部且能访问到外部的这些自由变量的函数。

很拗口是吧?其实严格意义上 JavaScript 中所有函数都是闭包。为了实现词法作用域,JavaScript 函数对象的内部状态不仅包含逻辑代码,还包含了当前作用域链的引用。

就很简单的例子:

var a = 1;

function func() {
  console.log(a);
}

func();

func 方法内拥有不属于自身变量环境的自由变量 a,那么 func 就是闭包。


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!