JavaScript函数执行过程

3,062 阅读12分钟

当 JS 引擎处理一段脚本内容的时候,它是以怎样的顺序解析和执行的?脚本中的那些变量是何时被定义的?它们之间错综复杂的访问关系又是怎样创建和链接的?

执行上下文

JavaScript标准把一段代码(包括函数),执行所需的所有信息定义为:“执行上下文”(execution context,简称 EC,也可以叫做执行环境)。

因为这部分术语经历了比较多的版本和社区的演绎,所以定义比较混乱,具有不同的版本定义。

在ES3中的执行上下文

JavaScript中有三中执行上下文类型:

  • 全局执行上下文
    • 最基础的执行上下文,一个程序中仅一个全局执行上下文,且在整个JavaScript脚本的生命周期内都会存在于执行堆栈的最底部不会被栈弹出销毁。
  • 函数执行上下文
    • 每当一个函数被调用时,都会创建一个新的函数执行上下文(不管这个函数是不是被重复调用的)。
  • Eval 函数执行上下文
    • 执行在 eval 函数内部的代码也会有它属于自己的执行上下文。

变量对象(variable object)

变量对象(variable object,简称 VO),是每一个执行上下文都有的,一个表示变量的对象。

  • 全局执行上下文中,会生成一个全局对象(以浏览器环境为例,这个全局对象是window),并且将this值绑定到这个全局对象上。
  • 函数执行上下文中,在函数被调用时且在具体的函数代码运行之前,JS 引擎会用当前函数的参数列表(arguments)初始化一个 “变量对象” 并将当前执行上下文与之关联,函数代码块中声明的变量函数将作为属性添加到这个变量对象上。

这里有几个点需要注意:

  1. 只有函数声明(function declaration)会被加入到变量对象中,而函数表达式(function expression)会被忽略。
// 函数声明
function a () {}
console.log(a); // ƒ a() {}

// 函数表达式
var b = function _b () {}
console.log(b); // ƒ b() {}
console.log(_b); // Uncaught ReferenceError: _b is not defined
  1. 变量对象内部定义的属性,是不能被直接访问的。当函数运行时,变量对象被激活为活动对象(activation object, 简称AO)时,我们才能访问到其中的属性和方法。

其实变量对象和活动对象是一个东西,只不过处于不同的状态和阶段而已。

作用域(scope)

作用域,也常常叫做作用域链,其规定了如何查找变量,也就是确定当前执行代码对变量的访问权限。

当查询变量时,会先在当前执行上下文的变量对象中查找。若没有,则在父级执行上下文的变量对象中查找,直至全局对象中查找。这样由多个执行上下文的变量对象构成的链表就叫做作用域链。

由于作用域由变量对象构成,所以函数的作用域在函数创建时就已经确定了。当函数创建时,会有一个名为[[scope]]的内部属性保存所有父变量对象到其中。当函数执行时,通过复制函数的[[scope]]属性中的对象构建起执行环境的作用域链,然后,VO 被激活生成AO并添加到作用域链的前端,完整作用域链创建完成:

scope = [AO].concat([[scope]]);

this值(this value)

this值默认为全局对象。当函数作为对象属性进行调用,或者使用applycallbind方法调用,或者使用箭头函数时,this取值不是全局对象,具体逻辑我们后面进行描述。

ES3中执行上下文的生命周期

  1. 创建阶段,发生在函数调用且具体执行函数之前。
    • 用当前函数的参数列表(arguments)初始化一个“变量对象”并将当前执行上下文与之关联,并且,函数代码块中声明的变量函数将作为属性添加到这个变量对象上(即变量声明提升)。
    • 构建作用域链
    • 设置this
  2. 执行阶段
    • 代码逐行开始执行,有对定义的变量赋值、顺着作业域链访问变量如果内部有函数调用就创建一个新的执行上下文压入执行栈并把控制权交出等操作。
  3. 销毁阶段
    • 当函数执行完成后,当前执行上下文会被弹出执行上下文栈并且销毁,控制权被重新交给执行栈上一层的执行上下文。

在ES5中的执行上下文

ES5ES3中的部分概念进行了修改,去掉了变量对象、活动对象和作用域,变为了词法环境(lexical environment)和变量环境(variable environment)

