likes
comments
collection
share

面试官:你真的懂 JS 执行上下文吗?

作者站长头像
站长
· 阅读数 58

前言

当你面试遇到这样一道题目,你认为两段代码各自的执行结果是多少?

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

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

从调用函数的作用域来分析,有的人可能会说是local scopeglobal scope。但其实javascript采用的是词法作用域,函数的作用域基于函数创建的位置,也就是静态作用域。在定义函数 f() 时,首先在函数内部来判断是否存在变量scope,如果不存在则会根据书写的位置,去上一层寻找直至找到该变量。所以这两段代码的执行结果都是local scope

但是真正值得思考的是,虽然两段代码结果一致,但是这两段代码究竟有什么不同呢? 这就需要我们抽丝剥茧,一点点去解析 javascript 底层是如何执行这段代码的。文章篇幅稍长,干货满满,请各位看官耐心食用。

词法作用域和动态作用域

作用域是指程序源代码中定义变量的区域,也就是确定当前执行代码对变量的访问权限。 正如上文,js 采用的词法作用域(lexical scoping),也就是静态作用域,函数的作用域是在函数定义的时候就已经决定好了。而与之相反的是动态作用域,例如 java,bash等使用的都是动态作用域,函数的作用域是在函数调用时才决定的。我们来举个简单的 🌰:

var value = 1;

function foo() {
    console.log(value);
}

function bar() {
    var value = 2;
    foo();
}

bar();

// 输出结果

分析一下执行过程:

1)如果采用的是静态作用域,执行 foo 函数时,我们在 foo 函数内部找 value,没有找到就根据书写的位置去上一层代码中查到,找到 value = 1,所以结果输出为1。

2)如果是采用动态作用域,依然先从foo内部去找是否存在局部变量 value,没有找到就从调用函数的作用域,bar 函数内部去找 value = 2,所以输出结果为2。

这也就解释了上文为什么都输出 local scope,我们只需要看该函数定义时内部是否有变量,否则就去根据词法位置,向外逐层寻找直到找到该变量。那么这两段代码区别在哪里呢?下面就该聊到js执行上下文了。

执行上下文

开发中我们都有种直观感受 js 是从上到下顺序执行的,但其实这里有个认识误区,javascript 引擎并非是一行一行的分析和执行代码,而是一段一段的分析执行。当执行到一个函数时,它会进行准备工作,也就是生成一个执行上下文(execution context)

当js引擎每次执行一段可执行代码时,都会创建对应的执行上下文,并且有执行上下文栈来管理上下文。 我们用栈模拟下面这段代码执行的顺序,遵循先进后出的顺序。

function fun3() {
    console.log('fun3')
}

function fun2() {
    fun3();
}

function fun1() {
    fun2();
}

fun1();
  • step1:定义一个空栈 stack,当 js 开始要解释执行代码时,肯定最先遇到全局代码,所以初始化时优先将全局执行上下文(globalContext)压入栈。此时stack=[globalContext].

  • step2:执行fun1(),创建 fun1 的执行上下文并入栈,此时stack=[globalContext, fun1 Context]

  • step3:没想到fun1中执行了fun2,还得创建 fun2 的执行上下文;执行完fun2时,万万没想到还有个fun3,那还得创建 fun3 的执行上下文,并且依次压入栈,此时stack=[globalContext, fun1Context, fun2Context, fun3Context]

  • step4:此时fun3执行完毕,执行栈stack会将 fun3 的执行上下文弹出,此时 stack=[global Context, fun1Context, fun2Context]

  • step5:接下来按顺序,fun2,fun1也依次执行完毕,从执行栈中依次弹出,stack 中只剩下全局上下文,此时stack=[globalContext]

  • step6:javascript 接着执行下面的代码,但是 stack 底层永远有个globalContext

所以再回到前言中抛砖引玉提出的面试题:两段代码执行的结果一样,但是两段代码究竟有哪些不同呢?

答案是:执行上下文栈的变化不一样。

// 第一段代码
var scope = "global scope";
function checkscope(){
    var scope = "local scope";
    function f(){
        return scope;
    }
    return f();
}
checkscope();

// 执行上下文栈变化
Stack.push(<checkscope> functionContext);
Stack.push(<f> functionContext);
Stack.pop();
Stack.pop();
// 第二段代码
var scope = "global scope";
function checkscope(){
    var scope = "local scope";
    function f(){
        return scope;
    }
    return f;
}
checkscope()();

//执行上下文栈变化
Stack.push(<checkscope> functionContext);
Stack.pop(); 
// checkscope函数执行完了再执行f函数,所以先出栈,f上下文再入栈
Stack.push(<f> functionContext);
Stack.pop();

这就是两者间的区别,也是 js 在执行过程中背后的动作。那么如果面试官打破砂锅问到底呢?既然你一直在提执行上下文,那执行上下文里究竟是个啥,执行前又准备了哪些东西呢?来,请开始你的表演。

