JavaScript
代码是在浏览器中被执行的,肯定和浏览器的内核有关系,浏览器内核由两部分组成,主要看下常见的两个浏览器
Safari
和
Chrome
:
Safari
浏览器:使用webkit
内核,由以下两部分组成WebCore
:负责HTML
解析、布局、渲染等等相关的工作;JavaScriptCore
:解析、执行JavaScript
代码;
Chrome
浏览器:Blink
:在2013年被开发是从WebKit
项目分支出来的一个独立的渲染引擎,更容易与其他组件(如V8
引擎)集成V8
引擎:主要理解学习它的原理,非常高性能的JavaScript
引擎,由谷歌开发,最初用于Chrome
浏览器,现在广泛用于Node.js
以及其他项目
v8引擎的执行原理
1. 解析
Parse
模块将JavaScript
代码转换成AST
(抽象语法树)
Parse模块
Parse
的V8
官方文档:https://v8.dev/blog/scanner
这张图展示了从 Blink
引擎接收 JavaScript
代码到生成AST
的详细流程,即Parse
模块做了什么:
- 首先
Blink
拿到JavaScript
代码,将从Blink
渲染引擎接收到的各种编码格式(如ASCII、Latin1、UTF-8
)转换为统一的UTF-16
编码,因为V8
引擎内部处理字符串和字符数据时,通常使用UTF-16
编码 - 然后
Scanner
(扫描器)将UTF-16
编码的代码单元转换为标记(tokens
)。这些标记是解析器(Parser
)用来生成抽象语法树(AST
)的基本单位,Scanner
具体作用如下:- 词法分析(
Lexical Analysis
):Scanner
执行词法分析,将连续的字符流分割成有意义的标记(tokens
) - 标记化(
Tokenization
):每个标记代表代码中的一个基本语法成分,包含了其类型和相关的值,如关键字、标识符、字面量、操作符等 - 过滤无关字符:
Scanner
还负责过滤掉代码中的无关字符,如空白符(空格、制表符、换行符)和注释 - 错误检测:在标记化过程中,
Scanner
还可以进行一些基本的语法错误检测,识别出不符合语法规则的字符序列,并生成相应的错误信息
- 词法分析(
PreParser
:预解析器用于快速扫描代码,检测基本语法错误,进行初步优化,并可能跳过不必要的代码解析,比如会跳过函数体的解析只记录函数的定义Parser
:从Scanner
或PreParser
接收标记,解析标记的顺序和语法规则,生成抽象语法树(AST),进行详细的语法分析和检查
AST(抽象语法树)
表示源代码结构的树状数据结构,用途如下:
- 代码解析:将源代码转换为 AST,使其结构化和易于分析。
- 代码优化:编译器可以通过分析 AST 来进行代码优化,例如常量折叠、死代码消除等。
- 代码生成:编译器可以根据 AST 生成目标代码,如机器码或字节码。
- 代码分析和转换:工具可以通过操作 AST 来进行代码分析、格式化、重构等任务。
2. 解释执行
V8
使用一个解释器,称为Ignition
(/ɪɡˈnɪʃn/
),将JavaScript
源代码编译成字节码(ByteCode
)并执行
Ignition
解释器
Ignition
的V8
官方文档:https://v8.dev/blog/ignition-interpreter
- 即使代码只执行一次,
JIT
机器代码也会消耗大量内存。为了减轻这种开销,V8团队构建了一个新的JavaScript
解释器,称为Ignition
- 会收集
TurboFan
优化所需要的信息(比如函数参数的类型信息,有了类型才能进行真实的运算) - 如果函数只调用一次,
Ignition
会执行ByteCode
3. 即时编译(JIT
)
优化性能,将热点代码编译成机器码
在此过程V8
使用了两个JIT
编译器:
Baseline
编译器:快速生成初始机器码,提升初始执行速度。TurboFan
(/ˈtɝboʊˌfæn/
)优化编译器:在代码执行过程中收集运行时信息,针对热点代码进行深入优化,生成高效的机器码
TurboFan编译器
TurboFan
的V8
官方文档:https://v8.dev/blog/turbofan-jit
- 如果函数被多次调用,这个函数被标记为热点函数,那么就会经过
TurboFan
编译成优化的机器码,提高代码的执行性能 - 但是优化的机器码也会被还原成
ByteCode
,这是因为后续执行函数的过程中,类型发生了改变(比如sum
函数原来是执行number
类型的值相加,后面调用函数传的参数是string
类型),这种情况之前优化的机器码并不能正确的处理运算了,就会再逆向的转换成字节码
5. 垃圾回收
垃圾回收(
GC
)是自动管理内存的过程,负责回收不再使用的内存,以防止内存泄漏和优化内存使用。JavaScript
语言本身提供了垃圾回收机制,而V8
引擎则具体实现了这一机制,也就是JavaScript
的垃圾回收机制主要由JavaScript
引擎(如V8
)实现,V8
的垃圾回收机制使得JavaScript
的自动内存管理更加高效
内存管理
JavaScript 的内存管理依赖于 V8 引擎的实现
不管使用什么方式管理内存,内存的管理都会有如下生命周期:
- 第一步:分配申请你需要的内存(申请)
- 第二步:使用分配的内存(存放一些东西,比如对象等)
- 第三步:不需要使用时,对其进行释放
不同的编程语言对于第一步和第三步会有不同的实现:
- 手动管理内存:比如
C
、C++
,包括早期的OC
,都是需要手动来管理内存的申请和释放的(malloc
和free
函数) - 自动管理内存:比如
Java
、JavaScript
、Python
、Swift
等,它们有自动帮助管理内存
JavaScript
会在定义数据时为我们分配内存:
JS
对于原始数据类型内存的分配会在执行时,直接在栈空间进行分配JS
对于复杂数据类型(如对象、数组和函数)内存的分配会在堆内存中开辟一块空间,并且将这块空间的指针返回值作为变量引用
V8引擎为了提高内存的管理效率,对内存进行非常详细的划分:
垃圾回收器(GC
)怎么知道哪些对象是不再使用的呢?这主要是使用以下算法:
常见GC算法——引用计数
一种较早的算法垃圾回收算法,原理如下:
- 每一个对象都有一个引用计数器,用来记录这个对象被引用的次数
- 当有新引用指向这个对象,它的引用计数就会加一
- 当一个对象的引用为0时,这个对象就可以被销毁
有一个缺点就是会产生循环引用:(两个对象互相引用,但它们都不再被其他对象引用)
常见GC算法——标记清除
标记清除是 JavaScript
最常用的垃圾回收算法:
- 标记清除的核心思路是可达性(
Reachability
) - 垃圾回收器会定期从根对象(如全局对象)开始,遍历对象图(图结构),找所有从根开始有引用到的可达对象并标记,没有引用到的对象,就认为是不可达的对象,解决了循环引用的问题
常见GC算法——其他算法优化补充
JS
引擎比较广泛采用是可达性中的标记清除算法,当然类似于V8
引擎为了进行更好的优化,它在算法的实现细节上也会结合一些其他的算法:
- 标记整理:和标记清除类似,但它回收期间回收器会同时整理内存碎片,将存活对象汇集到连续的内存空间进行整合,以确保有大块连续的空间可用避免内存碎片化
- 分代收集:
V8
将堆内存分为新生代(Young Generation
)和老生代(Old Generation
)- 新生代:存放生命周期较短的对象,使用复制算法,新生代内存分为两个等大小的空间(
From
和To
),对象首先分配在From
空间。当From
空间满了时,垃圾回收器开始工作,它遍历并标记所有可达的对象并复制到To
空间,调整对象的引用指向新的内存地址,清空From
空间,然后交换From
和To
空间的角色,原来的To
空间成为新的From
空间 - 老生代:存放些长期存活生命周期较长的对象,使用标记清除和标记整理算法
- 新生代:存放生命周期较短的对象,使用复制算法,新生代内存分为两个等大小的空间(
- 增量收集:
- 如果有许多对象,如果我们试图一次遍历并标记整个对象集,可能需要一些时间,会在执行过程中带来明显的延迟
- 所以
V8
会将垃圾回收过程分为多个小步,每一小步只做一小部分工作,减少单次垃圾回收的停顿时间
- 闲时收集:垃圾收集器只会在
CPU
空闲时尝试运行,以减少可能对代码执行的影响
JavaScript
执行中的概念
1. 作用域(Scope
)
作用域是指代码中变量和函数声明的可见范围。JavaScript
有三种作用域:
- 全局作用域:在全局范围内声明的变量和函数在整个代码中都可见
- 函数作用域:在函数内部声明的变量和函数只在该函数内部可见
- 块级作用域:在ES6引入,在块级结构(如
if
、for
、while
等)内使用let
或const
声明的变量,只在该块级结构内部可见
2. 作用域链(Scope Chain
)
是查找变量时沿着执行上下文链向上查找的规则,适用于所有执行上下文。在当前作用域中找不到变量时,JavaScript
引擎会沿着作用域链向上查找,直到找到变量或到达全局作用域,都找不到则抛出引用错误
-
每个执行上下文都有一个作用域链,当进入到一个执行上下文时,作用域链被创建,是一个对象列表
-
在函数执行上下文中,作用域链在函数调用时动态创建,包含函数的
AO
和这个函数内部的[[Scopes]]
属性记录的作用域信息[[Scopes]]
属性:在函数定义时静态创建,保存了函数定义时的作用域链(包括父函数的AO
和全局作用域),不会随函数调用的上下文而变化
3. 全局对象(GO
)Global Object
JavaScript
引擎会在代码执行之前,在堆内存中创建一个全局对象GO
,
-
会将所有全局定义的变量、函数等加入到
GO
中,并且所有的作用域都可以访问- 函数会在变量之前声明,在堆内存分配一个函数对象,保存函数相关信息
- 变量被声明但是并不会赋值(即为
undefined
),这个过程也称变量的作用域提升(hoisting
)
-
GO
会包含Math
、Date
、Array
、String
、Number
、setTimeout
等等,有个window
属性指向GO
自己 -
在浏览器环境中,全局对象就是
window
4. 执行上下文(EC
)Execution Contexts
JavaScript
引擎内部有一个执行上下文栈ECS
(Execution Context Stack
),它是用于执行代码的调用栈
执行上下文分为三种,我们主要学前两种:
-
全局执行上下文
GEC
(Global Execution Context
):- 代码在全局作用域中执行时,会创建一个
GEC
并放入ECS
中 - 只有一个
GEC
,在整个JavaScript
脚本的生命周期内都会存在于执行栈的最底部也不会被栈弹出销毁
- 代码在全局作用域中执行时,会创建一个
-
函数执行上下文
FEC
(Function Execution Context
):- 每当函数被调用时,都会创建一个新的
FEC
,并压入到ECS
中 - 当函数体执行完时会弹出栈销毁
- 每当函数被调用时,都会创建一个新的
-
eval
执行上下文(Eval Execution Context
):自行学习并谨慎使用
每个执行上下文有三个重要部分:
- 变量对象
VO
:GEC
中包含全局定义的变量、函数等,FEC
中包含函数的参数、内部变量和函数声明等 - 作用域链:包含当前执行上下文的
VO
(在作用域链的顶端) 和所有父级执行上下文的VO
this
绑定:浏览器环境GEC
中是window
,FEC
中根据函数调用方式设置this
的指向 https://juejin.cn/post/7390413118889377832
5. 变量对象(VO
)Variable Object
- 在每个执行上下文创建阶段,
VO
被初始化并关联,VO
是一个静态的结构 VO
在整个执行上下文生命周期内不会改变,内部定义的属性也不能被直接访问的- 在全局执行上下文中,
VO
是GO
(浏览器环境中是window
对象) - 在函数执行上下文中
VO
就是活动对象AO
- 可以说
VO
只是一个抽象概念,它会包含执行上下文中所有变量、函数和参数的对象,但GO
和AO
是VO
的具体实现
6. 活动对象AO
(Activation Object
)
是FEC
中的一个动态结构,它在函数调用时被创建,AO
用于处理函数的实际执行,比如执行函数体代码时定义和修改变量及参数的值
FEC
的VO
就是AO
,其实VO
和AO
是一个东西,只不过处于不同的状态和阶段而已AO
会使用arguments
做初始化,并且初始值是传入的参数AO
不使用的属性优化:当函数被解析时,V8
不会立即创建AO
,而是根据实际需要进行延迟解析。只有在访问变量或参数时,才会为其分配内存,对于从未使用过的变量和参数,V8
不会为其分配内存
全局代码执行过程
var code = "javaScript";
var names = ['你','我','他']
var obj = {
name: 'obj',
age: 18,
}
var num1 = 10;
var num2 = 20;
var result = num1 + num2;
console.log(result);
代码执行前:
代码执行完: 执行代码会将相关变量进行赋值,obj
和names
会在堆内存中创建新的对象空间,obj
和names
变量在栈内存中存储的是对象和数组对象在堆内存中的地址或指针,图示如下:
函数的执行过程
function foo(age) {
var bar = function () {
console.log(age);
};
return bar
}
var baz = foo(18);
baz();
代码执行前:
遇到函数声明是使用 function
关键字并且函数声明是有名称时,在解析中会被提升,函数声明会先被处理,然后才是变量声明,声明函数会先在堆内存中分配一个对象来表示这个函数,里面包括一些内置属性、函数的代码、作用域链(Scope Chain
)和闭包(如果有的话)
代码执行中:
foo
函数执行上下文中的Scope Chain
为[foo AO, Global Scope]
,var baz = foo(18)
执行图:
foo
函数执行上下文出栈,然后baz
函数执行上下文进栈,baz()
执行图:
代码执行完:
闭包
在上面函数执行的代码中我们可以了解到执行baz()
时我们访问了外层作用域的age
值为18
,这是因为闭包通过保存函数创建时的作用域链,实现了对外层作用域变量的持久访问,即使在该函数外部作用域执行结束后,仍然可以访问这些变量
-
那什么是闭包? 这是
JavaScript
中的一个核心概念,下面我们好好学习:- 一个函数和对其周围状态(
lexical environment
)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure
) - 在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来
- 从广义的角度来说:
JavaScript
中的函数都是闭包 - 从狭义的角度来说:
JavaScript
中一个函数,如果访问了外层作用域的变量,那么这个函数和周围环境就是一个闭包
- 一个函数和对其周围状态(
-
那什么是内存泄漏? 在函数执行过程例子中如果我们后面再也不会调用
baz
了,该函数对象和函数引用的AO
怎么被销毁掉?- 所以我们经常说的闭包会造成内存泄露,其实是
baz
的引用链中的所有对象都是无法释放的(执行完的图中可以看出) - 我们不用的时候给
baz
设置为null
,这时baz
没有任何引用,在GC
的下一次检测中,它们就会被销毁掉,如图:
- 所以我们经常说的闭包会造成内存泄露,其实是
-
闭包的用途:
- 函数柯里化:将一个多参数函数转换为多个单参数函数的技术,可以保持对初始参数的引用
- 模拟块级作用域:在
ES6
之前,JavaScript
没有块级作用域,通过闭包可以模拟块级作用域,避免变量污染全局作用域 - 事件处理:闭包在事件处理器中很常见,它可以让处理器函数访问到事件绑定时的环境变量
-
代码:
var btnEl = document.querySelector(".btn"); /* 1. 使用var声明时 i 会被提升值为undefined 2. 再执行for循环,i = 0, 3. 判断 0 < btnEl.children.length,得出0小于4 4. 执行循环体的代码btnEl.children[0].onclick = 函数 5. i++,i = 1,再重复执行for循环 当循环执行完时 i = 4,这时你点击按钮会找 i, 在函数作用域没找到会向外层即全局查找,找到 i=4 所以不管你点击第几个按钮都是4,那么怎么解决呐? */ for (var i = 0; i < btnEl.children.length; i++) { // 事件处理函数是闭包,创建时会捕获其外部作用域(全局作用域) btnEl.children[i].onclick = function () { console.log(`第${i + 1}个按钮被点击了`); }; } /* 使用立即执行函数解决: 当执行到循环体代码时,立即执行函数会立即调用创建FEC, 形成自己的作用域,并定义传入的参数ii=0和事件, 当点击时执行事件函数,取到立即执行函数中的ii,不会取到全局的4 */ for (var i = 0; i < btnEl.children.length; i++) { (function (ii) { // 事件处理函数是闭包,创建时会捕获其外部作用域(立即执行函数的函数作用域) btnEl.children[ii].onclick = function () { console.log(`第${ii + 1}个按钮被点击了`); }; })(i); }