语法环境

ES6官方中的词法环境定义:

词法环境是一种规范类型,基于ECMAScript代码的词法嵌套结构来定义标识符和具体变量和函数的关联。一个词法环境由环境记录器和一个可能的引用外部词法环境的空值组成。一般来说,词法环境都与特定的ECMAScript代码语法结构相关联,例如函数、代码块、TryCatch中的Catch从句,并且每次执行这类代码时都会创建新的词法环境。

简而言之,词法环境是一种持有标识符—变量映的结构。这里的标识符指的是变量/函数的名字,而变量是对实际对象(包含函数类型对象)或原始数据的引用。

语法环境分为两部分,具体说明可以看看这篇文章

  • 环境记录(Environment Record):记录相应代码块的标识符绑定,可以理解为ES3中的变量对象和活动对象
  • 对外部词法环境的引用(outer):用于形成多个词法环境在逻辑上的嵌套结构,以实现可以访问外部词法环境变量的能力。可以理解为ES3中的作用域链

变量环境

变量环境也是一个词法环境,所以它有着词法环境的所有特性。

创建一个变量环境,是为了ES6中的块级作用域(通过新增的letconst等命令来实现)。

  • 变量环境,用于记录var声明的绑定。
  • 词法环境,用于记录其他声明的绑定(如letconstclass等)。

ES5中执行上下文的生命周期

具体周期同ES3,只不过在创建阶段有些不同:

  1. 创建执行上下文的词法环境
    • 创建声明式环境记录器,存储变量、函数和参数(负责处理letconst定义的变量)
    • 创建外部环境引用,值为全局对象或者父级词法环境(同作用域)
  2. 创建执行上下文的变量环境
    • 创建声明式环境记录器,存储变量、函数和参数(负责处理var定义的变量,初始值为 undefined 造成声明提升)
    • 创建外部环境引用,值为全局对象或者父级词法环境(同作用域)
  3. 确定this

在ES9中的执行上下文

  • lexical environment:词法环境,当获取变量或者this值时使用
  • variable environment:变量环境,当声明变量时使用。
  • code evaluation state:用于恢复代码执行位置。
  • Function:执行的任务是函数时使用,表示正在被执行的函数。
  • ScriptOrModule:执行的任务是脚本或者模块时使用,表示正在被执行的代码。
  • Realm:使用的基础库和内置对象实例。
  • Generator:仅生成器上下文有这个属性,表示当前生成器。

注:具体细节后期会总结下,现在先做个记录~

闭包

闭包的定义:有权访问另一个函数内部变量的函数。简单说来,如果一个函数被作为另一个函数的返回值,并在外部被引用,那么这个函数就被称为闭包。

闭包这个概念第一次出现在1964年的《The Computer Journal》上,由P. J. Landin在《The mechanical evaluation of expressions》一文中提出了applicative expression和closure的概念。

这个古典的闭包定义中,闭包包含两个部分。
1. 环境部分
    1.1 环境
    1.2 标识符列表
2. 表达式部分

我们根据这个古典定义,找到JavaScript中闭包的组成部分。

  1. 环境部分
      1.1 环境:函数的词法环境(执行上下文的一部分)
       1.2 标识符列表:函数中用到的未声明的变量
    2. 表达式部分:函数体

这里我们可以了解到,JavaScript中跟闭包对应的概念就是“函数”。

内存泄漏

当闭包包含的父级函数执行完毕后,其对应的作用域链会销毁,但是由于闭包的存在,父级函数中变量对象会一直存在内存中。只有当闭包销毁,才会从内存中释放掉。所以,过度使用闭包,会存在内存泄漏的风险。

this值

this值,设计目的就是在函数体内部,指代函数当前的运行环境

在函数中this到底取何值,是在函数真正被调用执行的时候确定的,函数定义的时候是无法确定this所指向的值,因为this的取值是执行上下文环境的一部分,每次调用函数,都会产生一个新的执行上下文的环境。

全局环境

在全局环境中,this值表示全局对象。

this === window // true

function f() {
  console.log(this === window);
}
f() // true

构造函数