不用慌张,这就不得不提到执行上下文中包含的三个重要属性:变量对象 Vo(Variable object)作用域链(Scope chain)this指针

变量对象

变量对象是与执行上下文相关的数据作用域,存储了在上下文中定义的变量和函数声明。 变量对象又分为全局上下文中的变量对象函数上下文中的变量对象,其中函数上下文中的变量对象又被称为活动对象(activation object, AO)

对于js来说,全局上下文中的变量对象就是全局对象,可以通过this引用,指向window对象。

// 全局对象是由 Object 构造函数实例化的一个对象。
console.log(this instanceof Object);
// output: true

// 可以使用预定义的属性或方法
console.log(Math.random());
console.log(this.Math.random());

// 全局变量的宿主
var i = 1
console.log(this.i)

// 全局对象有window属性指向自身
var a = 1;
console.log(window.a);
this.window.b = 2;
console.log(this.b);

活动对象AO变量对象VO其实是一个东西,只是变量对象是规范上的或者说是引擎实现上的,不可在 JavaScript 环境中访问,只有进入一个执行上下文中,这个执行上下文的变量对象才会被激活,所以才叫 activation object,而只有被激活的变量对象,也就是活动对象上的各种属性才能被访问。

活动对象是在进入函数上下文时刻被创建的,它通过函数的arguments属性初始化。arguments 属性值是 Arguments对象

执行过程中的变量对象

介绍完变量对象的定义,接下来就是代码执行过程中变量对象是如何创建及变化的。

刚进入执行上下文,还没有开始执行时,变量对象包括三个部分:函数的所有形参函数声明变量声明

举个 🌰:

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

  b = 3;

}

foo(1);

以上这个函数,在进入执行上下文但还没执行时,foo 函数的执行上下文中的活动对象 AO 就变成了:

AO = {
    arguments: { // 形参
        0: 1,
        length: 1
    },
    a: 1, // 传的实参
    b: undefined, // 变量声明,还未赋值
    c: reference to function c(){},// 函数声明
    d: undefined  // 函数变量声明,还未赋值
}

而在代码执行阶段,foo 函数的执行会改变活动对象 AO,当执行完毕后 AO 就变成了:

AO = {
    arguments: { // 形参
        0: 1,
        length: 1
    },
    a: 1, // 实参
    b: 3, // 已赋值的变量
    c: reference to function c(){}, // 函数声明
    d: reference to FunctionExpression "d" // 已赋值的变量函数
}

以上便是整个变量对象的创建过程。总结起来就是全局上下文中的变量对象 VO 初始化是全局对象,而函数上下文中的变量对象 AO 初始化只包括 arguments对象;在进入执行上下文时,会给变量对象添加形参,已声明的变量和函数;并且在代码执行阶段再次修改变量对象,为其赋值。

作用域链

接下来聊聊执行上下文中的第二要素:作用域链。前文提到当查找变量的时候,会先从当前上下文的变量对象中查找,如果没有找到,就会从父级(词法层面上的父级)执行上下文的变量对象中查找,一直找到全局上下文的变量对象,也就是全局对象。这样由多个执行上下文的变量对象构成的链表就叫做作用域链

具体来说,函数的作用域在函数定义的时候就已经决定好了,函数内部有一个属性[[scope]],在我们创建函数的时候,[[scope]] 就会保存父级的变量对象,假设我们在foo函数中定义了一个bar函数,我们可以将他们的作用域链视为以下结构:

foo.[[scope]] = [
  globalContext.VO // 全局作用域链
];

bar.[[scope]] = [
    fooContext.AO, // 父级作用域
    globalContext.VO // 全局作用域链
];

那么作用域链如何与变量对象进行关联呢? 执行函数前,我们进入函数执行上下文时,创建变量对象后,就会将变量对象添加到作用域的前面,此时该执行上下文的作用域链为Scope=[AO].concat([[Scope]])。这就是执行上下文中作用域链最终的表现形式。

this指向

剩下最后一个就是老生常谈的this,通常this值取决于脚本运行的执行上下文,也就是由运行时的调用者决定。在全局上下文中,无论是否严格模式,this都是指向globalThis。而在函数上下文中,this的指向取决于函数如何被调用,而不是它如何被定义。

那么我们如何去确定 this 的指向呢?凭感觉当然不行,这里就不得不引出 ECMAScript 规范中的规范类型Reference。

官方对 Reference 的定义:Reference 类型就是用来解释诸如 delete、typeof 以及赋值等操作行为的。

也就是 “只存在于规范里的抽象类型”。它们是为了更好地描述语言的底层行为逻辑才存在的,但并不存在于实际的 js 代码中,它由base valuereference namestrict reference三部分组成。

