前言
在深入探索 JavaScript的世界中,理解其底层逻辑是提升编程技能的关键一步。预编译、变量提升、函数提升、调用栈、词法作用域以及作用域链等概念,构成了 JavaScript 运行机制的基石。这些底层原理不仅影响着代码的执行结果,更能让我们洞察代码背后的运行奥秘。接下来,让我们一起揭开这些神秘面纱,深入剖析 JavaScript 代码的预编译、变量提升、函数提升、调用栈、词法作用域和作用域链的底层原理。
基础知识
我们通过面试题了解 JavaScript底层逻辑以及原理。
eg1:
javascript
function fun(){
var a = 1
let b = 2
{
let b = 3
var c = 4
console.log(a) //1
console.log(b) //3
}
console.log(b) //2
console.log(c) //4
console.log(d) //error
}
fun()
eg2:
javascript
function fun1(){
console.log(id)
}
function fun2(){
var id = 666
fun1()
}
var id = 888
fun2() //888
你得到正确的输出值了吗。如果没有得到正确的输出值,可以看看这篇文章得到一些收获。
在详细以JavaScript引擎的视角执行代码时我们先了解一些基础知识。
变量提升和函数提升
javascript
sayName()
console.log(myName)
var myName = '李明'
function sayName() {
console.log('函数被执行了')
}
输出结果是什么呢?让我们一起分析分析。
在执行sayName()
时,但是函数都没有声明,那不就运行不了了吗?在执行时console.log(myName)
时,要输出myName的值。但是myName没有被声明,这样会报错吗?
函数存在函数提升,变量存在声明提升。在JavaScript引擎的眼里这段代码是这样的,然后再执行。
javascript
var myName = undefined
function sayName = function(){
console.log('函数被执行了')
}s
sayName()
console.log(myName)
myName = '李明'
执行下来的结果是
调用栈
栈是一种遵循先进后出原则的线性结构,它的操作主要集中在栈顶,只能在栈顶进行插入和删除元素。在 JavaScript 中,可以使用数组来模拟栈的功能。因为在JavaScript中,数组具有强大的功能可以通过只使用push和pop或shift和unshift实现栈的先进后出功能。(栈是阉割版的数组)
调用栈是一种数据结构,用于存储计算机程序执行时的函数调用信息。当函数被调用时,它的执行上下文会被压入调用栈,当函数执行完毕后,执行上下文会从调用栈中弹出。
可以产生执行上下文的代码有一下三种:
- 全局代码
- 函数体代码
- eval代码(不常见)
注意:
-
要避免回调地狱。(回调地狱:当有多个异步操作需要依次执行,且每个操作都依赖前一个操作的结果时,就容易出现多层回调函数的嵌套。)
eg:
javascriptfunction func1(callback) { callback('data1'); } function func2(data1, callback) { callback(data1 + 'data2'); } func1(function(result1) { func2(result1, function(result2) { }); });
-
避免无限递归调用。
javascriptfunction fn() { fn() } fn()
因为调用一个函数是就要创建一个函数上下文对象,一直无限调用会使栈空间会不断被消耗,直到栈空间耗尽,程序就会崩溃。
预编译
在代码执行前需要进行预编译环节。
预编译要进行的操作
- 在全局进行预编译(发生在全局中):
- 创建全局上下文对象(Global Object)用于存储全局的有效标识符。
- 在全局找变量声明,将变量名作为Global Object的属性名,属性值为undefined。
- 在全局找函数声明,将函数名作为Global Object的属性名,属性值为该函数体。
- 在函数体中进行预编译(发生在函数体中):
- 创建一个函数上下文对象(Activation Object)用于存储函数中的有效标识符。
- 在函数体里找形参和变量声明,将形参和变量名作为Activation Object的属性名,属性值值为undefined。
- 形参和实参相互统一。
- 在函数体内找函数声明,将函数名作为Activation Object的属性名,属性值为该函数体。
在了解完预编译的过程,你就会知道变量的声明提升和函数提升的原理。
词法作用域
词法作用域又称静态作用域,是指变量的作用域在代码编写时就已经确定,由代码的词法结构来决定。
具体来说,在词法作用域中,变量的可见性是由其在代码中声明的位置决定的,而与函数的调用位置无关。
底层分析代码eg1(图解)
想象一下我们现在就是一个JavaScript引擎,运行下面的代码。
javascript
function fun(){
var a = 1
let b = 2
{
let b = 3
var c = 4
let d = 5
console.log(a) //1
console.log(b) //3
}
console.log(b) //2
console.log(c) //4
console.log(d) //error
}
fun()
这就是预编译过程生成的上下文对象,它描述了 JavaScript 代码在执行时的环境和状态。上下文对象包括了变量对象、作用域链、this 指向等信息,以及可执行代码的类型(全局代码、函数代码、eval 代码)。
预编译完成后状态:
词法环境通过词法作用域来识别块级作用域,通过创建新的词法环境记录来实现在代码块内部声明的变量的作用域限制。
执行完后状态:
在执行块级作用域中访问变量时:
- JavaScript 引擎首先在当前执行上下文的词法环境中查找变量。
- 如果在词法环境中找不到变量,则 JavaScript 引擎会继续在当前执行上下文的变量环境中查找。
在第8行console.log(a)
和第九行console.log(b)
的执行,我们应该按这个顺序寻找。(从现在的作用域往外层作用域寻找。)
所以结果分别为1和3。
在执行11、12、13行的console.log(b)
、console.log(c)
、console.log(d)
。查找变量的顺序为这样(从现在的作用域往外层作用域寻找,外层作用域不能访问内层作用域):
结果分别为2,4,error。之所以执行console.log(d)
产生错误,是因为当前所在的块级作用域无法访问内层块级作用域的变量d,没有找变量d的声明定义所以报错。
底层分析代码eg2(图解)
想象一下我们现在就是一个JavaScript引擎,运行下面的代码。
javascript
function fun1(){
console.log(id)
}
function fun2(){
var id = 666
fun1()
}
var id = 888
fun2() //888
过程:(调用栈状态)
在全局执行调用fun2函数后,执行fun2函数调用fun1函数。
- 全局执行
- 调用fun2函数
- 调用fun1函数
在执行fun1函数的console.log(id)
时,你是按这样的顺序找的吗?
这是错误的查找顺序。
每个执行上下文的变量环境中都存在应该outer属性,这个outer指向的就是当前上下文对象的外层作用域。全局的outer为null,因为全局上下文对象没有外层了;fun2的outer为全局;fun1的outer为全局。通过outer属性查找的链条被称为作用域链。
作用域链不是在调用栈中从上到下查找,而是看当前执行上下文变量环境中的outer指向来定,而outer指向的规则是:我的词法作用域在哪里,outer就指向哪。
该代码的正确查找顺序:
小结
通过深入探索 JavaScript 的底层原理,我们不仅可以更好地理解代码的执行过程,还能够掌握解决各种复杂问题的技能。预编译、作用域链和调用栈等概念构成了 JavaScript 运行机制的核心,掌握这些知识将为我们在编写代码时提供更清晰的思路和更高效的解决方案。在未来的学习和实践中,让我们继续探索 JavaScript 的奥秘,不断提升自己的编程水平。