在计算机里的任何东西,在计算机看来都是一串二进制数据01(没电和有电)组成,所以你在计算机上写一串js代码,计算机是读不懂的,因此需要借助其他东西(浏览器)来去读懂这串代码,显然浏览器可以帮助读懂你的这串js代码, 浏览器由许多部分组成,其中有着这么一个部分即js执行引擎去专门读懂js代码,如果把浏览器比作一个公司的话,那么js执行引擎就是其中的一个部门即执行引擎部门,专门读懂js代码,内置在浏览器中。每一个浏览器都有js引擎,只不过有优有劣,其中谷歌浏览器内置的js执行引擎即v8引擎,非常好用。 那么浏览器里的js执行引擎是怎么去读懂并执行你的这串代码的呢?接下来我会详细写出来
执行引擎在编译执行代码前,通常会把你的代码又重新整理一下,变成它能够看得懂的代码,JavaScript 中的代码执行过程包括以下几个主要阶段:
词法分析
css
var a = 2 // var, a, =, 2
我们先来看这串代码,在我们看来就是声明并定义了一个变量a = 2
,但在谷歌浏览器里的v8执行引擎理解里,这里却不是这样的,它没有这么智能,在代码执行出结果前,它首先第一步需要做一个词法分析,就是将这个语句转化为var, a, =, 2
词法单元(注意:只要是构成这串代码的所有东西包括标点符号或者空格等都是词法单元,但是空格是否是词法单元主要取决于在这行代码中有没意义,比如var a
在这里有意义,去掉空格后vara
会报错;=
却没有意义,去掉空格后var a=2
后,一样可以运行,之所以写空格,是因为美观,显得不那么拥挤)
解析(语法分析)
将词法单元转换成一个逐级嵌套的程序语法结构树 --- 抽象语法树。例如转化为树结构,使代码的执行过程按照树的结构顺序执行。学过数据结构的人会更好理解这一部分。
预编译
在执行代码之前,JavaScript 引擎会对代码进行预编译,这个过程包括:
- 变量提升(Hoisting):将变量声明移动到其所在作用域的顶部。
- 函数提升:将函数声明(而不是函数表达式)移动到其所在作用域的顶部。
- 创建执行上下文(Execution Context):为每个函数调用创建执行上下文,包括变量对象、作用域链、this 指向等信息。
预编译步骤详解
预编译,有着众多规则和细节去遵循,才能准确的整理成执行引擎能够理解的代码,我把这一部分抠出来单独讲解,我们接下来看看这个步骤到底有哪些规则和细节:
1. 作用域
看这下面三段代码:
scss
function foo(a) {
console.log(a+b)
}
var b = 2
foo(1) // 3
运行输出:3
scss
funtion foo() {
var a = 1
}
foo()
console.log(a); // b is not defined
运行显示:b is not defined
scss
function foo(a) {
console.log(a + b)
}
function bar() {
var b = 2
}
bar()
foo(1) // b is not defined
运行显示:b is not defined
第一个对,第二,三个错,我们不禁会拿这三段代码做对比,发现:在全局声明定义的b可以被foo函数获取,运行输出;在函数内声明定义的b,然后在函数外去输出,运行却显示not defined;在函数中声明定义的b,然后同一级函数中输出也显示not defined;这就要谈到作用域这个知识点了。
作用域分为全局作用域,函数作用域,块级作用域。
'域'是什么意思呢?简单点理解就是:影响范围,你可以将它理解为某个动物的领地。
- 全局作用域:这个很好理解,就是你写代码整个页面的范围。在这个区域里,我们把这个区域看成全局作用域。
- 函数作用域:顾名思义就是函数包含的区域,我们把他看为函数作用域。
- 块级作用域:块级作用域是指由一对花括号 {} 包围的代码块所创建的作用域范围。在这种作用域内声明的变量或函数只在该代码块内部可见,并且在代码块外部无法访问,使用
let
和const
关键字引入了块级作用域的概念。这意味着通过let
或const
声明的变量只在其所在的块级作用域内有效,而不再受限于函数作用域。
在作用域中有这么一个规则:内层作用域是可以访问外层作用域的,反之则不行。
我们把这个规则带到上面那三段代码中去看,你就会明白,在全局作用域中声明定义的函数作用域中即内层作用域,可以去访问外层作用域即全局作用域,所以第一段代码可以运行输出结果,而第二段代码却不可以。第三段代码中bar函数不是foo函数的外层作用域,所以不能运行出结果。
对于块级作用域你可能还没有理解,看下面这段代码:
ini
function test() {
if (true) {
var x = 10;
let y = 20;
const z = 30;
}
console.log(x); // 输出:10
console.log(y); // 报错:y is not defined
console.log(z); // 报错:z is not defined
}
test();
在这个例子中,变量 x
被 var
声明,因此它是函数作用域的,在 test()
函数内的任何地方都可以访问。变量 y
和 z
则是通过 let
和 const
声明的,它们只在 if
语句的块级作用域内有效,因此在 if
语句外部访问它们会导致 ReferenceError。
还有eva()和with可构成欺骗词法作用域,比较特殊,单独拿出来讲,看如下代码:
- eval() 函数的使用 :
eval()
函数可以将字符串作为 JavaScript 代码来执行。这意味着它可以动态创建变量、修改现有变量,甚至执行任意代码。但是,由于它的执行会产生副作用,并且增加了代码的复杂性和不确定性,因此在大多数情况下,最好避免使用eval()
。
ini
var x = 10;
eval('var y = 20;');
console.log(y); // 输出:20,y 被创建在了当前作用域内
- with 语句的使用 :
with
语句将指定的对象添加到作用域链的顶部,使得可以直接访问该对象的属性。但是,如果在with
语句中修改了一个不存在的属性,JavaScript 引擎会认为这是在全局作用域中创建了一个新的变量,这可能会导致变量泄露到全局作用域。
ini
var obj = { x: 10 };
with (obj) {
x = 20; // 修改 obj 中的属性 x
y = 30; // 如果 obj 中没有属性 y,则会创建一个全局变量 y
}
console.log(obj.x); // 输出:20
console.log(y); // 输出:30
由于 eval()
和 with
可能导致一些不良的副作用和降低代码的可读性,一般来说,在实际编码中应该尽量避免使用它们,除非确实有必要,并且能够清楚地理解和控制其行为。
总之一句话:eval() 将原本不属于这里的代码变成就像天生就定义在了这里一样 ,with() {} 用于修改一个对象中的属性值,但如果修改的属性在原对象中不存在,那么该属性就会被泄露到全局
总结:
- 优先使用
const
和let
:在 ES6 中引入了const
和let
来声明变量,它们分别用于声明常量和可变的变量。相较于传统的var
,它们具有块级作用域,更好的可读性和可维护性。优先使用const
来声明不会被重新赋值的变量,只有在确实需要重新赋值的情况下才使用let
。
ini
const PI = 3.14;
let count = 0;
- 避免使用
var
:尽管var
仍然有效,但它具有函数作用域而不是块级作用域,容易导致变量提升和意外的行为。因此,最好避免使用var
,除非有特殊情况需要。
ini
var age = 30; // 避免使用
- 避免使用
eval()
和with
:已经讨论过,它们可能会导致不良的副作用和降低代码的可读性,应尽量避免使用。
ini
eval('var x = 10;'); // 避免使用
- 显式声明变量 :始终使用
const
、let
或var
来声明变量,不要依赖隐式声明,这样可以提高代码的可读性和可维护性。
ini
let name = "John";
- 避免变量泄露到全局作用域 :在函数内部声明的变量如果不使用
var
、let
或const
关键字就会自动成为全局变量,因此要注意避免这种情况,以免引起意外的行为。
csharp
function foo() {
bar = 10; // 如果没有使用 var、let 或 const,bar 将成为全局变量
}
遵循这些最佳实践可以帮助你编写更清晰、更健壮的 JavaScript 代码。
2. 声明提升
声明提升是 JavaScript 中一个重要的概念,它指的是在代码执行前,JavaScript 引擎会将变量声明(但不包括赋值)和函数声明提升到其所在作用域的顶部。这意味着在执行代码之前,JavaScript 引擎会先处理变量和函数的声明,然后再执行实际的代码。
变量提升只影响变量的声明,而不影响赋值操作。举个例子:
ini
console.log(x); // 输出:undefined
var x = 5;
在这个例子中,变量 x
被声明了,但是在赋值之前被访问,因此输出结果是 undefined
。这是因为在实际执行时,JavaScript 引擎会将变量 x
的声明提升到代码的顶部,所以上述代码实际执行顺序相当于:
ini
var x;
console.log(x); // 输出:undefined
x = 5;
函数声明也会被提升,不论函数声明在代码中的位置如何,都可以在其所在作用域中被访问到。
scss
foo(); // 输出:Hello
function foo() {
console.log("Hello");
}
在这个例子中,函数 foo
被声明在调用之前,但是仍然可以成功调用,因为函数声明被提升到了作用域的顶部。
需要注意的是,只有声明会被提升,而赋值不会。变量赋值和函数表达式不会被提升。
ini
console.log(y); // 报错:y is not defined
let y = 5;
在这个例子中,使用 let
声明变量 y
,但是在赋值之前访问它,由于 let
声明不会被提升,因此会报错。
3. 作用域链
作用域链(Scope Chain)是 JavaScript 中的一个重要概念,它决定了在当前执行环境中变量和函数的可访问性。每个函数在被创建时都会创建一个作用域链。
作用域链的构成如下:
- 当前执行环境的变量对象(Variable Object) :当前执行环境包含了当前函数的所有局部变量、函数参数以及
this
指向的对象。 - 包含(Enclosing)作用域的变量对象:如果当前函数是在另一个函数内部定义的,那么它就会有一个包含作用域链的变量对象,该对象包含了外部函数的变量对象。
- 全局作用域的变量对象:在 JavaScript 中,每个执行环境都可以访问到全局作用域的变量对象,它包含了全局作用域中的变量和函数。
当在函数内部访问一个变量时,JavaScript 引擎会首先在当前函数的变量对象中查找是否存在该变量。如果找不到,它就会沿着作用域链逐级向外搜索,直到找到该变量或者搜索到全局作用域为止。如果在全局作用域中仍然找不到该变量,JavaScript 引擎会抛出一个 ReferenceError
错误。
作用域链的形成是在函数定义的时候确定的,而不是在函数调用的时候。这意味着内部函数会包含对外部函数的作用域链的引用,即使外部函数执行完毕,内部函数依然可以访问外部函数的变量。
scss
function outerFunction() {
var outerVariable = 'I am outer!';
function innerFunction() {
console.log(outerVariable); // innerFunction可以访问outerFunction中的变量
}
innerFunction();
}
outerFunction(); // 输出:I am outer!
在这个例子中,innerFunction
在定义时就会创建一个作用域链,其中包含了 outerFunction
的变量对象,因此 innerFunction
可以访问到 outerVariable
变量。
4. 执行上下文对象
给大家补足知识点和细节后,重点来了(敲黑板),创建执行上下文:
javascript
showName()
console.log(myName);
var myName = '坤坤'
function showName() {
console.log('函数showName被执行');
}
对于这段代码,创建执行上下文对象,如下:
创建上下文对象情况有三种,对于全局,会创建一个全局执行上下文对象,对于函数,会创建一个函数执行上下文对象,对于eval等,也会创建一个执行上下文对象。变量环境中放var声明的变量,并赋值为undefined,以及放置函数名,并把函数中的东西放入堆中(原始类型数据存在栈里面,引用类型或复杂类型数据存在堆里面,原始数据类型有number,string,bool,undefined和none,函数类型是复杂类型)。
然后,除了声明以外的代码编译成字节码,然后执行,从上往下,执行到myName变量时,将undefined改为'kunkun',当执行到函数调用时,会在变量环境中查找是否有这个函数,如果有,就调用堆里面的函数体,再对函数里的东西进行预编译,生成一个函数执行上下文对象,再执行......,看起来像是一个套娃的过程,总之一句换,你写的代码会被创建成n个执行上下文对象,他们之间的关系就是执行先后关系。
scss
function fn() {
fn()
}
fn()
看这段代码,会报错,因为他会创建无限个上下文对象,在牛逼的电脑也挺不住,我们称这样的为爆栈
那么栈在其中起着什么作用呢?
我们写的代码,js执行引擎预编译,创建了n个执行上下文对象,那这些执行上下文对象谁先执行,又是谁后执行?
显然,用栈去实现这种执行先后关系,看如下代码:
css
var a = 2
function add(b, c) {
return b + c
}
function addAll(b, c) {
var d = 10
var result = add(b, c)
return a + result + d
}
addAll(3, 6)
画图:
当执行完add执行上下文了,就要销毁,从上往下依次类推。到这里js执行就结束了。
再来看下面这段代码:
ini
function bar() {
console.log(myName);
}
function foo() {
var myName = 'Tom';
bar();
}
var myName = 'Jerry';
foo()
画图:
当bar函数执行时,到底是Tome还是Jerry?
根据栈先进后出的原理,感觉是从上往下获取值,结果应为Tome
但是输出结果却是Jerry,why?
这就是作用域链的作用了,每个执行上下文对象中都有一个outer属性,它会指向下一个作用域,当 当前作用域没找到变量的声明定义时,它就会去下一个作用域找,那么bar的作用域outer指向的下一个作用域就是全局作用域,那么你可能会有疑问,凭什么bar和foo的outer都指向全局作用域?简单点理解就是bar和foo都声明在了全局里,那么他们的outer肯定指向全局作用域,这跟函数调用没有半毛钱关系,规则就是这样,记住就行(其实深一点就是词法环境在哪outer就指向哪里,我解释不通......)。
小段总结:作用域链并不是在调用栈中从上到下查找,而是看当前执行上下文变量环境中的 outer 指向来定,而 outer 指向的规则是,我的词法作用域在哪里,outer 就指向哪里
词法作用域:在函数定义时所在的作用域(有些书上会这么写)
结语
我感觉我写的好乱,也写了好多,恨不得全部解释完全,其中必然会出现些问题和语句不通,望大家谅解,有问题,欢迎在评论区中指出!