# 👉 JavaScript 执行上下文

一个执行上下文的生命周期可以分为两个阶段:创建阶段执行阶段

创建阶段:执行上下文会分别创建变量对象,建立作用域链,以及确定 this 的指向。

执行阶段:这是执行上下文对象初始化完毕,开始执行代码,这个时候会完成变量赋值,函数引用,以及执行其他代码。

# 执行上下文栈

JavaScript 引擎创建了执行上下文栈(Execution context stack,ECS)来管理执行上下文。

为模拟执行上下文栈行为,定义执行上下文栈是一个数组 ECStack = []

JavaScript 开始要解释执行代码的时候,最先遇到的就是全局代码,所以初始化的时候首先就会向执行上下文栈压入一个全局执行上下文,我们用 globalContext 表示它,并且只有当整个应用程序结束的时候,ECStack 才会被清空,所以程序结束之前, ECStack 最底部永远有个 globalContext:ECStack = [globalContext]

// 片段1
var scope = "global scope";
function checkscope() {
    var scope = "local scope";
    function f() {
        return scope;
    }
    return f();
}
checkscope();

// 片段2
var scope = "global scope";
function checkscope() {
    var scope = "local scope";
    function f() {
        return scope;
    }
    return f;
}
checkscope()();

对以上两个代码片段的执行上下文栈变化,如下:

// 片段1
ECStack.push(globalContext);
ECStack.push(<checkscope> functionContext);
ECStack.push(<f> functionContext);
ECStack.pop();
ECStack.pop();

// 片段2
ECStack.push(globalContext);
ECStack.push(<checkscope> functionContext);
ECStack.pop();
ECStack.push(<f> functionContext);
ECStack.pop();

# 执行上下文

执行函数,会先创建函数执行上下文,推入执行上下文栈。接着就会初始化执行上下文。(和开篇的第一段话一个意思。)

每个执行上下文都有三个属性:变量对象(Variable Object VO)作用域(Scope chain)this

# 变量对象

变量对象是与执行上下文相关的数据作用域,存储上下文中定义的变量和函数声明,根据不同的执行上下文可大体划分成全局上下文的变量对象函数上下文的活动对象

在全局上下文中的变量对象就是全局对象。

在函数上下文中的变量对象用活动对象(activation object, AO)来表示。

活动对象是在进入函数上下文时被创建的,函数上下文的变量对象初始化只包括 Arguments 对象(arguments)。
只有到当进入一个执行上下文中,这个执行上下文的变量对象才会被激活,所以才叫 activation object 呐,而只有被激活的变量对象,也就是活动对象上的各种属性才能被访问。
AO 在进入到执行阶段的时候被激活,但是激活的除了 VO 之外,还包括函数执行时传入的参数和 arguments 这个特殊对象。

执行上下文会被分成两个阶段进行处理:创建上下文和执行上下文。(咳咳,这句话第三次出现了!)

# 创建执行上下文(针对变量对象分析)

创建执行上下文时,还没执行代码,会先初始化上下文对象。

此时的变量对象会包括(下面也是变量对象属性生成的顺序):

  1. 函数所有形参(如果是在函数上下文中)
  • 名称和对应值会以key:val的形式创建为变量对象的一个属性;

  • 如果没有实参(没有对应值),属性值会设为 undefined

  1. 函数声明
  • 名称和函数对象会以key:val的形式创建为变量对象的一个属性;
  • 如果变量对象已存在相同的属性名称,则函数声明会完全替换掉这个属性
  1. 变量声明
  • 名称和对应值(undefined)会以key:val的形式创建为变量对象的一个属性;
  • 如果变量名称和已声明的形式参数或函数相同,变量声明则不会干扰已存在的这些属性。

理解例子 1:

function foo(a) {
    var b = 2;
    function c() {}
    var d = function() {};

    b = 3;
}

foo(1);

// 创建执行上下文时活动对象
AO = {
    // 初始化阶段只包括arguments
    arguments: {
        0: 1,
        length: 1
    },

    // 进入执行上下文
    a: 1,
    b: undefined,
    c: reference to function c(){},
    d: undefined
}

# 执行上下文 (针对变量对象分析)

在代码执行阶段,会顺序执行代码,根据代码,修改变量对象的值。

// 用上个例子,执行阶段的活动对象
AO = {
    arguments: {
        0: 1,
        length: 1
    },
    a: 1,
    b: 3,
    c: reference to function c(){},
    d: eference to FunctionExpression "d"
}

# 作用域

# 作用域

作用域是定义变量的区域,可确定当前执行代码对变量的访问权限,以及决定着变量的生命周期。

# 作用域链

当在一个函数中访问某个变量时,会先在当前函数上下文的变量对象中作用中检索,如果没检索到这个变量,逐步往上一级的变量对象检索,一直到全局上下文的变量对象(浏览器环境下全局对象 window)。这样由多个执行上下文的变量对象构成的链表就叫做作用域链。

JavaScript 中,采取的是词法作用域(静态作用域),函数作用域在在函数定义的时候就决定了。

