懂王系列(一)之彻底搞懂JavaScript函数执行机制

1,019 阅读8分钟

作为一名前端小白,不知道大家是否遇到和我一样的问题。看了一道面试题的解析,当时觉得会了,可是过两天以后再看又不会了;盲目追求各种新技术,感觉什么都会点,但是一上手就不行了... 痛定思痛后,我终于认识到了问题所在,开始专注于基本功的修炼。近半年来通读了(其实是囫囵吞枣)《JavaScript高级程序设计》、《你不知道的JavaScript上、中、下》等书籍,本系列文章是我读书过程中对知识点的一些总结。喜欢的同学记得帮我点个赞😁。

懂王系列(一)之彻底搞懂JavaScript函数执行机制
懂王系列(二)之彻底搞懂JavaScript作用域
懂王系列(三)之彻底搞懂JavaScript对象
懂王系列(四)之彻底搞懂JavaScript类
懂王系列(五)之彻底搞懂JavaScript原型
懂王系列(六)之彻底搞懂JavaScript中的this
懂王系列(七)之彻底搞懂JavaScript数据类型
懂王系列(八)之彻底搞懂JavaScript语句
懂王系列(九)之彻底搞定JavaScript类型转换

当Javascript代码执⾏的时候会将不同的变量存于内存中的不同位置:堆(heap)栈(stack)中来加以区分。其中,堆⾥存放着⼀些对象。⽽栈中则存放着⼀些基础类型变量以及对象的指针。 但是我们这⾥说的执⾏栈和上⾯这个栈的意义却有些不同。

这里的栈除了执行代码外,基础类型的值也会存储在栈中,引用类型存储在堆中。

null的作用,null不会开辟内存,而是把之前的指针指向空指针。
null和0区别:0需要开启一个内存,null不需要
null和undefined的区别:
null想要赋值,但是还没有赋值,拿null占位
undefined:未赋值

js 在执⾏可执⾏的脚本时,⾸先会创建⼀个全局可执⾏上下⽂(globalContext),每当执⾏到⼀个函数调⽤时都会创建⼀个可执⾏上下⽂(execution context) EC。当然可执⾏程序可能会存在很多函数调⽤,那么就会创建很多EC,所以 JavaScript 引擎创建了执⾏上下⽂栈(Execution contextstack,ECS)来管理执⾏上下⽂。

当函数调⽤完成,js会退出这个执⾏环境并把这个执⾏环境销毁,回到上⼀个⽅法的执⾏环境...这个过程反复进⾏,直到执⾏栈中的代码全部执⾏完毕,如下是以上的⼏个关键词,我们来⼀次分析⼀下:

执⾏栈(Execution Context Stack)
全局对象(GlobalContext)
活动对象(Activation Object)
变量对象(Variable Object)

函数执行的阶段可以分文两个:函数建立阶段、函数执行阶段

1. 函数创建阶段

当调用函数时,还没有执行函数内部的代码,会创建一个创建执行上下文对象

fn.EC = { 
    variableObject: // 函数中的 arguments、参数、局部成员 
    scopeChains: // 当前函数所在的父级作用域中的活动对象 
    this: {} // 当前函数内部的 this 指向 
}

创建阶段做了三件事:

1.创建一个堆(存储代码字符串和对应的键值对)
2. 初始化当前函数的作用域(没错,创建时就定义好了)
3. [[scope]] = 所在上下文EC中的变量对象VO/AO

这里的变量对象VO是与执⾏上下⽂相关的特殊对象,⽤来存储上下⽂的函数声明,函数形参和变量。 VO分为全局上下⽂的变量对象VO,函数上下⽂的变量对象VO

VO(globalContext) === global;

我们以下面这个函数为例,来分析一下:

var a = 10;
function test(x) {
    var b = 20;
};
test(30);

// 全局上下⽂的变量对象
VO(globalContext) = {
    a: 10,
    test: <reference to function>
};

// test函数上下⽂的变量对象
VO(test functionContext) = {
    x: 30, 
    b: 20
};

2. 函数执行阶段

fn.EC = { 
    activationObject: // 函数中的 arguments、参数、局部成员
    scopeChains: // 当前函数所在的父级作用域中的活动对象 
    this: {} // 当前函数内部的 this 指向 
}

函数执行阶段将变量对象VO变为了活动对象AO,具体做了以下几件事:

  1. 创建一个新的执行,上下文EC (压缩到栈ECStack里执行)
  2. 初始化THIS的指向
  3. 初始化作用域链[[scopeChain]] : xxx
  4. 创建AO变量对象用来存储变量

这里我们先来简单看下存储变量时的步骤:

  1. 找形参和变量声明(变量提升),将变量和形参名作为AO属性名,值为undefined
  2. 将实参值和形参统一
  3. 在函数体里面找函数声明,值赋予函数体

举个例子:

function fn(a) {
    console.log(a);
    var a = 123;
    console.log(a);

    function a() {}
    console.log(a);
    var b = function() {}
    console.log(b);

    function d() {}
}
fn(1);

储存变量过程:

// 1
AO: {
    a: undefined,
    b: undefined
}
// 2
AO: {
    a: 1,
    b: undefined
}
// 3
AO: {
    a: function a() {},
    b: undefined
    d: function d() {}
}

注意:

  1. 函数提升优先级比变量高
  2. 当函数声明的函数名被重新var,但是还未赋值时,该变量还是指向该函数

下面我们来正式看一下AO

