02_函数执行过程和作用域链

函数的执行过程

首先看以下代码:

var name = "why";

foo();

function foo(num) {
  var m = 10;
  console.log(m);
}

var num1 = 20;
var num2 = 30;
var result = num1 + num2;

具体执行过程如下:

  1. 首先函数编译阶段会创造一个 GlobaleObject,这里面的有一些 Date、String 等对象,还有 window 对象,除此之外就是我们代码的这些变量:
// Global Object (GO)
{
  ...,
  name: undefined,
  num1: undefined,
  num2: undefined,
  result: undefined,
  foo:
}
  1. 当解析到函数类型的时候,这时候由于函数是一种特殊的对象类型,不是基本数据类型,所以引擎会单独开辟一块内存空间用以存放函数中的变量和父级作用域,于是上面的 GlobalObject 就会将 foo 的地方存放为一个地址;
// Global Object (GO)
{
  ...,
  name: undefined,
  num1: undefined,
  num2: undefined,
  result: undefined,
  foo: 0xa00,
}
  1. 编译阶段结束,到执行阶段,执行需要有一个 ECS(执行上下文栈),有了它之后,那么现在要执行全局的代码,所以需要一个全局执行上下文 GEC ,它里边包含了两部分内容,一个是变量对象 VO,VO 这里等同于 GO,还有一个是为执行过程而开辟的空间;

  2. 执行到函数的时候,函数的执行会开辟函数的 FEC(functional exection context 执行上下文), 它里边也是保存两部分内容,一个是活动对象 AO(Activation object),另一部分是执行过程开辟的空间,这个时候可以看到我们的 foo 中的 m 在编译阶段已经存在了,值为 undefined, 所以这里打印 undefined

  3. 当函数执行完之后,整个 FEC 函数执行上下文会出栈,如果后面有重新调用的话,会重新入栈,出栈之后机会被内存回收

函数执行过程

以上过程在 AST 阶段就会被确定下来,包括作用域

作用域链

当我们查找一个变量的时候,是根据作用域链来查找的

在我们的函数执行上下文和全局的执行上下文是保存了我们的作用域的,如下图

作用域

还是上面的例子,此时我在 foo 函数中再定义一个函数:

var name = "why";

foo(123);

function foo(num) {
  var m = 10;
  console.log(m);

  function bar() {
    console.log(name);
  }

  bar();
}

var num1 = 20;
var num2 = 30;
var result = num1 + num2;

执行过程:

  1. 首先编译阶段,创建出 GO,包含 name、num1、num2、result 值为 undefined 以及 foo,值为内存地址,注意这里 foo 函数内包含另一个函数,昨天我们知道这里只会进行预编译,预编译阶段会创建 AO 对象,并且找到相关的变量,只有到我们执行阶段,创建出 bar 的函数执行上下文的时候,才会进行真正的全量执行;
  2. 到执行阶段,会首先创建执行上下文栈,然后创建全局执行上下文压入栈中,全局执行上下文中解析到 foo 函数,于是创建 foo 的函数执行上下文,继续解析 foo 的函数执行上下文,发现里边还有一个 bar 函数,又创建出 bar 的函数执行上下文,继续执行;
  3. 执行完毕后,将 bar 的执行上下文出栈,再将 foo 的执行上下文出栈,最后将全局的执行上下文出栈,完成整体流程;

嵌套函数的执行流程

函数调用函数的执行过程

看一下这个例子:

var message = "Hello Global";

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

function bar() {
  var message = "Hello Bar";
  foo();
}

bar();

直接上图:

函数调用函数的执行流程

执行过程大致都差不多,需要注意的是,在我们的编译阶段作用域就已经确定了,这里在 bar 中执行 foo,foo 中打印 message, 此时 foo 在自己的 AO 中找 message 这个变量,会发现找不到,于是就去寻找自己的上一层作用域,foo 的上一层作用域在图中很明显是 GO,于是就去 GO 中寻找 message 变量,于是打印 Hello Global.

概括一下 ,遇到函数如何执行?

  • 在执行的过程中执行到一个函数时,就会根据函数体创建一个函数执行上下文(Functional Execution Context,简称 FEC),并且压入到 EC Stack 中
  • FEC 中包含三部分内容
    • 在解析函数成为 AST 树结构时,会创建一个 Activation Object(AO)
      • AO 中包含形参、arguments、函数定义和指向函数对象、定义的变量;
    • 作用域链:由 VO(在函数中就是 AO 对象)和父级 VO 组成,查找时会一层层查找
    • this 绑定的值

AO 对象在编译阶段就会存在了

FEC中包含的内容

变量环境和记录

