前言
JavaScript 以其灵活的语法和强大的功能而著称,但也因其动态特性 和一些看似奇怪的行为而让许多开发者感到困惑。本文将深入探讨JavaScript的底层运行机制,帮助读者更好地理解这门语言。
JS它的动态特性
JavaScript是一门弱类型(动态类型) 语言,这意味着变量的类型是在运行时确定的,它与C++和Java 等静态 类型语言不同,JavaScript没有独立 的编译阶段。编译和执行是紧密 结合的,和pyhton 一样都是脚本语言通常在代码执行之前完成。而不是在编译时确定的。这种特性使得JavaScript非常灵活,但也可能导致一些常见的错误和陷阱。
javascript
var name; // 声明变量,初始值为undefined
console.log(name); // 输出: undefined
name = "wql"; // 赋值
console.log(name); // 输出: wql
在这个例子中,name
变量在声明时被初始化为undefined
(赋予undefined是因为要分配内存 给这个变量)。这是因为在JavaScript中,使用var
关键字声明的变量会被提升到当前作用域的顶部,但只有声明部分会被提升,赋值部分不会。
我们再看一段代码,你来说运行结果
javascript
console.log(sayHello);
function sayHello() {
return "Hello, World!";
}
这里是报错,还是输出undefined呢?相信大家心中已经有了答案,我们来看运行结果
是不是很不可思议,函数声明不仅会被提升,而且函数体也会被提升。
变量提升实际上是在预编译阶段发生的。JavaScript引擎在预编译阶段会扫描整个代码块,识别所有的变量声明和函数声明,并将它们提升到当前作用域的顶部。我们在前文也提到了JS它是在执行之前会进行短暂的编译也称为即时编译。接下来,我们将详细探讨函数的预编译和全局的预编译过程,以便更好地理解JavaScript的执行机制。
函数的预编译和全局的预编译
在 JavaScript 中,函数预编译也称为函数执行上下文的创建阶段,全局预编译为创全局执行上下文的创建阶段。对全局代码 进行的预编译。它主要涉及全局变量 和全局函数 的处理,为整个 JavaScript 程序的执行创建初始环境 。当浏览器开始执行一个函数之前,会先进行预编译。它主要是为函数的执行做准备,确定变量和函数的初始状态等。
函数的预编译
下面是函数预编译的执行步骤,大家反复读几遍之后,开始我们的代码之旅!
- 创建函数的执行上下文对象 AO Activation Object
- 找行参和变量的声明,将形参和变量名作为AO的属性,值为undefined
- 将形参和实参统一
- 在函数体内找函数声明,将函数名作为AO属性的属性名,值赋予函数体
javascript
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() {}
var d = a;
console.log(d);
}
fn(1)
大家来分析一下他的执行结果,如果咱们不按上述的分析步骤走的话,是不是感觉大脑乱成一锅粥了,很难分析清楚!接下来我们来一步一步分析
首先创建一个 函数执行上下文对象 AO,然后将形参和变量作为属性,值为undefined赋予它
javascript
AO{
a:undefined
b:undefined
d:undefined
}
然后是把形参和实参统一,这里的1会覆盖a之前的undefined。
javascript
AO{
a:undefined 1
b:undefined
d:undefined
}
然后是最后一步,在函数体内找函数声明,将函数名作为AO属性的属性名,值赋予函数体
javascript
AO{
a:undefined 1 function a(){}
b:undefined
d:undefined function d(){}
}
如果把编译器理解为V8引擎(js的引擎)的的秘书的话,v8引擎在执行代码之前,会让秘书去整理一下代码是什么规则,整理出一个AO对象,至此编译结束啦,v8引擎开始代码的执行!
现在我们开始执行上述代码,首先是输出 a 因为在AO对象里,他现在是function a(){}
,所以输出function a(){}
,然后是 var a = 123;
所以 现在 a
更新为了 123
,再输出a
,输出 123
,再出现输出a
,还是输出123
, 后面有给b的赋值操作,现在b更新为 functuon b(){}
,所以log(b)
,就是输出 functuon b(){}
, 然后再是 给 d 赋值为 a 的值 ,呢就是 d 更新为 123 ,所以log(d )
,是输出123
javascript
function fn(a) {
console.log(a); //function a(){}
var a = 123;
console.log(a); //123
function a() {}
console.log(a); //123
var b = function () {};
console.log(b); //function b(){}
function d() {}
var d = a; //123
console.log(d); //123
}
这样走一遍之后,结果是不是就很清晰明了了!面试再也不怕啦! 理解了之后大家还要多加练习,可以自己去写类似的代码,然后进行练习!
全局的预编译
- 创建全局执行上下文对象 GO
- 找变量声明,变量名作为GO的属性名,值为undefined
- 在全局找函数声明,函数名作为GO的属性名,值为函数体
讲到了全局预编译的话就更有意思啦,我们再来进行代码训练,在上文代码的基础上,做了轻微改动
javascript
// 新增一个全局变量
var globalVariable = "I'm global";
function fn(a) {
console.log(a); //function a(){}
var a = 123;
console.log(a); //123
function a() {}
console.log(a); //123
var b = function () {};
console.log(b); //function b(){}
function d() {}
var d = a; //123
console.log(d); //123
}
// 再新增一个全局函数,该函数内部会调用fn函数
function globalFn() {
var localVariable = "I'm local in globalFn";
fn(5);
console.log(localVariable);
}
globalFn()
开始分析全局预编译过程
第一步:创建全局执行上下文对象 GO
ini
var GO = {};
第二步:找变量声明,变量名作为GO的属性名,值为undefined
ini
GO.globalVariable = undefined;
GO.globalFn = undefined;
第三步:在全局找函数声明,函数名作为GO的属性名,值为函数体
ini
GO.globalVariable = undefined;
GO.globalFn = function globalFn(){}
然后是全局的执行时刻!
首先执行 globalFn 函数 ,进入它的函数预编译阶段 (这里和之前讲解的函数预编译类似) 在 globalFn 函数内部声明了 localVariable 变量,初始值为 undefined(预编译阶段),执行时被赋值为 "I'm local in globalFn"*
当执行到 fn(5) 时,进入 fn 函数的预编译和执行过程,这部分和原函数逻辑一致,按照之前分析的步骤进行。
重点来看全局预编译相关的体现:全局变量 globalVariable 在全局预编译时先是被初始化为 undefined,然后进行赋值操作变成了 "I'm global"。全局函数 globalFn 在预编译时先被初始化为 undefined,之后被赋予了实际的函数体,当调用 globalFn 时,就会按照函数内定义的逻辑去执行,包括调用 fn 函数以及输出 localVariable 的值等操作。
所以在整个代码执行过程中,全局预编译为整个 JavaScript 程序的执行创建了初始环境,确定了全局变量和全局函数的初始状态,后续函数的执行都是基于这个初始环境 ,沿着作用域链 去查找和操作相应的变量与函数的。
这里我们就引出了我们使用什么去装载这些执行上下文呢,他又是一个什么样的结构去实现的呢?
调用栈
在 JavaScript 的运行机制中,调用栈(Call Stack)扮演着至关重要的角色,它就像是一个管理函数调用顺序和执行上下文的 "容器",帮助 JavaScript 引擎有条不紊地执行代码。
首先你得知道他的规则
当 JavaScript 代码开始执行时,首先是全局,创建一个全局执行上下文,在栈的最底部。然后每当一个函数 被调用,就会创建一个对应 的执行上下文,并将其压入调用栈中。这个执行上下文包含了函数执行时所需要的各种信息,比如前面提到的函数的活动对象(AO)、作用域链等内容。函数在执行过程中,可以访问到自己作用域内以及沿着作用域链可访问到的变量和函数。
当函数执行完毕后,它对应的执行上下文就会从调用栈中弹出,控制权交回到调用这个函数的地方,然后继续执行后续的代码。这种 "后进先出"(Last In, First Out,简称 LIFO)的结构,保证了函数调用的正确顺序和执行逻辑。
执行上下文是一个包含多个组件的整体环境,用于执行代码。它就像一个舞台,代码在这个舞台上进行表演。而变量环境和词法环境是这个舞台上的两个重要 "道具" 或者 "区域",它们协同工作,为代码执行提供必要的环境支持。
-
变量环境(Variable Environment) :
- 存储所有通过
var
关键字声明的变量。 - 想象你在一个巨大的图书馆(代表整个代码世界)里。这个图书馆有很多不同的房间(代表不同的函数),每个房间里有书架(代表变量和函数)。词法环境就像是图书馆的布局图,它知道每个房间(函数)在哪里,也知道每个房间里书架(变量和函数)的位置。
- 存储所有通过
-
词法环境(Lexical Environment) :
- 存储所有通过
let
和const
关键字声明的变量。 - 变量环境就像是每个房间(函数)里专门放东西(变量)的一个大箱子。当你声明一个变量(比如用 "var"),就好像把一个东西放进了这个箱子。这个箱子里放着这个房间(函数)里所有用 "var" 声明的变量。
- 而且这个箱子还有个特殊的地方,有些东西(变量)可以在你还没正式放进箱子之前就被找到(变量提升),不过那时候它们还没真正整理好,所以找到的时候可能是乱的(值为 "undefined")
- 存储所有通过
上面对词法环境和变量环境的介绍很值得大家反复去思考,他也是我们实现作用域链的重要part!
知道了之后,我们继续介绍规则
一直入栈,他是怎么出栈的?
当执行上下文它调用完之后,因为垃圾回收机制,就是把分配给你的内存收回,将这个执行上下文弹出调用栈中!!
想象一下,执行上下文就好比是一个临时的 "工作间",你在里面干活(执行代码),等活干完了(代码执行完了),这个 "工作间" 里好多东西可能就没用了呀。比如说,你在一个函数执行上下文中声明了一些变量,像 var num = 10
或者定义了一些临时的对象啥的,等这个函数执行完了,这些东西理论上在后面的代码里大概率就不再需要了,它们就变成了 "垃圾",占着内存空间也没意义了呀。
如果你在当前作用域找不到的话,就可以往下去查找
- 在函数执行过程中,需要访问变量或者调用其他函数时,就会依赖执行上下文的信息进行操作。例如,在
add
函数中访问num1
和num2
这两个参数,这些参数存储在add
函数的执行上下文的变量环境中。同时,函数内部的变量查找也会基于执行上下文的词法环境和作用域链进行。如果在当前函数的执行上下文中找不到需要的变量,就会沿着作用域链向外层执行上下文查找,这与调用栈的层次结构有关。因为调用栈的下层执行上下文对应的函数可能是上层函数的外层作用域。
csharp
var a = 2
function add(){
var b = 10
return a+b
}
add()
它的查找过程如下:
END
相信大家通过跟着我一起对这些内容的深入解读,已经能够清晰地认识 JavaScript 代码从预编译到执行 ,再到执行上下文管理 以及变量查找 等一系列底层运行机制,它有助于在实际开发中更准确地把握代码行为,避免因语言特性带来的常见错误和陷阱,同时也为深入理解 JavaScript 的高级应用 以及性能优化等方面奠定坚实基础。
有什么不会的欢迎大家评论区提问!!