问题引出
- 当JS引擎处理一段脚本的时候,它是以怎样的顺序解析和执行的?
- 脚本中的变量是怎样被创建的?
- 他们之间错综复杂的访问关系又是怎样创建和链接的?
这种时候,就必须了解执行上下文的概念
什么是执行上下文
当JS引擎解析到可执行代码段时(通常是函数调用阶段),就会进行一些执行前的准备工作,这些准备工作,就是执行上下文,也称为执行环境
- 通常需要准备的工作有:
- 变量对象的定义
- 作用域链的扩展
- 提供调用者的对象引用
执行上下文的类型
- 全局执行上下文:
- 一个程序只有一个全局执行上下文,因为在
js脚本的生命周期中,它都是存在于执行堆栈的最底部,并不会被栈弹出销毁 - 全局执行上下文会生成一个全局对象,在浏览器环境中,即使
window对象,并且会将this值绑定到全局对象上
- 一个程序只有一个全局执行上下文,因为在
- 函数执行上下文:每当一个函数被调用时,都会创建一个新的函数执行上下文,不管这个函数是不是被重复调用
- Eval函数执行上下文:待补充。。。
ES3执行上下文内容
执行上下文是一个抽象的概念,我们可以理解为一个Object,执行上下文的内容包括
- 变量对象 VO
- 活动对象 AO
- 作用域链 scope chain
- 调用者信息 this value
数据结构模拟
executionContext: {
[variable object | activation object]: {
arguments,
variables: [...],
functions: [...]
},
scope chain: variable object + all parents scopes
thisValue: context object
}
变量对象(variable object 简称 VO)
- 每一个执行上下文都一个存储变量的对象———变量对象
- 全局执行上下文的变量对象就是全局对象,是始终存在的,在浏览器环境下,就是
window对象 - 函数执行上下文的变量对象是函数内部定义的属性(包含参数、变量、函数/方法),只会在函数执行过程中存在
VO是不能直接访问的,只有在函数进入执行阶段,VO被激活成AO,我们才能访问其中的属性
函数执行上下文的VO的构建
当函数被调用且具体函数代码执行之前,JS引擎会
- 用当前函数的参数列表
arguments初始化一个变量对象,并将其与当前执行上下文关联 - 函数代码块中声明的变量和函数也会被当作属性添加到变量对象
注意点:函数声明会被添加到变量对象上,函数表达式不会
活动对象(activation object 简称 AO)
作用:当函数进入执行阶段,VO被激活成AO,我们就可以访问其中的属性
其实AO和VO本质上是同一个东西,只是所处的阶段不同
作用域链(scope chain)
作用域:规定如何查找变量,也就是规定当前执行代码对变量的访问权限
JavaScript采用的是词法作用域,也就是静态作用域
静态作用域与动态作用域
- 因为
JavaScript采用的是词法作用域,也就是静态作用域,所以函数的作用域在函数定义的时候就确定了- 与词法作用域相对的动态作用域,函数的作用域是在函数调用的时候确定的
var value = 1;
function foo() {
console.log(value);
}
function bar() {
var value = 2;
foo();
}
bar();
// 输出:1
分析:执行foo(),先从自身的VO查找变量value;找不到,再到父级的VO查找;
- 如果函数的作用域是在函数定义的时候确定的,那么
foo()的VO的父级就是window对象- 如果函数的作用域是在函数调用的时候确定的,那么
foo()的VO的父级就是bar()的VO
因为JavaScript是采用词法作用域,函数的作用域是在函数定义的时候确定,所以此时foo()的VO的父级是window对象,查找到的value值为1
作用域链:
查找变量时,先从当前执行上下文的VO中查找,再到父级执行上下文的VO,直到全局执行上下文的VO,即全局对象。如此有多个执行上下文的VO形成的链表就称为作用域链
graph TD
当前执行上下文的VO --> 父级执行上下文的VO --> 父级... -->全局对象
作用域链的构建
每个执行上下文的作用域都是由当前VO和父级执行上下文的作用域链构成
function test(num){
var a = "2";
return a+num;
}
test(1);
- 执行流开始 初始化function test,test函数会维护一个私有属性 [[scope]],并使用当前环境的作用域链初始化,在这里就是 test.[[Scope]]=global scope.
- test函数执行,这时候会为test函数创建一个执行环境,然后通过复制函数的[[Scope]]属性构建起test函数的作用域链。此时 test.scopeChain = [test.[[Scope]]]
- test函数的活动对象被初始化,随后活动对象被当做变量对象用于初始化。即 test.variableObject = test.activationObject.contact[num,a] = [arguments].contact[num,a]
- test函数的变量对象被压入其作用域链,此时 test.scopeChain = [ test.variableObject, test.[[scope]]];
调用者信息 this value
如果当前函数被作为对象方法调用或者使用apply bind call等API委托调用,则将当前函数的调用者信息this value存入当前执行上下文,否则默认为全局对象调用
ES3执行上下文的生命周期
- 创建阶段
- 执行阶段
- 销毁阶段 下面分析的过程主要针对函数执行上下文
创建阶段
- 利用函数
arguments参数列表初始化一个VO,并将其与当前函数的执行上下文关联,再将函数内部声明的变量和函数作为属性添加到VO。在这个阶段,会对变量和函数进行初始化:变量全部定义为undefined,而函数会直接定义 - 构建作用域链
- 确定
this的值
执行阶段
JS引擎开始逐行执行代码,对变量进行赋值,开始沿着作用域链查找变量,如果发生了函数调用,就创建一个新的执行上下文,并压入执行栈,同时将控制权交给当前执行上下文
销毁阶段
一般情况: 当前函数执行完后,当前执行上下文会被弹出执行栈并销毁,控制权交给执行栈中上一层的执行上下文
特殊情况: 存在闭包
扩展:闭包
定义: 闭包就是有权访问另一函数内部变量的函数
举例: 如果一个函数是另一个函数的返回值,并且该函数在外部被调用,那么这个函数就称为闭包
function Fn() {
var a = 1
return function () {
console.log(a);
}
}
var f = Fn()
f()
// 输出:1
分析:
一般情况下,父函数执行完后,其自身的执行上下文就会被销毁,但是因为闭包的作用域链在引用父函数的VO,导致父函数的VO一直存在于内存当中,因此无法销毁。
只有当闭包不再引用父函数的
VO,父函数的执行上下文才会被销毁。过度使用闭包会造成内存泄漏
ES5中的执行上下文
在ES5规范中,主要对ES3的两个概念做了调整,去除了ES3中的变量对象和活动对象,新增了词法环境组件LexicalEnvironment compoennt和变量环境组件variableEnvironment component来替代
数据结构模拟
executionContext = {
ThisBinding = <this value>,
LexicalEnviroment = {...},
VariableEnviroment = {...}
}
词法环境
- 词法环境是一种规范类型
- 定义标识符与具体的变量及函数的关联,也就是一种标识符——变量映射结构
- 由环境记录器与**外部词法环境引用(默认为 null)**组成
变量环境
ES5之前新增变量环境,是为了给ES6服务的
- 变量环境也是一个词法环境,有着词法环境的所有特性
- 词法环境存储函数声明和变量绑定(
let 和 const) - 变量环境存储变量绑定(
var)
ES5 执行上下文总结
全局执行上下文的构建
程序启动,创建全局执行上下文
-
创建全局上下文的词法环境
- 创建对象环境记录器,处理函数声明和
let / const定义的变量 - 创建外部词法环境引用,值为
null
- 创建对象环境记录器,处理函数声明和
-
创建全局上下文的变量环境
- 创建对象环境记录器,处理
var声明的变量 - 创建外部词法环境引用,值为
null
- 创建对象环境记录器,处理
-
确定
this为全局对象,浏览器环境下,就是window对象
函数执行上下文的构建
函数被调用,创建函数执行上下文
-
创建函数上下文的词法环境
- 创建声明式环境记录器,存储参数列表
arguments、let / const定义的变量、函数 - 创建外部词法环境引用,值为全局对象、或者父级词法环境(作用域)
- 创建声明式环境记录器,存储参数列表
-
创建函数上下文的变量环境
- 创建声明式环境记录器,存储参数列表
arguments、var定义的变量、函数 - 创建外部词法环境引用,值为全局对象、或者父级词法环境(作用域)
- 创建声明式环境记录器,存储参数列表
执行上下文栈
当JS引擎开始解析脚本代码,就会创建一个全局执行上下文,并压入栈底(在程序销毁前都会一直在栈底)
JS引擎每发现一次函数调用,都会新建一个函数执行上下文,并压入栈中,以及将控制权交给当前执行上下文。等到函数执行完成,就会将该执行上下文从栈内弹出并销毁,然后将控制权交给下一个函数执行上下文
练习题
在网上找了几条执行上下文比较典型的面试题,大家可以试一试:
第一题:
var foo = function () {
console.log('foo1');
}
foo();
var foo = function () {
console.log('foo2');
}
foo();复制代码
第一题没什么,应该能写出来。
第二题:
foo();
var foo = function foo() {
console.log('foo1');
}
function foo() {
console.log('foo2');
}
foo();复制代码
全局执行环境自动创建,过程中生成了变量对象进行函数变量的属性收集,造成了函数声明提升、变量声明提升。由于函数声明提升更加靠前,且如果 var 定义变量的时候发现已有同名函数定义则跳过变量定义,上面的代码其实可以写成下面这样:
function foo () {
console.log('foo2');
}
foo();
foo = function foo() {
console.log('foo1');
};
foo();复制代码
第三题:
var foo = 1;
function bar () {
console.log(foo);
var foo = 10;
console.log(foo);
}
bar();复制代码
bar 函数运行,内部变量申明提升,当执行代码块中有访问变量时,先查找本地作用域,找到了 foo 为 undefined ,打印出来。然后 foo 被赋值为 10 ,打印出 10。
第四题:
var foo = 1;
function bar () {
console.log(foo);
foo = 2;
}
bar();
console.log(foo);复制代码
这题也是考察的作用域链查找,bar 里操作的 foo 本地没有定义,所以应该是上层作用域的变量。
第五题:
var foo = 1;
function bar (foo) {
console.log(foo);
foo = 234;
}
bar(123);
console.log(foo);复制代码
运行 bar 函数的时候将 123 数字作为实参传入,所以操作的还是本地作用域的 foo。
第六题:
var a = 1;
function foo () {
var a = 2;
return function () {
console.log(a);
}
}
var bar = foo();
bar();复制代码
这道题目主要考察闭包和函数作用域的概念,我们只要记住:函数能够访问到的上层作用域,是在函数声明时候就已经确定了的,函数声明在哪里,上层作用域就在哪里,和拿到哪里执行没有关系。这道题目中,匿名函数被作为闭包返回并在外部调用,但它内部的作用域链引用到了父函数的变量对象中的 a ,所以作用域链查找时,打印出来的是 2。
第七题:
"use strict";
var a = 1;
function foo () {
var a = 2;
return function () {
console.log(this.a);
}
}
var bar = foo().bind(this);
bar();复制代码
这题考察的是执行环境中的 this 指向的问题,由于闭包内明确指定访问 this 中的 a 属性,并且闭包被 bind 绑定在全局环境下运行,所以打印出的是全局对象中的 a。
关于最后一题,评论区有朋友说 bind 加和不加都一样,于是我改用了严格模式。需要注意的是,严格模式下禁止函数内的 this 指向全局变量。