执行环境

当代码在运行时,它所在的执行环境非常重要。

执行上下文

在 JavaScript 中,执行上下文与执行环境关系密切,它与函数和变量的声明息息相关,通常认为有两种执行上下文:

  • 全局上下文——代码首次执行的默认环境;
  • 函数上下文——当代码执行进入函数体中。

让我们来看一段包含这几种执行上下文的代码:

  1. // 全局上下文
  2. var hello = 'Hello!';
  3. function introduce() { // 函数上下文
  4. var firstName = 'Ourai';
  5. var lastName = 'Lin';
  6. function sayFirstName() { // 函数上下文
  7. return firstName;
  8. }
  9. function sayLastName() { // 函数上下文
  10. return lastName;
  11. }
  12. console.log(hello + ' My name is ' + sayFirstName() + ' ' + sayLastName() + '.');
  13. }

在上面的代码中,有 1 个全局上下文和 3 个函数上下文。

在整个 JavaScript 程序中,全局上下文有且只有一个,可以被其他任何上下文访问;而函数上下文可以有无数个,每个函数调用都会创建出一个新的上下文,只可以被其内部的其他上下文访问,外部无法访问。

执行上下文中的细节

在 JavaScript 解释器内部,对每个执行上下文的调用会经历两个阶段:

  1. 创建阶段(在函数被调用但还未执行内部代码时):
    • 创建作用域链;
    • 创建变量、函数和参数;
    • 确定 this 的值。
  2. 激活/代码执行阶段:
    • 赋值,给函数分配引用以及解释/执行代码。

我们可以用一个含有三个属性的对象来表示执行上下文这个概念:

  1. executionContextObj = {
  2. scopeChain: { /* 变量对象 + 所有父级执行上下文的变量对象 */ },
  3. variableObject: { /* 函数的实参/形参,内部的变量和函数的声明 */ },
  4. this: {}
  5. }

活动对象/变量对象

executionContextObj 创建于函数被调用但还未被执行时,即之前所说的第一阶段——创建阶段。在这个阶段,解释器通过扫描函数的形参或传入的实参、局部函数声明和局部变量声明来创建 executionContextObj,扫描的结果形成了 executionContextObj 中的 variableObject

下面是解释器执行代码时的伪概述:

  1. 找到调用函数的代码
  2. 在执行函数代码前创建执行上下文
  3. 进入创建阶段:
    • 初始化作用域链
    • 创建变量对象
      • 创建实参对象,检查上下文中的形参,初始化名称和值并创建一个引用副本
      • 扫描上下文中的函数声明
        • 在变量对象中为每个找到的函数新建一个和函数名同名属性,其为该函数在内存中的引用指针
        • 若函数名已经存在,则引用指针的值会被覆盖
      • 扫描上下文中的变量声明
        • 在变量对象中为每个找到的变量声明新建一个同名属性并初始化其值为 undefined
        • 若变量名已经存在,则什么都不做而继续扫描
    • 确定这个上下文中 this 的值
  4. 激活/代码执行阶段:
    • 运行/解释在上下文中的函数代码并在代码逐行执行时给变量赋值

让我们来看一个例子:

  1. function foo( i ) {
  2. var a = 'hello';
  3. var b = function privateB() {};
  4. function c() {}
  5. }
  6. foo(22);

在调用 foo(22) 时,创建阶段看起来像是这样:

  1. fooExecutionContext = {
  2. scopeChain: { ... },
  3. variableObject: {
  4. arguments: {
  5. 0: 22,
  6. length: 1
  7. },
  8. i: 22,
  9. c: pointer to function c()
  10. a: undefined,
  11. b: undefined
  12. },
  13. this: { ... }
  14. }

如你所见,在创建阶段处理的是定义属性名而非给它们赋值,除了正式的实参/形参。一旦创建阶段结束,执行流就会进入函数。在函数执行完后,激活/代码执行阶段看起来如下:

  1. fooExecutionContext = {
  2. scopeChain: { ... },
  3. variableObject: {
  4. arguments: {
  5. 0: 22,
  6. length: 1
  7. },
  8. i: 22,
  9. c: pointer to function c()
  10. a: 'hello',
  11. b: pointer to function privateB()
  12. },
  13. this: { ... }
  14. }

说说「提升」

在网上能够找到很多定义 JavaScript 中「提升」这个术语的资源,解释了变量和函数的声明被提升到它们所在函数作用域的顶部的机制。然而,为什么会这样并没做详细说明。如果你已经明白了上面所讲的解释器创建活动对象的过程的话,那么你就会很容易理解。

来看看下面的代码:

  1. (function() {
  2. console.log(typeof foo); // 函数指针
  3. console.log(typeof bar); // undefined
  4. var foo = 'hello';
  5. var bar = function() {
  6. return 'world';
  7. };
  8. function foo() {
  9. return 'hello';
  10. }
  11. })();

这些问题我们现在能够回答了:

  • 为什么在 foo 声明之前我们就能访问它?
    • 如果按照创建阶段来,在激活/代码执行阶段之前变量就已经创建好了。所以当函数被执行时,foo 已经在活动对象中定义了。
  • foo 被声明了两次,为什么最后它显示为 function 而不是 undefinedstring
    • 即使 foo 被声明两次,但在创建阶段时函数先于变量创建在活动对象上,并且在属性名已经存在于活动对象上时会忽略掉重复的声明。
    • 因此,函数 foo() 的引用先在活动对象上创建了,当解释器遇到变量 foo 时属性名 foo 已经存在,所以解释器不做任何处理而继续运行。
  • 为什么 barundefined
    • bar 实际上是一个被赋值为函数的变量,变量是在创建阶段创建的,并且将它们的值初始化为 undefined

与作用域的关系

最容易与「执行上下文」弄混的概念恐怕就是「作用域」了吧?因为它们都与函数中变量的定义和使用有关。然而,它们并不一样。

执行上下文是在函数调用时创建的,是动态的;而作用域是在函数定义时创建的,是静态的。

执行上下文栈

JavaScript 解释器是单线程的,这意味着在程序运行过程中一次只能发生一件事,其他的行为或事件需要在所谓的「执行上下文栈」(也叫「调用栈」)中等待。

执行上下文栈

如上图所示,加载脚本后会默认进入到全局上下文,若这时调用了一个函数,会创建一个新的函数上下文并压入栈中;如果在函数里面又调用了另外一个函数,则会再新建一个函数上下文压入当前栈的顶部。

浏览器永远执行当前栈中顶部的执行上下文,一旦其执行完毕,就会被从栈的顶部弹出而将控制权移交给下一个执行上下文。

下面代码展示了一个递归函数及其产生的执行上下文栈:

  1. (function countDown( num ) {
  2. if ( num < 0 ) {
  3. return;
  4. }
  5. console.log('begin: %s', num);
  6. countDown(num - 1);
  7. console.log('end: %s', num);
  8. })(3)
  9. // begin: 3
  10. // begin: 2
  11. // begin: 1
  12. // begin: 0
  13. // end: 0
  14. // end: 1
  15. // end: 2
  16. // end: 3