前端开发最主要需要掌握的是三个知识点:HTML、CSS、JavaScript。
在浏览器中输入查找内容,浏览器是怎样将页面加载出来的?以及 JavaScript 代码在浏览器中是如何被执行的?大致流程如下图所示:
解析:
参考文章:https://developer.mozilla.org/zh-CN/docs/Web/Performance/How_browsers_work
浏览器从服务器下载的文件最终要进行解析,那么内部是谁在帮助解析呢?这里就涉及到浏览器内核。浏览器内核是浏览器的核心组件,负责解释和渲染网页内容。它是一个软件模块,实现了网页布局、解析 HTML、执行 JavaScript、渲染 CSS 等功能。浏览器内核通常由两部分组成:渲染引擎和 JavaScript 引擎。不同的浏览器由不同的内核构成,以下是几个常见的浏览器内核:
参考文章:https://baike.baidu.com/item/%E6%B5%8F%E8%A7%88%E5%99%A8%E5%86%85%E6%A0%B8/10602413
https://zhuanlan.zhihu.com/p/99777087
浏览器从服务器下载完文件后,就需要对其进行解析和渲染,流程如下:
解析:
可以发现上图中还有一个紫色的 DOM 三角,实际上这里是 js 对 DOM 的相关操作;在 HTML 解析时,如果遇到 JavaScript 标签,就会停止解析 HTML,而去加载和执行 JavaScript 代码;那么,JavaScript 代码由谁来执行呢?下面该 JavaScript 引擎出场了。
为什么需要 JavaScript 引擎呢? 高级的编程语言都是需要转成最终的机器指令来执行的,事实上我们编写的 JavaScript 无论你交给浏览器或者 Node 执行,最后都是需要被 CPU 执行的,但是 CPU 只认识自己的指令集,实际上是机器语言,才能被 CPU 所执行,所以我们需要 JavaScript 引擎帮助我们将 JavaScript 代码翻译成 CPU 指令来执行。
比较常见的 JavaScript 引擎有哪些呢? https://blog.51cto.com/u_12970/6565469
https://www.jianshu.com/p/4e0205726fb5
浏览器内核和 JavaScript 引擎的关系? 浏览器内核和 JavaScript 引擎是浏览器中两个不同但密切相关的组件,它们共同负责解析和执行网页中的 JavaScript 代码,但其功能和职责略有不同。浏览器内核: 浏览器内核是浏览器的核心组件之一,负责解析 HTML、CSS 和 JavaScript 等网页内容,并将其呈现给用户。浏览器内核通常包括渲染引擎(用于解析和渲染 HTML、CSS) 和 JavaScript 引擎。不同的浏览器使用不同的内核,例如:
JavaScript 引擎: JavaScript 引擎是解析和执行 JavaScript 代码的组件。它负责将 JavaScript 代码转换为计算机可执行的指令,并在运行时处理变量、函数、对象等。常见的 JavaScript 引擎包括 V8、SpiderMonkey、JavaScriptCore 等。在浏览器内核中,JavaScript 引擎负责处理网页中的 JavaScript 代码,以便在用户的浏览器中执行。虽然浏览器内核通常包括 JavaScript 引擎,但它们并不是同一个概念。浏览器内核还包括其他组件,如渲染引擎、网络模块等,而 JavaScript 引擎专门负责处理 JavaScript 代码的执行。在大多数现代浏览器中,JavaScript 引擎是浏览器内核中的一个重要组成部分,负责执行网页中的 JavaScript 代码,从而实现交互性和动态性。 以 WebKit 为例,参考文章:https://blog.csdn.net/qq_44918090/article/details/131640533 在小程序中编写的 JavaScript 代码就是被 JSCore 执行的:
下面一起深入了解一下强大的 V8 引擎。先了解一下官方对 V8 引擎的定义:
V8 的底层架构主要有三个核心模块(Parse、Ignition 和 TurboFan),接下来对上面架构图进行详细说明。
① Parse 模块: 将 JavaScript 代码转换成 AST(抽象语法树)。该过程主要对 JavaScript 源代码进行词法分析和语法分析;词法分析:对代码中的每一个词或符号进行解析,最终会生成很多 tokens)一个数组,里面包含很多对象);比如,对 const name = 'amo'
这一行代码进行词法分析:
// 首先对const进行解析,因为const为一个关键字,所以类型会被记为一个关键词,值为const
tokens: [
{ type: 'keyword', value: 'const' }
]
// 接着对name进行解析,因为name为一个标识符,所以类型会被记为一个标识符,值为name
tokens: [
{ type: 'keyword', value: 'const' },
{ type: 'identifier', value: 'name' }
]
// 以此类推...
语法分析:在词法分析的基础上,拿到 tokens 中的一个个对象,根据它们不同的类型再进一步分析具体语法,最终生成 AST;以上即为简单的 JS 词法分析和语法分析过程介绍,如果想详细查看我们的 JavaScript 代码在通过 Parse 转换后的 AST,可以使用 AST Explorer 工具:AST 在前端应用场景特别多,比如将 TypeScript 代码转成 JavaScript 代码、ES6转ES5、还有像 Vue 中的 template 等,都是先将其转换成对应的 AST,然后再生成目标代码;参考官方文档:https://v8.dev/blog/scanner
② Ignition 模块: 一个解释器,可以将 AST 转换成 ByteCode(字节码)。字节码(Byte-code):是一种包含执行程序,由一序列 op 代码/数据对组成的二进制文件,是一种中间码。将 JS 代码转成 AST 是便于引擎对其进行操作,前面说到 JS 代码最终是转成机器码给 CPU 执行的,为什么还要先转换成字节码呢?因为 JS 运行所处的环境是不一定的,可能是 Windows 或 Linux 或 iOS,不同的操作系统其 CPU 所能识别的机器指令也是不一样的。字节码是一种中间码,本身就有跨平台的特性,然后 V8 引擎再根据当前所处的环境将字节码编译成对应的机器指令给当前环境的 CPU 执行。参考官方文档:https://v8.dev/blog/ignition-interpreter
③ TurboFan 模块: 一个编译器,可以将字节码编译为 CPU 认识的机器码。在了解 TurboFan 模块之前可以先考虑一个问题,如果每执行一次代码,就要先将 AST 转成字节码然后再解析成机器指令,是不是有点损耗性能呢?强大的 V8 早就考虑到了,所以出现了 TurboFan 这么一个库;TurboFan 可以获取到 Ignition 收集的一些信息,如果一个函数在代码中被多次调用,那么就会被标记为热点函数,然后经过 TurboFan 转换成优化的机器码,再次执行该函数的时候就直接执行该机器码,提高代码的执行性能;图中还存在一个 Deoptimization 过程,其实就是机器码被还原成 ByteCode,比如,在后续执行代码的过程中传入热点函数的参数类型发生了变化(如果给 sum 函数传入 number 类型的参数,那么就是做加法;如果给 sum 函数传入 String 类型的参数,那么就是做字符串拼接),可能之前优化的机器码就不能满足需求了,就会逆向转成字节码,字节码再编译成正确的机器码进行执行;从这里就可以发现,如果在编写代码时给函数传递固定类型的参数,是可以从一定程度上优化我们代码执行效率的,所以 TypeScript 编译出来的 JavaScript 代码的性能是比较好的;参考官方文档:https://v8.dev/blog/turbofan-jit
V8 引擎的官方在 Parse 过程提供了以下这幅图,最后就来详细了解一下 Parse 具体的执行过程。
解析:
Blink 内核将 JS 源码交给 V8 引擎;
Stream 获取到 JS 源码进行编码转换;
Scanner 进行词法分析,将代码转换成 tokens;
经过语法分析后,tokens 会被转换成 AST,中间会经过 Parser 和 PreParser 过程:
生成 AST 后,会被 Ignition 转换成字节码,然后转成机器码,最后就是代码的执行过程了;
编写一段 JavaScript 代码,它是如何执行的呢?简单来说,JavaScript 引擎在执行 JavaScript 代码的过程中需要先解析再执行。那么在解析阶段 JavaScript 引擎又会进行哪些操作,接下来就一起来了解一下 JavaScript 在执行过程中的详细过程,包括 执行上下文、GO、AO、VO 和 VE 等概念的理解。PS:我理解的是在创建函数 AO 的时候其实是不会对函数中定义的变量进行赋值的,与全局对象一样,最开始会手机函数中定义的变量且默认值都为 undefined,只有真正在执行函数体中的代码时才会进行赋值操作,下文画图的时候为了方便直接在创建 AO 对象时就将值写了上去,大家注意。
首先,JavaScript 引擎会在执行代码之前,也就是解析代码时,会在我们的堆内存创建一个全局对象:Global Object(简称GO),观察以下代码,在全局中定义了几个变量:
var name = 'amo'
var message = 'I have a dream'
var num = 18
JavaScript 引擎内部在解析以上代码时,会创建一个全局对象(伪代码如下):
所有的作用域(scope)都可以访问该全局对象;
对象里面会包含一些全局的方法和类,像 Math、Date、String、Array、setTimeout 等等;
其中有一个 window 属性是指向该全局对象自身的;
该对象中会收集我们上面全局定义的变量,并设置成 undefined;
全局对象是非常重要的,我们平时之所以能够使用这些全局方法和类,都是在这个全局对象中获取的;
var GlobalObject = {
Math: '类',
Date: '类',
String: '类',
setTimeout: '函数',
setInterval: '函数',
window: GlobalObject,
...
name: undefined,
message: undefined,
num: undefined
}
了解了什么是全局对象后,下面就来聊聊代码具体执行的地方。JavaScript 引擎为了执行代码,引擎内部会有一个 执行上下文栈(Execution Context Stack,简称 ECS), 它是用来执行代码的调用栈。
① ECS如何执行?先执行谁呢?
无疑是先执行我们的全局代码块;在执行前全局代码会构建一个全局执行上下文(Global Execution Context,简称 GEC);一开始 GEC 就会被放入到 ECS 中执行;
② 那么全局执行上下文(GEC)包含那些内容呢?
第一部分:执行代码前。在转成抽象语法树之前,会将全局定义的变量、函数等加入到 Global Object 中,也就是上面初始化全局对象的过程;但是并不会真正赋值(表现为 undefined),所以这个过程也称之为变量的作用域提升(hoisting);第二部分:代码执行。对变量进行赋值,或者执行其它函数等;下面就通过一幅图,来看看 GEC 被放入 ECS 后的表现形式:
接下来,将全局代码复杂化一点,再来看看调用栈调用全局执行上下文(GEC)的过程。示例代码:
var name = 'curry'
console.log(message)
var message = 'I am a coder'
function foo() {
var name = 'foo'
console.log(name)
}
var num1 = 30
var num2 = 20
var result = num1 + num2
foo()
调用栈调用过程:
var name = 'curry'
时,就从 VO(对应的就是GO)中找到 name 属性赋值为 curry;var result = num1 + num2
,也是从 VO 中找到 num1 和 num2 两个属性的值进行相加,然后赋值给 result,result 最终就为 50;在执行全局代码遇到函数如何执行呢?在执行的过程中遇到函数,就会根据函数体创建一个 函数执行上下文(Functional Execution Context,简称 FEC), 并且加入到执行上下文栈(ECS)中。函数执行上下文(FEC)包含三部分内容:
其实全局执行上下文(GEC)也有自己的作用域链和 this 指向,只是它对应的作用域链就是自己本身,而 this 指向为 Window。继续来看上面的代码执行,当执行到 foo() 时:先找到 foo 函数的存储地址,然后解析 foo 函数,生成函数的 AO;根据 AO 生成函数执行上下文(FEC),并将其放入执行上下文栈(ECS)中;开始执行 foo 函数内代码,依次找到 AO 中的属性并赋值,当执行 console.log(name)
时,就会去 foo 的 VO(对应的就是 foo 函数的 AO)中找到 name 属性值并打印;
上文中提到了很多次 VO,那么 VO 到底是什么呢?下面从 ECMA 新旧版本规范中来谈谈 VO。在早期 ECMA 的版本规范中:每一个执行上下文会被关联到一个变量环境(Variable Object,简称 VO),在源代码中的变量和函数声明会被作为属性添加到 VO 中。对应函数来说,参数也会被添加到 VO 中。也就是上面所创建的 GO 或者 AO 都会被关联到变量环境(VO)上,可以通过 VO 查找到需要的属性;规定了 VO 为 Object 类型,上文所提到的 GO 和 AO 都是 Object 类型;在最新 ECMA 的版本规范中:每一个执行上下文会关联到一个变量环境(Variable Environment,简称 VE),在执行代码中变量和函数的声明会作为环境记录(Environment Record)添加到变量环境中。对于函数来说,参数也会被作为环境记录添加到变量环境中。也就是相比于早期的版本规范,对于变量环境,已经去除了 VO 这个概念,提出了一个新的概念 VE;没有规定 VE 必须为 Object,不同的 JS 引擎可以使用不同的类型,作为一条环境记录添加进去即可;虽然新版本规范将变量环境改成了 VE,但是 JavaScript 的执行过程还是不变的,只是关联的变量环境不同,将 VE 看成 VO 即可;
了解了上面相关的概念和调用流程之后,就来看一下存在函数嵌套调用的代码是如何执行的,以及执行过程中的一些细节,以下面代码为例:
var message = 'global'
function foo(m) {
var message = 'foo'
console.log(m)
function bar() {
console.log(message)
}
bar()
}
foo(30)
① 初始化全局对象(GO),执行全局代码前创建 GEC,并将 GO 关联到 VO,然后将 GEC 加入 ECS 中:foo 函数存储空间中指定的父级作用域为全局对象;
② 开始执行全局代码,从上往下依次给全局属性赋值(给 message 属性赋值为 global):
③ 执行到 foo 函数调用,准备执行 foo 函数前,创建 foo 函数的 AO:bar 函数存储空间中指定父级作用域为 foo 函数的 AO;
④ 创建 foo 函数的 FEC,并加入到 ECS 中,然后开始执行 foo 函数体内的代码:根据 foo 函数调用的传参,给形参 m 赋值为 30,接着给 message 属性赋值为 foo;所以,m 打印结果为 30;
⑤ 执行到 bar 函数调用,准备执行 bar 函数前,创建 bar 函数的 AO:bar 函数中没有定义属性和声明函数,以空对象表示;
⑥ 创建 bar 函数的 FEC,并加入到 ECS 中,然后开始执行 bar 函数体内的代码:执行 console.log(message)
,会先去 bar 函数自己的 VO 中找 message,没有找到就往上层作用域的 VO 中找;这里 bar 函数的父级作用域为 foo 函数,所以找到 foo 函数 VO 中的 message 为 foo,打印结果为 foo;
⑦ 全局中所有代码执行完成,bar 函数执行上下文出栈,bar 函数 AO 对象失去了引用,进行销毁。接着 foo 函数执行上下文出栈,foo 函数 AO 对象失去了引用,进行销毁,同样,foo 函数 AO 对象销毁后,bar 函数的存储空间也失去引用,进行销毁。
① 函数在执行前就已经确定了其父级作用域,与函数在哪执行没有关系,以函数声明的位置为主;
② 执行代码查找变量属性时,会沿着作用域链一层层往上查找(沿着 VO 往上找),如果一直找到全局对象中还没有该变量属性,就会报错未定义;
③ 上文中提到了很多概念名词,下面来总结一下:
ECS 执行上下文栈(Execution Context Stack),也可称为调用栈,以栈的形式调用创建的执行上下文
GEC 全局执行上下文(Global Execution Context),在执行全局代码前创建
FEC 函数执行上下文(Functional Execution Context),在执行函数前创建
VO Variable Object,早期ECMA规范中的变量环境,对应Object
VE Variable Environment,最新ECMA规范中的变量环境,对应环境记录
GO 全局对象(Global Object),解析全局代码时创建,GEC中关联的VO就是GO
AO 函数对象(Activation Object),解析函数体代码时创建,FEC中关联的VO就是AO
在了解 JavaScript 的内存管理之前,可以先大致熟悉一下什么是内存管理,不管什么样的编程语言,在其代码执行的过程中都是需要为其分配内存的。不管什么样的编程语言,以及它用什么方式来管理内存,其内存的管理都具备以下的生命周期:
但是不同的编程语言对内存的申请和释放会有不同的实现,主要分为手动和自动管理内存:
通过上面对内存管理的简单介绍可以知道,JavaScript 是自动管理内存的,所以在我们编写 JavaScript 代码定义变量时就会为其分配内存。根据 JavaScript 不同的数据类型,会对其分配到不同的内存空间中,数据类型主要分为基本数据类型和复杂数据类型:对于基本数据类型的内存分配会在执行时,直接在栈空间中进行分配。基本数据类型(也称值类型):string、number、boolean、undefined、null、symbol;对于复杂数据类型的内存分配会在堆内存中开辟一块空间,变量引用其内存地址。复杂数据类型(也称引用类型):object、function、array;以下代码在内存结构中的表现形式如下:
var name = 'amo'
const age = 18
const info = {
name: 'jerry',
age: 30
}
图示:
在管理内存的生命周期中是包括内存的释放,因为我们的内存大小是有限的,所以当代码执行完毕,不再需要内存的时候,那么就需要对其进行内存释放,以便腾出更多的内存空间给其它的应用程序使用。而在手动管理内存的编程语言中,需要自己通过一些方式来释放不再需要的内存,这样就需要编写专门用于管理内存的代码,不仅影响编写代码的效率,管理不当也有可能产生内存泄露。所以大部分现代的编程语言都是有自己的垃圾回收机制的,那么什么是垃圾回收机制?垃圾回收(Garbage Collection,简称 GC), 就是对于那些不再使用的数据,都可以称之为垃圾,需要通过回收来释放内存空间;在 JavaScript 的运行环境 JS 引擎中就存在垃圾回收的功能模块,这个功能模块就称为垃圾回收器;那么这里就可以提出一个疑问,GC 是如何找到不再使用的数据,并对其进行内存回收呢?这里就用到了 GC 算法,下面介绍两种常见的 GC 算法;
什么是引用计数?当一个对象有一个引用指向它时,那么这个对象的引用就加1,并且将其引用次数保存起来,而当一个对象的引用为 0
时,那么这个对象就可以被销毁了(回收)。示例代码:
let person1 = {name: 'amo'} // person1的引用次数为3
let person2 = {
name: 'jerry',
friend: person1
}
let person3 = {
name: 'bob',
friend: person1
} //person2和person3的引用次数为1
内存表现:
如果接着执行 person3 = null,那么 person3 的引用指向次数就会减1,变为0,从而销毁。而 person3 销毁后 person1 也会失去 person3 的指向,引用指向次数也会减1,变为2。缺点: 但是引用计数这个 GC 算法,存在一个很大的弊端,就是当出现循环引用时,就无法进行正确的回收,导致内存泄露,如下示例代码:
//amo的好朋友是jerry,巧合的是jerry的好朋友是amo,这样就出现了对象的循环引用
let person1 = {
name: 'amo',
friend: person2
}
let person2 = {
name: 'jerry',
friend: person1
}
内存表现:
即使执行 person1 = null;person2 = null
,person1 和 person2 对象的引用次数依然为1;所以引用计数就无法很好的处理这种情况了;
什么是标记清除?这个算法设置了一个根对象(root object),GC 会定期从这个根对象开始往下查找有引用到的对象,而对于那些没有引用到的对象,也就是没有查找到的对象,就认为是需要进行回收的对象。标记清除的一大优势就是可以很好的解决循环引用的问题,如下图:
标记清除算法首先会从 root object 往下开始查找引用到的对象;而对于 object6 和 object7 进行了循环引用了的对象,是查找不到的,就会被视为回收对象,从而被 GC 回收;目前的 JavaScript 引擎的 GC 核心采用的比较多的算法就是标记清除,类似于 V8 引擎不单单只是用了标记清除,同时也结合了一些其它的算法来应对更多的情况。