下面以一个函数的创建和激活两个时期来讲解作用域链是如何创建和变化的:

# 函数创建(针对作用域分析)

函数创建时,会创建一个内部属性 [[scope]],会将所有的父变量对象保存到其中。(可以理解[[scope]]就是所有父变量对象的层级链)。

function foo() {
    function bar() {
        ...
    }
}

上述函数在创建时,各自的[[scope]]分别如下:

foo.[[scope]] = [globalContext.VO]

bar.[[scope]] = [fooContext.AO, globalContext.VO]

# 函数激活(针对作用域分析)

当函数激活时,进入函数上下文,创建 VO/AO 后,就会将函数自身的活动对象添加到作用链的前端。

这时候执行上下文的作用域链,可命名为 Scope:

Scope = [AO].concat([[scope]]);

这个时候,函数的作用域链构建完毕。

# 作用域的生命周期

ES6 之前支持的作用域大体分为两种:
1)全局作用域中的对象在代码中的任何地方都能访问,其生命周期伴随着页面的生命周期。

2)函数作用域就是在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部定义的变量会被销毁。

ES6 支持块级作用域:
通过{}(if、for、try-catch)提供块级作用域,用块内声明的变量不影响块外面的变量。

function varTest() {
    var x = 1;
    if (true) {
        var x = 2; // 同样的变量!
        console.log(x); // 2
    }
    console.log(x); // 2
}

其中提出块级作用域的原因:
1)变量提升容易导致变量不知不觉被覆盖

var myname = "极客时间";
function showName() {
    console.log(myname); // undefined

    // 不执行
    if (0) {
        var myname = "极客邦";
    }

    console.log(myname); // undefined
}
showName();

2)本应销毁的变量没及时被销毁的问题

function foo() {
    for (var i = 0; i < 7; i++) {}

    // i在for循环结束以后,依然没被销毁
    console.log(i); // 7
}
foo();

# this

对 this 指向的简单理解:this 是在函数被调用时发生的绑定,指向什么取决于函数在哪里被调用。主要的规则有以下几种:
(1)new 调用,指向实例对象;
(2)显式绑定 call、bind、apply,指向指定对象;
(3)隐式绑定,根据调用位置的上下文对象;
(4)默认绑定:严格模式下指向 undefined,全局情况下指向 window

function foo() {
    console.log(this.a);
}

var obj = { a: 1, foo: foo };

// 对象.方法的形式调用,this 指向调用者,即obj
obj.foo(); // 1

// 对象属性引用链只要在上一层或者最后一层在调用位置中起作用
var obj2 = {
    a: 2,
    obj: obj,
};

obj2.obj.foo(); // 1

// 隐式丢失,会应用默认绑定
var a = "global";
var bar = obj.foo; //函数别名,实际引用的是foo函数本身,bar此时不带任何修饰的函数调用
bar(); // global

// 回调函数参数也容易发生丢失,此时传进去的只是foo函数地址,与obj没关系
function doFun(fn) {
    fn();
}
doFun(obj.foo); // global;

// 间接引用
const p = { a: 3 };
obj.foo();
(p.foo = obj.foo)(); //global

(5)箭头函数中通过查找作用域链来决定 this 的值:
this 指向当前包含箭头函数的外层的最近非箭头函数的 this,否则,找不到就是 window(严格模式是 undefined)。

// 例子1
function foo() {
    return () => {
        // this继承foo
        console.log(this.a);
    };
}

const obj1 = {
    a: 1,
};

const obj2 = {
    a: 2,
};

var bar = foo.call(obj1); // 此时foo的this绑定到了obj1
bar.call(obj2); // 1

// 例子2
const obj2 = {
    val: "obj2",
    foo: function() {
        const val = "foo";
        const bar = () => {
            console.log(this.val);
        };
        bar();
    },
};
obj2.foo();

// bar() 找到最近的非箭头函数为 foo,而此时 foo 是被 obj2 调用,即 foo 中的 this 指向 obj2
// 因此bar中的this指向obj2
// 输出:'obj2'

//  例子3
const obj3 = {
    foo: function() {
        console.log(this);
        const bar = () => {
            console.log(this);
        };
        bar();
    },
};

const baz = obj3.foo;
baz();
// window
// window

// bar() 找到最近的非箭头函数为 foo,而 baz() 是方法调用, 即 baz 中的 this 指向全局上下文,因此箭头函数中 this 指向全局上下文 window

(6)setTimeout 中函数内的 this 是指向了 window 对象:
由于 setTimeout()调用的代码运行在与所在函数完全分离的执行环境上。这会导致这些代码中包含的 this 关键字会指向 window (或全局)对象。

var num = 0;
function Obj() {
    this.num = 1;

    this.getNum = function() {
        console.log(this.num);
    };

    this.getNumLater = function() {
        setTimeout(function() {
            console.log(this.num);
        }, 1000);
    };
}
var obj = new Obj();
obj.getNum(); //1  打印的是obj.num,值为1
obj.getNumLater(); //0  打印的是window.num,值为0