构造函数中的this,指的是实例对象。

var Obj = function (p) {
  this.p = p;
};

var o = new Obj('Hello World!');
o.p // "Hello World!"

由于this指向实例对象,所以在构造函数内部定义this.p,就相当于定义实例对象有一个p属性。

对象的方法

如果对象的方法里面包含thisthis的指向就是方法运行时所在的对象。该方法赋值给另一个对象,就会改变this的指向。

var obj ={
  foo: function () {
    console.log(this);
  }
};

obj.foo() // obj
(false || obj.foo)() // window

如果this所在的方法不在对象的第一层,这时this只是指向当前一层的对象,而不会继承更上面的层。

var a = {
  p: 'Hello',
  b: {
    m: function() {
      console.log(this.p);
    }
  }
};

a.b.m() // undefined

绑定 this 的方法

call和apply

函数实例的call/apply方法,可以指定函数内部this的指向(即函数执行时所在的作用域),然后在所指定的作用域中,调用该函数。

var obj = {};

var f = function () {
  return this;
};

f() === window // true
f.call(obj) === obj // true
f.apply(obj) === obj // true

bind

bind()方法用于将函数体内的this绑定到某个对象,然后返回一个新函数。

var obj = {};

var f = function () {
  return this;
};

f() === window // true
f.bind(obj)() === obj // true

箭头函数

箭头函数中没有this绑定,this由外围最近一层的非箭头函数决定(非箭头函数的this值按照上面的逻辑来获取),即需通过查询作用域链来决定其值

var f = () => {
    console.log(this === window);
}
f(); // true

var obj = {
    name: 'Hello World!',
    f: () => {
        console.log(this === window);
    },
    s() {
      setTimeout(() => {
        console.log(this.name);
      }, 100);  
    }
}

var newObj = {
    name: 'Hello!',
}

obj.f(); // true
obj.s(); // Hello World!
obj.s.call(newObj); // Hello!

执行上下文栈

我们可以知道,当程序运行时,可能产生多个执行上下文。为了对这些执行上下文进行管理,JavaScript引擎创建了一个后进先出的栈式结构(LIFO)--执行上下文栈(Execution context stack 简称 ECS)。

我们通过这个动图来了解下执行上下文栈的运行逻辑:

  1. 开始执行任何JavaScript代码前,会创建全局上下文并压入栈,所以全局上下文一直在栈底。
  2. 每次调用函数都会创建新的执行上下文(即便在函数内部调用自身),并压入栈。
  3. 函数执行完毕返回,其执行上下文出栈。
  4. 所有代码运行完毕,执行上下文栈只剩全局执行上下文。

栈溢出和尾调用优化

从上面我们了解到,函数的运行离不开执行上下文栈。但是,执行栈本身也是有容量限制的。当执行栈内部的执行上下文对象积压到一定程度如果继续积压,就会报栈溢出(stack overflow)的错误。

这种情况多发生在递归调用中:

// 求 1~num 的累加,此时 num 由外部传入,是未知的
function recursion (num) {
    if (num === 0) return num;
    return recursion(num - 1) + num;
}

recursion(100) // => 5050
recursion(1000) // => 500500
recursion(10000) // => 50005000
recursion(100000) // => Uncaught RangeError: Maximum call stack size exceeded

针对这个问题,我们可以使用尾调用优化的方式来解决。

尾调用指的是函数作为另一个函数的最后一条语句被调用。在上面的递归中,每个函数都的运行都保存在了执行上下文栈中,但是若尾调用满足下面几个点,则不在上下文栈中新增数据,而是进行优化,清除并重用当前的栈:

  1. 尾调用不访问当前栈的变量(即尾调用不是一个闭包)
  2. 在函数内部,尾调用是最后一条语句
  3. 尾调用的结果作为函数值返回
// 尾调用优化
function recursion (num, sum = 0) {
    "use strict";
    if (num === 0) return sum;
    return recursion(num - 1, sum + num);
}

尾递归优化这种东西,现在没有任何一个浏览器是支持的(据说 Safari 13 是支持的),babel 编译也不支持。

注:现在浏览器中对尾调用优化的支持不是很好,我们这里就先了解下这个概念。

参考