执行上下文

132 阅读11分钟

问题引出

  1. 当JS引擎处理一段脚本的时候,它是以怎样的顺序解析和执行的?
  2. 脚本中的变量是怎样被创建的?
  3. 他们之间错综复杂的访问关系又是怎样创建和链接的?

这种时候,就必须了解执行上下文的概念

什么是执行上下文

JS引擎解析到可执行代码段时(通常是函数调用阶段),就会进行一些执行前的准备工作,这些准备工作,就是执行上下文,也称为执行环境

  • 通常需要准备的工作有:
  1. 变量对象的定义
  2. 作用域链的扩展
  3. 提供调用者的对象引用

执行上下文的类型

  1. 全局执行上下文
    • 一个程序只有一个全局执行上下文,因为在js脚本的生命周期中,它都是存在于执行堆栈的最底部,并不会被栈弹出销毁
    • 全局执行上下文会生成一个全局对象,在浏览器环境中,即使window对象,并且会将this值绑定到全局对象上
  2. 函数执行上下文:每当一个函数被调用时,都会创建一个新的函数执行上下文,不管这个函数是不是被重复调用
  3. Eval函数执行上下文:待补充。。。

ES3执行上下文内容

执行上下文是一个抽象的概念,我们可以理解为一个Object执行上下文的内容包括

  1. 变量对象 VO
  2. 活动对象 AO
  3. 作用域链 scope chain
  4. 调用者信息 this value

数据结构模拟

executionContext: {
    [variable object | activation object]: {
        arguments,
        variables: [...],
        functions: [...]
    },
    scope chain: variable object + all parents scopes
    thisValue: context object
}

变量对象(variable object 简称 VO)

  1. 每一个执行上下文都一个存储变量的对象———变量对象
  2. 全局执行上下文的变量对象就是全局对象,是始终存在的,在浏览器环境下,就是window对象
  3. 函数执行上下文的变量对象是函数内部定义的属性(包含参数、变量、函数/方法),只会在函数执行过程中存在
  4. VO是不能直接访问的,只有在函数进入执行阶段,VO被激活成AO,我们才能访问其中的属性

函数执行上下文的VO的构建

当函数被调用且具体函数代码执行之前,JS引擎会

  1. 用当前函数的参数列表arguments初始化一个变量对象,并将其与当前执行上下文关联
  2. 函数代码块中声明的变量函数也会被当作属性添加到变量对象

注意点:函数声明会被添加到变量对象上,函数表达式不会

活动对象(activation object 简称 AO)

作用:当函数进入执行阶段,VO被激活成AO,我们就可以访问其中的属性

其实AOVO本质上是同一个东西,只是所处的阶段不同

作用域链(scope chain

作用域:规定如何查找变量,也就是规定当前执行代码对变量的访问权限

JavaScript采用的是词法作用域,也就是静态作用域

静态作用域与动态作用域

  1. 因为JavaScript采用的是词法作用域,也就是静态作用域,所以函数的作用域在函数定义的时候就确定了
  2. 与词法作用域相对的动态作用域,函数的作用域是在函数调用的时候确定的
var value = 1;

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

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

bar();
// 输出:1

分析:执行foo(),先从自身的VO查找变量value;找不到,再到父级的VO查找;

  1. 如果函数的作用域是在函数定义的时候确定的,那么foo()的VO的父级就是window对象
  2. 如果函数的作用域是在函数调用的时候确定的,那么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);
  1. 执行流开始 初始化function test,test函数会维护一个私有属性 [[scope]],并使用当前环境的作用域链初始化,在这里就是 test.[[Scope]]=global scope.
  2. test函数执行,这时候会为test函数创建一个执行环境,然后通过复制函数的[[Scope]]属性构建起test函数的作用域链。此时 test.scopeChain = [test.[[Scope]]]
  3. test函数的活动对象被初始化,随后活动对象被当做变量对象用于初始化。即 test.variableObject = test.activationObject.contact[num,a] = [arguments].contact[num,a]
  4. test函数的变量对象被压入其作用域链,此时 test.scopeChain = [ test.variableObject, test.[[scope]]];

调用者信息 this value

如果当前函数被作为对象方法调用或者使用apply bind call等API委托调用,则将当前函数的调用者信息this value存入当前执行上下文,否则默认为全局对象调用

ES3执行上下文的生命周期

  1. 创建阶段
  2. 执行阶段
  3. 销毁阶段 下面分析的过程主要针对函数执行上下文

创建阶段

  1. 利用函数arguments参数列表初始化一个VO,并将其与当前函数的执行上下文关联,再将函数内部声明的变量函数作为属性添加到VO。在这个阶段,会对变量和函数进行初始化:变量全部定义为undefined,而函数会直接定义
  2. 构建作用域链
  3. 确定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 = {...}
}

词法环境

  1. 词法环境是一种规范类型
  2. 定义标识符与具体的变量及函数的关联,也就是一种标识符——变量映射结构
  3. 环境记录器与**外部词法环境引用(默认为 null)**组成

变量环境

ES5之前新增变量环境,是为了给ES6服务的

  1. 变量环境也是一个词法环境,有着词法环境的所有特性
  2. 词法环境存储函数声明和变量绑定let 和 const
  3. 变量环境存储变量绑定var

ES5 执行上下文总结

全局执行上下文的构建

程序启动,创建全局执行上下文

  1. 创建全局上下文的词法环境

    1. 创建对象环境记录器,处理函数声明let / const定义的变量
    2. 创建外部词法环境引用,值为 null
  2. 创建全局上下文的变量环境

    1. 创建对象环境记录器,处理var声明的变量
    2. 创建外部词法环境引用,值为null
  3. 确定this为全局对象,浏览器环境下,就是window对象

函数执行上下文的构建

函数被调用,创建函数执行上下文

  1. 创建函数上下文的词法环境

    1. 创建声明式环境记录器,存储参数列表argumentslet / const定义的变量、函数
    2. 创建外部词法环境引用,值为全局对象、或者父级词法环境(作用域)
  2. 创建函数上下文的变量环境

    1. 创建声明式环境记录器,存储参数列表argumentsvar定义的变量、函数
    2. 创建外部词法环境引用,值为全局对象、或者父级词法环境(作用域)

执行上下文栈

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 函数运行,内部变量申明提升,当执行代码块中有访问变量时,先查找本地作用域,找到了 fooundefined ,打印出来。然后 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 指向全局变量。

参考文章

面试官:说说执行上下文把

VO、AO、执行环境和作用域链