# 👉 JavaScript 执行上下文
# JavaScript 代码执行
在展开介绍 JavaScript 执行上下文前,先简单介绍一下 JavaScript 代码执行顺序。
先让我们看一段代码:
showName();
console.log(myname);
var myname = '极客时间';
function showName() {
console.log('函数showName被执行');
}
如果对变量提升有一点了解,应该会猜到第 1 行 showName 会正常执行输出“函数showName被执行”,第 2 行输出“undefined”。那什么是变量提升呢?
所谓的变量提升,是指在JavaScript代码执行过程中,JavaScript 引擎把变量的声明部分和函数的声明部分提升到代码开头的“行为”。变量被提升后,会给变量设置默认值,这个默认值就是熟悉的 undefined。
其中关于变量提升的一些结论:
- 在一个变量定义之前使用它,不会出错,但该变量的值为 undefined,而不是定义时的值。在一个函数定义之前使用它,不会出错,且函数能正确执行
- 如果在编译阶段,存在两个相同的函数,后定义的会覆盖掉之前定义的,最终存放在变量环境中的是最后定义的那个
- 如果在编译阶段,存在同名的函数声明和变量声明,函数声明的优先级高于变量声明。但后续的变量赋值会覆盖函数声明。
下面我们来模拟下上面那段代码变量提升的实现:
/*
* 变量提升部分
*/
// 把变量myname提升到开头,
// 同时给myname赋值为undefined
var myname = undefined;
// 把函数showName提升到开头
function showName() {
console.log('showName 被调用');
}
/*
* 可执行代码部分
*/
showName()
console.log(myname)
// 去掉var声明部分,保留赋值语句
myname = '极客时间'
从上面的模拟实现中,可以看出所谓的“变量提升”意味着变量和函数的声明会在物理层面移动到代码的最前面。但,这并不准确。实际上变量和函数声明在代码里的位置是不会改变的,而且是在编译阶段被JavaScript引擎放入内存中。
从上图可以看出,输入一段代码,经过编译后,会生成两部分内容:执行上下文(Execution context)和可执行代码。
执行上下文是 JavaScript 执行一段代码时的运行环境,比如调用一个函数,就会进入这个函数的执行上下文,确定该函数在执行期间用到的诸如 this、变量、对象以及函数等。
接下来,我们结合上面那段代码来分析下是如何生成变量环境对象的:
- 第 1 - 2 行,由于这两行代码不是声明操作,所以 JavaScript 引擎不会做任何处理;
- 第 3 行,由于这行是经过 var 声明的,因此 JavaScript 引擎将在环境对象中创建一个名为 myname 的属性,并使用 undefined 对其初始化;
- 第 4 行,JavaScript 引擎发现了一个通过 function 定义的函数,所以它将函数定义存储到堆 (HEAP)中,并在环境对象中创建一个 showName 的属性,然后将该属性值指向堆中函数的位置。这样就生成了一个属于全局的变量环境对象。接下来 JavaScript 引擎会把声明以外的代码编译为字节码。
直到 JavaScript 引擎开始执行“可执行代码”,将会按照顺序一行一行地执行:
- 当执行到 showName 函数时,JavaScript 引擎便开始在变量环境对象中查找该函数,由于变量环境对象中存在该函数的引用,所以 JavaScript 引擎便开始执行该函数,并输出“函数 showName 被执行”结果。
- 接下来打印 myname 变量值,JavaScript 引擎继续在变量环境对象中查找该对象,由于变量环境存在 myname 变量,其值为 undefined,所以这时候就输出 undefined。
- 接下来执行把 “极客时间” 赋给 myname 变量,赋值后变量环境中的 myname 属性值改变为“极客时间”。
以上就是一段代码的编译和执行流程,其主要目的是了解 JavaScript 的执行机制:先编译,创建执行上下文,再执行,同时也引出执行上下文的概念,下面将对执行上下文展开讲解。
小彩蛋:这里留下了一些代码 case 来总结一下变量提升的那些结论,感兴趣可以看看他们的运行结果。
// 彩蛋1
showName();
var showName = function() {
console.log(2);
}
function showName() {
console.log(1);
}
// 1
// 彩蛋2
var showName = function() {
console.log(2);
}
function showName() {
console.log(1);
}
showName();
// 2
// 彩蛋3
console.log('第一次输出a:', a)
console.log('第一次输出a():', a())
var a = 1
function a() {
console.log('执行a函数,并输出1');
}
console.log('第二次输出a:', a)
a = 3
console.log('第二次输出a():', a())
// 第一次输出a:ƒ a(){}...
// 执行a函数,并输出1
// 第一次输出a(): undefined
// 第二次输出a: 1
// TypeError: a is not a function
# 执行上下文栈
一个执行上下文的生命周期可以分为两个阶段:创建阶段与执行阶段。
创建阶段:执行上下文会分别创建变量对象,建立作用域链,以及确定 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();
# 执行上下文
当调用该函数时,JavaScript引擎会编译该函数,并为其创建一个执行上下文,最后还将该函数的执行上下文压入栈中。
每个执行上下文都有三个属性:变量对象(Variable Object VO)
、作用域(Scope chain)
、this
。
# 变量对象
变量对象是与执行上下文相关的数据作用域,存储上下文中定义的变量和函数声明,根据不同的执行上下文可大体划分成全局上下文的变量对象和函数上下文的活动对象:
在全局上下文中的变量对象就是全局对象。
在函数上下文中的变量对象用活动对象(activation object, AO)来表示。
活动对象是在进入函数上下文时被创建的,函数上下文的变量对象初始化只包括 Arguments 对象(arguments)。
只有到当进入一个执行上下文中,这个执行上下文的变量对象才会被激活,所以才叫 activation object 呐,而只有被激活的变量对象,也就是活动对象上的各种属性才能被访问。
AO 在进入到执行阶段的时候被激活,但是激活的除了 VO 之外,还包括函数执行时传入的参数和 arguments 这个特殊对象。
执行上下文会被分成两个阶段进行处理:创建初始化上下文和执行上下文。
# 创建执行上下文
创建执行上下文时,还没执行代码,会先初始化上下文对象。
此时的变量对象会包括(下面也是变量对象属性生成的顺序):
函数所有形参(如果是在函数上下文中)
- 名称和对应值会以
key:val
的形式创建为变量对象的一个属性; - 如果没有实参(没有对应值),属性值会设为
undefined
。
- 名称和对应值会以
函数声明
- 名称和函数对象会以
key:val
的形式创建为变量对象的一个属性; - 如果变量对象已存在相同的属性名称,则函数声明会完全替换掉这个属性。
- 名称和函数对象会以
变量声明
- 名称和对应值(undefined)会以
key:val
的形式创建为变量对象的一个属性; - 如果变量名称和已声明的形式参数或函数相同,变量声明则不会干扰已存在的这些属性。
- 名称和对应值(undefined)会以
理解例子 1:
function foo(a) {
var b = 2;
function c() {}
var d = function() {};
b = 3;
}
foo(1);
// 创建执行上下文时活动对象
fooContext = {
AO: {
// 初始化阶段只包括arguments
arguments: {
0: 1,
length: 1
},
// 进入执行上下文
a: 1,
b: undefined,
c: reference to function c(){},
d: undefined,
}
}
# 执行上下文
在代码执行阶段,会顺序执行代码,根据代码,修改变量对象的值。
// 用上个例子,执行阶段的活动对象
fooContext = {
AO: {
arguments: {
0: 1,
length: 1
},
a: 1,
b: 3,
c: reference to function c(){},
d: reference to FunctionExpression "d"
}
}
# 作用域
作用域是定义变量的区域,可确定当前执行代码对变量的访问权限,以及决定着变量的生命周期。
# 作用域链
当在一个函数中访问某个变量时,会先在当前函数上下文的变量对象中作用中检索,如果没检索到这个变量,逐步往上一级执行上下文的变量对象中检索,一直到全局上下文的变量对象(浏览器环境下全局对象 window)。这样由多个执行上下文的变量对象构成的链表就叫做作用域链。
作用域链是由词法作用域决定(静态作用域),词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以函数作用域在函数定义声明的时候就决定了。
下面以一个函数的创建和激活两个时期来讲解作用域链是如何创建和变化的:
# 函数创建(针对作用域分析)
函数创建时,会创建一个内部属性 [[scope]]
,会将所有的父变量对象保存到其中。(可以理解[[scope]]
就是所有父变量对象的层级链,特别注意的是在函数创建时就会创建)。
function foo() {
function bar() {
...
}
}
// 上述函数在创建时,各自的`[[scope]]`分别如下:
foo.[[scope]] = [globalContext.VO]
bar.[[scope]] = [fooContext.AO, globalContext.VO]
function bar() {
...
}
function foo() {
...
bar();
}
// 上述函数在创建时,各自的`[[scope]]`分别如下:
foo.[[scope]] = [globalContext.VO]
bar.[[scope]] = [globalContext.VO]
// 尽管bar是在foo中才调用的,但因为bar的声明位置在全局上下文下,因此bar的父级作用域指向全局上下文
# 函数激活(针对作用域分析)
当函数激活时,进入函数上下文(可以理解为编译创始化执行上下文时),创建 VO/AO 后,就会将函数自身的活动对象添加到作用链的前端。
这时候执行上下文的作用域链,可命名为 Scope:
Scope = [AO].concat([[scope]]);
这个时候,函数的作用域链构建完毕。
# 作用域的生命周期
ES6 之前支持的作用域大体分为两种:
1)全局作用域中的对象在代码中的任何地方都能访问,其生命周期伴随着页面的生命周期。
2)函数作用域就是在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部定义的变量会被销毁。
ES6 支持块级作用域:
通过{}
(if、for、try-catch)提供块级作用域,用块内声明的变量不影响块外面的变量。
var x = 0;
function varTest() {
var x = 1;
if (true) {
var x = 2; // 同样的变量!
console.log(x); // 2
}
console.log(x); // 2
}
console.log(x); // 0
// 上面代码解释:
// 1. 由于 var 的变量提升特性,函数内的所有 var 声明都会被提升到其作用域的顶部,只有声明被提升,赋值不会被提升。
// 等同于:
function varTest() {
var x; // 变量声明被提升
x = 1;
if (true) {
x = 2; // 修改的是同一个变量
console.log(x); // 2
}
console.log(x); // 2
}
// 2. 在函数内部,所有的 var x 声明实际上都是同一个变量,因为它们都在同一个函数作用域内
// 3. 当函数执行完毕后,函数作用域内的变量 x 被销毁
// 4. 最后一行 console.log(x) 输出的是全局作用域中的 x,即 0
其中提出块级作用域,可以更好地避免以下问题发生:
1)变量提升容易导致变量不知不觉被覆盖
var myname = "极客时间";
function showName() {
console.log(myname); // undefined
// 不执行
if (0) {
var myname = "极客邦";
var showNewName= "newName";
}
console.log(myname, showNewName); // undefined
}
showName();
// 以上代码等同于,其中涉及知识点有:
function showName() {
// 知识点 1: 变量提升。变量声明被提升到函数作用于顶部,赋值不会
var myname;
var showNewName;
console.log(myname); // undefined
// 暂时性死区
if (0) {
myname = "极客邦";
showNewName = "newName";
}
// 知识点 2: 作用域遮蔽。函数内的 var myname 声明会创建一个新的变量,会遮蔽全局作用域中的同名变量,这就是为什么函数内无法访问到全局的 "极客时间" 值
console.log(myname, showNewName); // undefined undefined
}
2)本应销毁的变量没及时被销毁的问题
function foo() {
for (var i = 0; i < 7; i++) {}
// i在for循环结束以后,依然没被销毁
console.log(i); // 7
}
foo();
# this
this 没有作用域的限制,对 this 指向的简单理解:this 是在函数被调用时发生的绑定,指向什么取决于函数在哪里被调用。主要的规则有以下几种:
(1)new 调用,指向实例对象;
(2)显式绑定 call、bind、apply,指向指定对象;
(3)隐式绑定,根据调用位置的上下文对象;
(4)默认绑定:在默认情况下调用一个函数,其执行上下文中的 this 是默认指向全局对象 window 的,严格模式下指向 undefined。
function foo() {
console.log(this.a);
}
var obj = { a: 1, foo: foo };
// 属于3,对象.方法的形式调用,this 指向调用者,即obj
obj.foo(); // 1
// 对象属性引用链只要在上一层或者最后一层在调用位置中起作用
var obj2 = {
a: 2,
obj: obj,
};
obj2.obj.foo(); // 1
// 属于4,隐式丢失,会应用默认绑定
var a = "global";
var bar = obj.foo; //函数别名,实际引用的是foo函数本身,bar此时不带任何修饰的函数调用
bar(); // global
// 属于4,回调函数参数也容易发生丢失,此时传进去的只是foo函数地址,与obj没关系
function doFun(fn) {
fn();
}
doFun(obj.foo); // global;
// 间接引用
const p = { a: 3 };
obj.foo(); // 1
(p.foo = obj.foo)(); //global
var myObj = {
name: "极客时间",
showThis: function() {
console.log(this);
function bar() {console.log(this);}
// 属于4,
// bar 是一个普通函数(非箭头函数、非对象方法),根据默认绑定规则,当函数直接独立调用时,`this`在非严格模式下指向全局对象(浏览器中是`window`,Node.js中是`global`),严格模式下是`undefined`。
bar();
}
};
// 属于3
myObj.showThis();
(5)箭头函数中通过查找作用域链来决定 this 的值:
箭头函数没有自己的 this,它会继承定义时所在作用域的 this。
this 指向当前包含箭头函数的外层的最近非箭头函数的 this,否则,找不到就是 window(严格模式是 undefined)。
箭头函数的 this 绑定不能被修改,即使使用 call、apply 或 bind
// 例子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 变量接收了返回的箭头函数
bar.call(obj2); // 1
//针对最后一行代码:
// 虽然使用 call 试图将箭头函数的 this 绑定到 obj2
// 但是箭头函数的 this 是在定义时继承自外层函数的 this
// 所以箭头函数的 this 仍然指向 obj1,因此输出 obj1.a 的值: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
// 箭头函数没有自己的 this,它会继承定义时所在作用域的 this。
// 因此bar中的this指向obj2,输出:'obj2'
// const obj2 = {
// val: "obj2",
// foo: function() {
// const val = "foo";
// function bar() { // 如果改为普通函数,则输出undefined
// console.log(this.val);
// }
// bar();
// },
// };
// obj2.foo();
// 例子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 如何支持块级作用域
// demo1
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();
// demo2
let myname= '极客时间'
{
console.log(myname)
let myname= '极客邦'
}
JS 引擎在编译时遇到 let、const 声明的变量,不会被创建在变量环境中,而是在词法环境(Lexical Environment)中被创建。
词法环境是执行环上下文组成的另一个部分,let 和 const 声明的变量在执行阶段被创建而不是编译阶段中被创建。当 JS 引擎执行完块级作用域中所有的代码,let 和 const 声明的相关变量会被移除。
具体查找方式是:沿着词法环境的栈顶向下查询,如果在词法环境中的某个块中查找到了,就直接返回给 JavaScript 引擎,如果没有查找到,那么继续在变量环境中查找。
更详细解析,可戳:
https://blog.csdn.net/feral_coder/article/details/106447013