// 修改setTimeout里面的this指向的方法
// 1. 箭头函数
function Obj() {
    this.num = 1;

    this.getNum = function() {
        console.log(this.num);
    };

    this.getNumLater = function() {
        setTimeout(() => {
            console.log(this.num);
        }, 1000);
    };
}

// 2.闭包
function Obj() {
    this.num = 1;
    const that = this;
    this.getNum = function() {
        console.log(this.num);
    };

    this.getNumLater = function() {
        setTimeout(function() {
            console.log(that.num);
        }, 1000);
    };
}

// bind
function Obj() {
    this.num = 1;
    const that = this;
    this.getNum = function() {
        console.log(this.num);
    };

    this.getNumLater = function() {
        setTimeout(
            function() {
                console.log(that.num);
            }.bind(this),
            1000
        );
    };
}

this 的另一个深入学习角度 (opens new window)

# 执行上下文过程例子

var scope = "global scope";
function checkscope() {
    var scope = "local scope";
    function f() {
        return scope;
    }
    return f();
}
checkscope();
// 1. 执行全局代码,创建一个全局执行上下文栈,将全局上下文压入执行上下文栈。
ECStack = [globalContext];


// 2. 初始化全局执行上下文
globalContext = {
    VO: {},
    Scope: [globalContext.VO],
    this: globalContext.VO
};


// 3. 初始化的同时,checkscope函数会被创建,此时函数会生成一个[[scope]]属性,将作用域链保存
checkscope.[[scope]] = [globalContext.VO];


// 4. 执行函数checkscope(并非马上执行)
// 此阶段会分为预编译阶段和执行阶段
// 4.1 预编译阶段会创建checkscope函数执行上下文,并将其压入上下文栈中, 以及执行上下文初始化
ECStack = [globalContext, checkscopeContext];

// 4.1 checkscope函数执行上下文初始化
// 1)用 arguments 创建活动对象,初始化活动对象,即加入形参、函数声明、变量声明
// 2)复制函数 [[scope]] 属性创建作用域链
// 3)将活动对象压入 checkscope 作用域链Scope顶端
// 4)初始化this指向为undefined
checkscopeContext = {
  AO: {
      arguments: {
          length: 0
      },
      scope: undefined,

      // 处于预编译阶段中函数声明的时候,此时只是创建了f函数(只是创建了f函数的[[scope]]属性,这个属性只包含了checkscope函数的活动对象和全局变量对象,并不包含f函数的活动对象)
      f: reference to function f(){}
  },

  Scope: [AO, globalContext.VO],

  this: undefined
};

// 在此时f函数会被创建,f函数的内部属性[[scope]]会保存作用域链
f.[[scope]] = [checkscopeContext.AO, globalContext.VO],


// 4.2 等到函数checkscope处于执行阶段时
// 根据执行情况,修改AO里面的变量属性值

// 1)此时 AO.scope会变成‘local scope’
// 2)return f(),此时才会调用f(),这时候才会创建f函数上下文,进入第5步


// 5. 执行函数f,f函数此阶段也会分为预编译阶段和执行阶段
// 5.1 预编译阶段会创建f函数执行上下文,并将其压入上下文栈中, 以及执行上下文初始化 (跟第4.1步相同)
ECStack = [globalContext, checkscopeContext, fContext];

// 5.1 f函数执行上下文初始化,
// 1)用argument创建活动对象,然后初始化活动对象,加入形参、函数声明、变量声明
// 2)复制函数的[[scope]]属性创建作用域Scope
// 3)将自身的活动对象压入 f.Scope 作用域顶端
// 4)初始化this指向undefined
fContext = {
  AO: {
    arguments: {
      length: 0
    },
  },

  Scope: [AO, checkContext.AO, globalContext.VO],

  this: undefined
}

// 5.2 f函数执行上下文执行,即执行return scope
// fContext的AO不存在此变量,因此将会沿着作用域链查找 scope 值
// 在checkscopeContext.AO中找到,返回 scope 值

// 6. f函数执行完,f函数上下文从执行上下文栈中弹出
ECStack = [
    checkscopeContext,
    globalContext
];

// 7. checkscope函数执行完毕,checkscope执行上下文从执行上下文栈中弹出
ECStack = [globalContext];

# 额外加餐:JavaScript 如何支持块级作用域

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

JS 引擎在编译时遇到 let、const 声明的变量,不会被创建在变量环境中,而是在词法环境(Lexical Environment)中被创建。

词法环境是执行环上下文组成的另一个部分,let 和 const 声明的变量在执行阶段被创建而不是编译阶段中被创建。当 JS 引擎执行完块级作用域中所有的代码,let 和 const 声明的相关变量会被移除。

具体查找方式是:沿着词法环境的栈顶向下查询,如果在词法环境中的某个块中查找到了,就直接返回给 JavaScript 引擎,如果没有查找到,那么继续在变量环境中查找。

更详细解析,可戳:

  1. JavaScript 是如何支持块级作用域的 (opens new window)

  2. https://blog.csdn.net/feral_coder/article/details/106447013