从一个小问题开始
昨天,我带的一个工作室的实习成员问了这样一个问题:
这个问题我们可以简化一下,其实就是:function A() {console.log(username);
}
function B() {console.log('Uni');
}
B();复制代码
这位同学想问的是,在这种情况下会不会因为没有声明username而产生报错。
当然这个问题玩过前端的一眼就能看出来,正常输出Uni且不会报错。可是原理是什么呢?这中间到底发生了什么?为什么它不会有任何问题?
这篇文章将会从JS执行机制出发,更深层地探讨JS的基础问题。
声明和提升
首先,由浅入深,我们来回顾一下学习JS变量是遇到的知识,做一个小热身。我们先不讨论ES6的语法特性,这个部分我想留到这个系列的下篇文章进行探讨。
首先我们来看一段代码:console.log(username); // undefined
var username = "Uni";复制代码
这段代码很简单,也就是我们常常说的变量提升。在JS的执行机制里面(这个机制是什么我等等讲)这段代码是这样的:var username = undefined;
console.log(username); // undefined
username = "Uni";复制代码
我们再来看一个例子:test(); // Uni
function test() {console.log("Uni");
}复制代码
这里发生一个函数提升,在JS执行机制的眼里是这样的:function test() {console.log("Uni");
}
test(); // Uni复制代码
到这里都很简单对吧,我们再给点耐心看看另一个小例子:test(); var test = function() { console.log("Uni");
}复制代码
这段代码会产生报错:
报错的类型是TypeError。
通过上述例子,其实我更喜欢把提升叫做声明提升,不论是变量提升还是函数提升,它都只有是一个声明的时候才会发生提升。
接下来我们从JS代码执行的过程来看看提升。
JS代码的执行流程因为是对某一个点的知识进行讨论,与本篇文章讨论的知识无关的细节我会进行掩盖处理。
编译
初学JS的时候,我们大都可能听到:“JavaScript是一门解释型语言。”这样的话。但是JS事实上是一门编译语言,它是存在编译这个阶段的,只不过它的编译与传统的编译语言(譬如C语言)那样有些许不同。
在这里我不先从词法、LHS、RHS这些进行讨论,这些我会留在后面文章讨论作用域的时候拿出来仔细讨论。
我们首先要知道,JS执行流程的第一步是编译。通过编译,会生成两个部分:执行上下文和可执行代码。
执行上下文就是执行一段代码是的运行环境,执行上下文中包括:变量环境和词法环境。
在编译时,JS引擎在变量环境中生成一个对象结构,我们称为变量对象。扫描代码,如果发现var声明的变量,会将其作为变量对象中的一个属性,并且赋予undefined。如果发现一个函数声明(通过function定义)的话,会将其定义存放在堆内存中,同时会在变量对象中创建一个属性,并且该属性的值是指向这个堆内存。
比如下面这段代码:var name = 'Uni';
console.log(name);
function Fun() { var age = 20; console.log(age);
}
Fun();复制代码
这一阶段的编译会生成类似于这样的变量对象VO = { name: undefined, Fun: function() {var age = 20;console.log(age);
}
}复制代码
而JS引擎会将除了声明以外的代码转化为字节码,也就是我们的可执行代码,一下是抽象写法,并不是实际上的:name = 'Uni'; console.log(name);
Fun();复制代码
执行
细心的同学应该会发现,我刚刚一直在有一重复一个词:当前阶段。这个词不是那么准确,但是能突出我想表达的。JS并不会只编译一次。不会在第一次编译阶段就讲所有的声明提出来。我们从上面的环境变量的例子也能看出来:VO = { name: undefined, Fun: function() {var age = 20;console.log(age);
}
}复制代码
我们Fun属性对应的值是存放于堆内存中的,我们可以看到函数内部的代码并没有做处理。
当我们可执行代码按顺序执行到我们的Fun()时,也就是调用Fun函数,JS引擎会找出这段函数代码,进行对这段函数的编译,从而生成这段函数的执行上下文和可执行代码。
调用栈
看到这里,我们可以知道,在一个JS代码开始运行的时候,进行第一次编译,会生成一个执行上下文,这个执行上下文我们叫做:全局执行上下文
而当调用一个函数的时候,函数体内的代码才会被编译,从而创建函数执行上下文。当函数中的可执行代码执行结束后,函数便会被销毁。
这是我们很自然的会想到,如果函数体里还有函数调用呢?那必然是会去找被调用的那个函数体然后进行代码编译(如果没有什么违规写法的话)。所以我们当前函数只有等自己函数体中的函数调用执行完了,它才能被销毁。我们来看段代码清晰一下思路:function A() {console.log('begin');
B();console.log('end');
}function B() {console.log('run');
}
A();复制代码
我们按照我们刚刚的思路来分析一下:
1、首先会生成一个全局的执行上下文,执行上下文中的变量对象是:VO:
A : {console.log('begin');
B();console.log('end');
}
B : {console.log('run');
}复制代码
生成可执行代码并执行。
2、调用我们的函数A,A的函数体中的代码进行编译,生成一个函数执行上下文:VO(A): {
}复制代码
生成函数A的可执行代码,并进行执行。
3、执行到B()时,调用B,对B的函数体进行编译,生成其函数执行上下文,生成其可执行代码并执行。
4、函数B执行结束,函数B销毁,继续执行函数A的可执行代码。
5、函数A执行结束,函数A销毁,回到全局的可执行代码中,发现无可执行代码,销毁全局(这里先忽略事件队列的相关内容)
我们简单模拟了一遍JS引擎的运行机制,那么JS引擎具体是如何管理这些执行上下文的呢?
JS引擎采用了一种名叫栈的数据结构对执行上下文进行管理,而这个结构我们称作调用栈。
学过数据结构的同学都知道,栈是一个先进后出的数据结构,它就像一个羽毛球筒,先放进去的我们会压在最底下,拿出来的话是最后才拿出来。
由于缺乏画图工具,我在网上找了一个很形象的图:
所以我们再来看看刚刚那段代码运行时,调用堆栈是怎么样的:
1、全局上下文入栈,被压到栈底
2、调用函数A,函数A的执行上下文被压入栈
3、调用函数B,函数B的执行上下文被压入栈
4、函数B执行完毕,函数B的执行上下文出栈。
5、函数A执行完毕,函数A的执行上下文出栈。
6、全局执行完毕,全局执行上下文出栈。
回到一开始
这个时候我们再来会看一开始那位实习的同学提出的问题,是不是更清晰为什么了?function A() {console.log(username);
}
function B() {console.log('Uni');
}
B();复制代码
我们函数A的函数体始终没有被编译,所以没有生成可执行代码,自然是不会被执行,所以也不会产生报错。
One more ting
其实这里有个小问题我留下了没有写,关于函数什么和函数表达式的,我打算再下篇深入讲解作用域时通过LHS和RHS来进行细讲。
此文章已经同步更新到我的个人微信公众号:想养猫的前端觉得不错的话,关注一下啦!