1 浏览器工作原理
1.1 JS代码在浏览器中如何运行?
当我们输入一个网站域名,浏览器会经过DNS域名解析,转换成对应服务器的IP地址。一般情况下,服务器会返回一个index.html的网页,然后浏览器会开始解析。解析html过程中可能会遇到link标签,会去服务器下载css文件;遇到script标签就会去服务器把对应的JS文件下载下来。
下载之后就会在浏览器执行该JS代码。
1.2 浏览器是如何渲染页面的?
(首先,不同浏览器的内核不一样,渲染过程也会存在差异。)
首先请求到html之后,会对里面的标签进行解析。通过浏览器内核里的HTML Parser将html转成DOM树。如果在这个过程中JS代码存在DOM操作,会由JS引擎去执行。
另外,CSS文件会由CSS Parser进行解析,其中的CSS规则会附加到DOM树里,生成一个叫做Render Tree的渲染树。
然后通过Layout再进行一些具体操作,(结合浏览器当前的状态)生成布局。
最后绘制在屏幕上展示出来。
1.3 什么是V8引擎?介绍V8引擎的工作原理。
定义:
V8引擎是用C ++编写的Google开源高性能JavaScript和WebAssembly引擎。因为JS代码最终在CPU里要转成机器指令执行,但是CPU只认识自己的指令集,所以需要通过JS引擎来进行翻译。
原理:
V8引擎主要有三个核心模块:Parse、Ignition、TurboFan模块。
Parse模块:将JS代码转换成AST(抽象语法树)。该过程主要对JavaScript源代码进行词法分析和语法分析;
词法分析:对代码中的每一个词或符号进行解析,最终会生成很多tokens(一个数组,里面包含很多对象);
const name = "ywj" // 首先对const进行解析,因为const为一个关键字,所以类型会被记为一个关键词,值为const tokens: [ { type: 'keyword', value: 'const' } ] // 接着对name进行解析,因为name为一个标识符,所以类型会被记为一个标识符,值为name tokens: [ { type: 'keyword', value: 'const' }, { type: 'identifier', value: 'name' } ] ...
语法分析:
在词法分析的基础上,拿到tokens中的对象,根据它们不同的类型进一步分析具体语法,最终生成AST;
Ignition模块:一个解释器,可以将AST转换成ByteCode(字节码)。
字节码(Byte-code):是一种包含执行程序,由一序列 op 代码/数据对组成的二进制文件,是一种中间码。
将JS代码转成AST是便于引擎对其进行操作,前面说到JS代码最终是转成机器码给CPU执行的,为什么还要先转换成字节码呢?
- 因为JS运行所处的环境是不一定的,可能是windows或Linux或iOS,不同的操作系统其CPU所能识别的机器指令也是不一样的。字节码是一种中间码,本身就有跨平台的特性,然后V8引擎再根据当前所处的环境将字节码编译成对应的机器指令给当前环境的CPU执行。
TurboFan模块:一个编译器,可以将字节码编译为CPU认识的机器码。
- 如果每执行一次代码,就要先将AST转成字节码然后再解析成机器指令,会损耗性能
- TurboFan可以获取到Ignition收集的一些信息,如果一个函数在代码中被多次调用,那么就会被标记为热点函数,然后经过TurboFan转换成优化的机器码,再次执行该函数的时候就直接执行该机器码,提高代码的执行性能;
- 图中还存在一个
Deoptimization
过程,就是机器码被还原成ByteCode,比如,在后续执行代码的过程中传入热点函数的参数类型发生了变化(如果给sum函数传入number类型的参数,那么就是做加法;如果给sum函数传入String类型的参数,那么就是做字符串拼接),可能之前优化的机器码就不能满足需求了,就会逆向转成字节码,字节码再编译成正确的机器码进行执行; - 从这里就可以发现,如果在编写代码时给函数传递固定类型的参数,是可以从一定程度上优化我们代码执行效率的,所以TypeScript编译出来的JavaScript代码的性能是比较好的
1.4 Parse模块的具体执行原理?
Blink内核将JS代码以Stream(流)的方式传递给V8引擎;
②Stream获取到JS源码进行编码转换;
③Scanner进行词法分析,将代码转换成tokens;
④经过语法分析后,tokens会被转换成AST,中间会经过Parser和PreParser过程:
- Parser:直接解析,将tokens转成AST树;
- PreParser:预解析(为什么需要预解析?)
- 因为并不是所有的JS代码在一开始时就会执行,如果直接对所有JavaScript代码进行解析会影响性能,所以V8就实现了Lazy Parsing(延迟解析)方案,对不必要的函数代码进行预解析,也就是先解析直接需要执行的代码内容,对函数的全量解析在函数调用时进行。
⑤生成AST后,会被Ignition转换成字节码,然后转成机器码,最后进行代码的执行;
1.5 JS的执行过程
1、初始化全局对象
JS引擎会在执行代码之前,在parser转成AST的过程中,会在堆内存创建一个全局对象:Global Object(简称GO),将全局定义的变量、函数等加入到GlobalObject中, 但是并不会赋值; 这个过程称之为变量的作用域提升(hoisting) : 变量已定义,初始值为undefined
var GlobalObject = {
Math: '类',
Date: '类',
String: '类',
setTimeout: '函数',
setInterval: '函数',
window: GlobalObject, // 指向自己
...
// 自定义变量
name: undefined,
message: undefined,
num: undefined
}
特点:
- 该对象所有的作用域(scope)都可以访问;
- 里面会包含Date、Array、String、Number、setTimeout、setInterval等等;
- 其中还有一个window属性指向自己;
2、执行上下文栈(ECS)
JS引擎为了执行代码,引擎内部会有一个执行上下文栈(Execution Context Stack,简称ECS),它是用来执行代码的调用栈。
3、 全局执行上下文(GEC)
ECS首先会执行全局代码,在执行全局代码前会构建一个全局执行上下文(Global Execution Context,简称GEC),一开始GEC就会被放入到ECS中执行。
GEC内部创建一个VO(Variable Object),对GEC来说,VO指向的就是GO。执行代码时,通过VO找到GO,将定义的变量值由undefined转为实际值。
4、函数执行上下文(FEC)
若定义了函数,JS引擎会另外开辟一个空间来存储函数,该空间内包含父级作用域[[scope]]:parent scope、函数执行体(代码块),而函数名foo指向该内存空间的地址。
当函数执行时,会在执行上下文栈(ECS)创建一个函数执行上下文(FEC),内部创建一个VO,包含三部分内容:
- VO:指向堆内存创建的AO(Activation Object),在里面定义形参和函数内变量;
- 作用域链:由**函数VO(即AO)和父级作用域(空间中的parent scope)**组成;
- this指向:this绑定的值,在函数执行时确定;
一旦函数执行完毕,函数执行上下文就会从ECS中出栈。此时堆内存中的AO没有指向,就会被销毁。
- 最新的ECMAScript规范中,VO改名为VE。每一个执行上下文关联到一个变量环境(VE)中,且不再规定VE必须为Object。
1.6 JS的内存管理?简述JS的垃圾回收机制
内存管理
内存管理主要分为三个生命周期:申请内存、使用内存、释放内存。JS是自动管理内存的,不同的数据类型会分配到不同的内存空间。
基本数据类型(也称值类型)直接在栈空间中进行分配:string、number、boolean、undefined、null、symbol;
复杂数据类型(也称引用类型)会在堆内存中开辟一块空间,变量引用其内存地址:object、function、array;
垃圾回收机制(不够)
管理内存的生命周期包括内存的释放,因为内存的大小有限,所以当代码执行完、不再需要内存的时候,需要被回收,以释放更多的内存空间。JS引擎中进行垃圾回收的模块称为垃圾回收器(GC)。
GC算法
引用计数
当一个对象有一个引用指向它时,给这个对象的引用加1,并且将其引用次数保存起来,当一个对象的引用变为0时,那么这个对象就可以被销毁了(回收)。
弊端:
1)当出现循环引用时,就无法进行正确的回收,导致内存泄露。
标记清除(JS引擎广泛采用,实际会更复杂)
设置一个根对象(root object),GC会定期从这个根对象开始往下查找有引用到的对象,对于那些没有引用到的对象,就认为是需要进行回收的对象。
弊端:
1)由于剩余对象的内存位置不变,会出现内存碎片化问题,进而影响内存分配速度。
标记整理
在标记清除算法的基础上,将剩余对象向内存的一端移动,最后清理掉边界的内存。
2 JavaScript
2.1 数组中的函数使用
filter
map
forEach
find
findIndex
reduce
2.2 闭包?怎么检测内存泄露的问题,什么情况会导致内存泄露
定义:一个函数,如果它可以访问外层作用域的自由变量,那么这个函数就是一个闭包;闭包可以在一个内层函数中访问外层函数的作用域。
理解:外层函数执行完毕后,本应回收,但其AO对象中有个指向内层函数的引用。所以内层函数在执行时,未找到自身AO中的变量,故向父作用域寻找变量,即外层函数的AO对象。
闭包的内存泄漏
如果内层函数只执行一次就不再使用,保存AO对象和内层函数对象就没有意义,但不会销毁造成了 内存泄漏。
解决方法:将指向内层函数的变量置为null(本质的内部实现为指向一个固定代表null的内部地址)