// 1.在函数执⾏上下⽂中,VO是不能直接访问的,此时由活动对象扮演VO的⻆⾊。
// 2.Arguments对象它包括如下属性:callee 、length
// 3.内部定义的函数
// 4.以及绑定上对应的变量环境;
// 5.内部定义的变量
VO(functionContext) === AO;
function test(a, b) {
    var c = 10;
    function d() {}
    var e = function _e() {};
    (function x() {});
}
test(10); // call

// 当进⼊带有参数10的test函数上下⽂时,AO表现为如下:
// AO⾥并不包含函数“x”。这是因为“x” 是⼀个函数表达式(FunctionExpression, 缩写为FE) ⽽不是函数声明,函数表达式不会影响VO
AO(test) = {
    a: 10, 
    b: undefined,
    c: undefined, 
    d: <reference to FunctionDeclaration "d"> 
    e: undefined
};

3. 完整过程分析

我们以下面代码为例,来完整分析一下函数执行过程:

let x = 1;

function A(y) {
    let x = 2;

    function B(z) {
        console.log(x + y + z);
    }
    return B;
}
let C = A(2);
C(3);

函数执行过程模拟:

/*第一步:创建全局执行上下文,并将其压入ECStack中*/
ECStack = [    //=>全局执行上下文    EC(G) = {        //=>全局变量对象        VO(G): {            ... //=>包含全局对象原有的属性            x = 1;            A = function (y) {...};            A[[scope]] = VO(G); // =>创建函数的时候就确定了其作用域
        }
    }
];

/*第二步:执行函数A(2)*/
ECStack = [    //=>A的执行上下文    EC(A) = {        //=>链表初始化为:AO(A)->VO(G)        [scope]: VO(G)
        scopeChain: <AO(A), A[[scope]]>
        // =>创建函数A的活动对象
        AO(A): {
            arguments: [0: 2],
            y: 2,
            x: 2,
            B: function (z) {...},
            B[[scope]] = AO(A);
            this: window;
        }
    },
    //=>全局执行上下文
    EC(G) = {
        //=>全局变量对象
        VO(G): {
            ... //=>包含全局对象原有的属性
            x = 1;
            A = function (y) {...};
            A[[scope]] = VO(G); //=>创建函数的时候就确定了其作用域
        }
    }
];

/*第三步:执行B/C函数 C(3)*/
ECStack = [    //=>B的执行上下文    EC(B) {        [scope]: AO(A)
        scopeChain: < AO(B), AO(A), B[[scope]]>
        //=>创建函数B的活动对象
        AO(B): {
            arguments: [0: 3],
            z: 3,
            this: window;
        }
    },
    //=>A的执行上下文
    EC(A) = {
        //=>链表初始化为:AO(A)->VO(G)
        [scope]: VO(G)
        scopeChain: < AO(A), A[[scope]] >
        //=>创建函数A的活动对象
        AO(A): {
            arguments: [0: 2],
            y: 2,
            x: 2,
            B: function (z) {...},
            B[[scope]] = AO(A);
            this: window;
        }
    },
    //=>全局执行上下文
    EC(G) = {
        //=>全局变量对象
        VO(G): {
            ... //=>包含全局对象原有的属性
            x = 1;
            A = function (y) {...};
            A[[scope]] = VO(G); //=>创建函数的时候就确定了其作用域
        }
    }
]

4. 作用域链

每个javascript函数都是一个对象,对象中有些属性我们可以访问,但有些不可以,这些属性仅供javascript引擎存取,[[scope]]就是其中一个。[[scope]]指的就是我们所说的作用域,其中存储了运行期上下文的集合。

作用域链: [[scope]]中所存储的执行期上下文对象的集合,这个集合呈链式链接,我们把这种链式链接叫做作用域链。

函数执行时,会在作用域顶层(0)创建自己的AO,所以查找变量时,也是从作用域链的顶端依次向下查找

我们以下代码为例,来看一下函数的作用域链

function a() {
    function b() {
        var b = 234;
    }
    var a=123;
    b();
}

var glob = 100 ;
a();

a函数定义时

a函数执行时

b函数创建时

b函数执行时

我们来修改一下原来的代码,

function a() {
    function b() {
        var b = 234;
    }
    var a=123;
    return b
}

var glob = 100 ;
let b = a();
b()

我们将b函数返回出去,此时我们发现,当a函数执行完时,a的AO就已经断开了,但是b依然保留着对它的引用。所以a的AO没有被销毁掉。这就是闭包,符合以下两个条件就是闭包:

  1. fn 外部对内部有引用
  2. 在另一个作用域访问到 fn 作用域中的局部成员

5. this

this对象是在运行时基于函数的执行环境绑定的,在全局函数中this等于window,而当函数被作为某个对象的方法调用时,this等于那个对象。

每个函数在被调用时都会自动取得两个特殊变量:this 和 arguments。内部函 数在搜索这两个变量时,只会搜索到其活动对象为止,因此永远不可能直接访问外部函数中的这两个变量。

6. 描述一下EventLoop的执行过程

  • 一开始整个脚本作为一个宏任务执行
  • 执行过程中同步代码直接执行,宏任务进入宏任务队列,微任务进入微任务队列
  • 当前宏任务执行完出队,检查微任务列表,有则依次执行,直到全部执行完
  • 执行浏览器UI线程的渲染工作
  • 检查是否有Web Worker任务,有则执行
  • 执行完本轮的宏任务,回到2,依此循环,直到宏任务和微任务队列都为空