如果理解起来较为困难,我们又来举个 🌰:

var foo = 1;

// 对应的Reference是:
var fooReference = {
    base: EnvironmentRecord, // 属性所在的对象或者运行环境 EnvironmentRecord(此时是全局环境)
    name: 'foo', // 属性的名字
    strict: false // 是否严格模式 通常为false
};
var foo = {
    bar: function () {
        return this;
    }
};
foo.bar(); // foo

// bar对应的Reference是:
var BarReference = {
    base: foo, // 此时bar所在的对象是foo
    propertyName: 'bar', 
    strict: false
};

具体的可以参考下官方文档,这里只介绍几个和 Reference 相关方法:

  1. GetBase: 获取 Reference 中 base 的值
  2. IsPropertyReference: 判断 Reference 中 base 是否为对象,是 返回 true, 否 返回 false
  3. GetValue: 返回的将是属性具体的值,而不是一个 Reference ,这个很关键

介绍完reference,我们就可以建立与this指向的关联,牢记以下几点即可。

  1. 计算 MemberExpression 的结果赋值给 ref;

  2. 判断 ref 是不是一个 Reference 类型;

  • 如果 ref 是 Reference,并且 IsPropertyReference(ref) 是 true, 那么 this 的值为 GetBase(ref)
  • 如果 ref 是 Reference,并且 base value 是 Environment Record, 那么this的值为 ImplicitThisValue(ref)
  • 如果 ref 不是 Reference,那么 this 的值为 undefined

什么意思呢?MemberExpression 我们可以看做函数调用 () 左边的内容,然后去赋值给 ref 并判断 ref 是否是一个 Reference 类型,从而找到 this 指向的值。

var value = 1;

var foo = {
  value: 2,
  bar: function () {
    return this.value;
  }
}

//示例1
console.log(foo.bar()); // MemberExpression为foo.bar
// bar的 Reference = {  base: foo,  name: 'bar',  strict: false };
// base 为一个对象,IsPropertyReference(ref)为true, this值为foo,结果为2.

//示例2
console.log((foo.bar)()); // MemberExpression为(foo.bar) 
// 虽然被括号包住,实际上()并没有对 `MemberExpression` 进行计算,所以结果示例一相同

//示例3
console.log((foo.bar = foo.bar)()); // MemberExpression为(foo.bar = foo.bar)
// 等号操作 返回值会调用GetValue('等号右边属性')
// 调用 GetValue 返回的将是属性具体的值,而不是 Reference
// 因此this 为 undefined ,非严格模式下会进行隐式转换。

//示例4
console.log((false || foo.bar)()); // MemberExpression为(foo.bar = foo.bar)
// 二元逻辑操作 返回值会调用 GetValue ,返回的不是Reference
// this 指向undefined 

//示例5
console.log((foo.bar, foo.bar)()); // MemberExpression为(foo.bar, foo.bar)
// 逗号操作 返回值会调用 GetValue ,返回的不是Reference
// this 指向undefined

总结

终于快到尾声了,经过上面这一连串的知识梳理,让我们再来看向这道面试题,你是不是有了全新的理解,可以反向拷打面试官了?让我们再次模拟一遍执行上下文栈和执行上下文的具体变化。

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

执行过程如下:

step1:执行全局代码,创建全局执行上下文,全局上下文被压入执行上下文栈

 ECStack = [
      globalContext
  ];

step2:全局上下文初始化,生成 变量对象VO,作用域链 和 this

  globalContext = {
      VO: [global], 
      Scope: [globalContext.VO],  
      this: globalContext.VO
  }

step3:初始化的同时,checkscope 函数被创建,保存作用域链到函数的内部属性 [[scope]]

  checkscope.[[scope]] = [
    globalContext.VO
  ];

step4:执行 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 函数执行上下文被压入执行上下文栈

ECStack = [
    checkscopeContext,
    globalContext
];

step5:checkscope 函数执行上下文初始化:

checkscopeContext = {
    // 初始化活动对象
    AO: {
        arguments: {
            length: 0 // 无形参
        },
        scope: undefined,
        f: reference to function f(){}
    },
    // 先复制checkscope.[[scope]]到Scope,再将活动对象压入作用域链顶端
    Scope: [AO, globalContext.VO], 
    this: undefined
}

step6: f函数执行,沿着scope作用域查找 scope 值并返回。

step7: f函数执行完毕,函数上下文从执行上下文栈中弹出。

ECStack = [
    checkscopeContext,
    globalContext
];

step8:checkscope函数执行完毕,checkscope上下文从执行上下文栈中弹出。

ECStack = [
    globalContext
];

ok,到这里就彻底结束啦,撒花完结!码字不易,喜欢三连,在互联网寒冬中,一起抱团取暖,拷打面试官~

转载自:https://juejin.cn/post/7374437543247740937
评论
请登录