面试官:你真的懂 JS 执行上下文吗?
前言
当你面试遇到这样一道题目,你认为两段代码各自的执行结果是多少?
// 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 scope
和 global 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 value
, reference name
和 strict 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 相关方法:
GetBase
: 获取 Reference 中 base 的值IsPropertyReference
: 判断 Reference 中 base 是否为对象,是 返回 true, 否 返回 falseGetValue
: 返回的将是属性具体的值,而不是一个 Reference ,这个很关键
介绍完reference,我们就可以建立与this指向的关联,牢记以下几点即可。
-
计算
MemberExpression
的结果赋值给 ref; -
判断 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