其实我们上面的讲解都是基于早期 ECMA 的版本规范:

Every execution context has associated width it a variable object, Variables and functions declared in the source text are added as properties of the variable object. For function code, paramenters are added as properties of the variable object. 每一个执行上下文会被关联到一个环境变量(variable object,VO),在源代码中的变量和函数声明会被作为属性添加到 VO 中。对于函数来说,参数也会被添加到 VO 中

在最新的 ECMA 的版本规范中,对一些词汇进行了修改:

Every execution context has an associated VariableEnvironment. Variables and functions declared in ECMAScript code evaluated in an execution context are added as bindings in that VariableEnvironment's Environment Record. For function code, paramenters are also added as bindings to that Environment Record. 每一个执行上下文会关联到一个变量环境(VariableEnvironment)中,在执行代码中变量和函数的声明会作为环境记录(Environment Record)添加到变量环境中。对于函数来说,参数也会被作为环境记录添加到变量环境中。

通过上面的变化我们可以知道,在最新的 ECMA 标准中,前面的变量对象 VO 已经有了另外一个称呼了,也就是变量环境(VE)

作用域提升题目

1. 关于 return

var a = 100;

function foo() {
  console.log(a);
  return;
  var a = 100;
}

foo();

这道题注意一下虽然说 return 后面的语句不会执行,但是在编译阶段还是会将所有语句进行编译的,只是在执行阶段不会去执行,所以这个的 a 是一个 undefiend,并不会报错!

2. 关于隐式赋值

function foo() {
  var a = (b = 100);
}

foo();

console.log(a);
console.log(b);

这里我们需要注意的是 var a = b = 100这条语句,这里被格式化插件自动添加了括号,实际上这里会被拆分成两条语句:

b = 100;
var a = b;

针对第一条语句 b = 100 其实这种写法是有问题的,因为 b 此时都没有任何声明,如果是在其它语言中,这本身就是不会存在的一种写法,但是在 JavaScript 中,我们允许这么做,这么做的结果就是 js 内部会在全局声明这个 b 变量,所以 b 变量会被挂载到全局中,所以最后在全局中会存在 b 变量为 100 的值,而不存在 a

内存管理

认识内存管理

不管什么样的编程语言,在代码的执行过程中都是需要给它分配内存的,不同的某些编程语言需要我们自己手动的管理内存,某些编程语言会可以自动帮助我们管理内存。

不管以什么样的方式来管理内存,内存的管理都会有如下的生命周期:

  1. 分配申请你需要的内存(申请);
  2. 使用分配的内存(存放一些东西,比如对象等);
  3. 不需要使用时,对其释放;

不同的编程语言对于第一步和第三步会有不同的实现:

  • 手动管理内存:比如 C、C++,包括早期的 OC,都是需要手动来管理内存的申请和释放的(malloc 和 free 函数);
  • 自动管理内存:比如 Java、JavaScript、Python、Swift、Dart 等,它们会自动帮助我们管理内存

JavaScript 的内存管理

  • JavaScript 会在定义变量时为我们分配内存
  • 但是内存分配方式是一样的吗?
    • JS 对于基本数据类型内存的分配会在执行时,直接在栈空间进行分配
    • JS 对于复杂数据类型内存的分配会在堆内存中开辟一块空间,并且将这块空间的指针返回值变量引用;

JavaScript 的垃圾回收

因为内存的大小是有限的,所以当内存不再需要的时候,我们需要对其进行释放,以便腾出更多的内存空间

大部分现代的编程语言都是有自己的垃圾回收机制:

  • 垃圾回收的英文是 Garbage Collection,简称 GC;
  • 对于那些不再使用的对象,我们都称之为垃圾,它需要被回收,以便释放更多的空间
  • 垃圾回收器我们也会简称为 GC

那么 GC 怎么知道哪些对象是不再使用呢? —— GC 算法

常见的 GC 算法——引用计数

当一个对象有一个引用指向它时,那么这个对象的引用就+1,当一个对象的引用为 0 时,这个对象就可以被销毁掉;

这个算法有一个很大的弊端就是会产生循环引用;

循环引用

循环引用导致这两个对象一直都会存在其他对象的引用,导致永远不会被销毁。

常见的 GC 算法——标记清除

这个算法是设置一个根对象(root object),垃圾回收器会定期从这个根开始,找所有从根开始有引用到的对象,对于那些没有引用到的对象,就认为是不可用的对象;

这个算法可以很好的解决循环引用的问题:

标记清除

JS 引擎比较广泛的采用的就是标记清除算法,当然类似于 V8 引擎为了进行更好的优化,他在算法的实现细节上也会结合一些其它的算法。

Last Updated:
Contributors: hqchqc