1. [ 语言特性 ]
-
[ 语言等级 ] 将偏向硬件的语言,称为低级语言,而偏向人类简单理解的语言,称为高级语言。最低级的语言就是机器语言,最高级的语言就是人类的语言。 越高级的语言越容易被人类理解,相对的其需要转化为机器能理解语言才能执行,从而越高级的语言越难转化,执行效率差。高级语言需要编译成更低级的语言才能够被机械使用。 越低级的语言越容易被机械执行,相对的其更难被人类理解,没有便捷的语法特性,从而开发效率慢。并且越低级的语言,其对机器的指令越直接,没有中间过程,更容易产生错误,例如难以内存管理,经常产生内存泄漏。因为低级语言是直接对接机器,其也捷荣总多cup平台。 语言等级:JavaScript/Ruby/Java > Go > C++/Rust > Assembler > 机械语言。
-
[ 设计缺陷 ] JavaScript设计仅用时十天,其结合函数式编程与面向对象编程两者的特点,因为其设计时间短,导致其设计上不够严谨,导致其有三位一体等设计缺陷。
-
[ 动态类型 ] js在声明变量时不关心变量的类型,而C++在声明变量时需要指定变量的类型,且不再更改变量的类型。 js动态类型特性对于开发者来说减少了心智负担,但也因此导致js不运行无法提前得知变量的类型到底是什么,从而js在运行前编译的效率低下。因此js相对C++这样的语言,编译效率更低。
-
[ JIT ] 因为js编译效率低下,因此如果js在完整编译后再执行,其执行效率则很低下,因此Just In Time Compilation,运行时编译的方案被提出,简称JIT。 JIT将js的运行与编译并行进行,而不是编译完全后再运行。因此对于首次运行的js代码相对执行效率会更低,而在首次运行后,再次调用,会直接使用首次运行编译过程产生的机械代码,而不用再编译。 与JIT相对的方式则是AOT,Ahead Of Time,即在代码完整编译得到完整机械代码后执行机械代码。
-
[ 执行引擎 ]
-
[ 概念 ] JavaScript执行引擎用于完成JavaScript的JIT过程。js执行引擎有多种,谷歌Chrome使用的V8引擎是较为常用的,其中还有Webkit使用的JavaScriptCore,Firefox的SpiderMonkey。以及近期出现的QuickJS,和在React Native中使用的Hermes。
-
[ 过程 ]
-
[ 解析 ] js经过解析器(parser),解析得到AST,即抽象语法处。抽象语法处记录了js的基本信息,例如记录了从第几行到第几行是一个函数,第几行到第几行是一段程序,第几行到第几行是变量声明等等
-
[ 解释 ] 解释器(interpreter)将抽象语法处与js代码结合,解释得到字节码(bytecode)。字节码能够在不同的操作系统上运行,与平台无关,但其还不是最终的机器代码。
-
[ 编译 ] 字节码经过编译器(compiler)生成得到最终的机器代码(machine code),这个过程与平台有关,会根据不同的CPU平台得到不同的机器代码。这里的机器代码其实就是汇编代码。
-
-
-
[ 差异 ] 上述过程在不同的执行引擎中存在差异,例如V8的5.9版本之前,没有解释过程,直接在解析得到抽象语法处后进行编译得到机械代码。
2.[ V8引擎 ]
-
[ 职能 ] V8引擎的只能包括:执行js代码、处理调用栈、内存分配、垃圾回收。
-
[ 早期V8引擎执行过程 ]
-
[ 概念 ] 早期的V8引擎其js代码执行过程仅有解析和编译两个过程,并没有解释过程。
-
[ 角色 ] 在js代码执行过程中共有解析器,解释器,基准编译器(Full-codegen),分析线程,优化编译器(Crankshaft)。
-
[ 过程 ]
-
[ 解析编译 ] js代码通过解析器,解析为抽象语法处,抽象语法处经过基准编译器得到机械代码。 在基准编译器的编译过程中,分析线程会分析js代码的可优化空间,并将优化信息告知优化编译器。
-
[ 优化 ] 如果优化编译器决定进行优化,那么优化编译器会优化js代码,优化后的js代码会通过解析器,得到优化后的抽象语法处,优化后的抽象语法处,通过优化编译器进行编译,最终得到优化后的机械代码。 因为优化后的机械代码是在首次运行后产生,所以只会在js代码二次调用时才会使用优化后的机械代码。
-
-
[ 缺陷 ]
-
[ 占用内存 ] 这个时期的V8引擎对应的设备有两种,大内存的PC与早期低内存的手机,而js代码单次执行得到两份机械代码,一份优化一份未优化,是占用大量内存空间的。特别是当一份js代码,仅执行一次,此时二次优化编译过程就是没有必要的。
-
[ 缺少中间层 ] 因为从抽象语法处直接编译得到了机械代码,因为抽象语法处不能优化,而如果要优化代码,只能对js代码进行优化,如果要进行中间过程的优化,则没有优化的空间。
-
[ 不支持新语法特性 ] js的新语法特性旧的V8引擎架构无法很好支持。
-
-
-
[ 现代V8引擎执行过程 ]
-
[ 概念 ] 这个时期的V8引擎就是经过解析,解释,编译三个过程,将js代码转换为机械代码。
-
[ 角色 ] 解析器、基准解释器(Ignition)、优化编译器(TurboFan)。
-
[ 过程 ]
-
[ 解析 ] js代码经过解析器得到抽象语法处。
-
[ 解释 ] 抽象语法处经过基准解释器,得到字节码。在得到字节码后,则会删除解析过程产生的抽象语法处,以回收内存空间。
-
[ 首轮运行 ] 生成的字节码会被基准解释器运行,并在运行过程中收集优化信息,包括变量类型信息,高频执行函数等等,优化信息会被传递给优化编译器。 该次执行相当于早期V8引擎的首轮执行,但并不会完整的执行。例如一个函数需要被调用多次,该函数的首次调用是字节码经过基准解释器执行,而下次调用则会是优化后的机械代码直接执行,因此基准解释器不会完整的执行函数的全部调用。
-
[ 编译 ] 优化编译器得到基准解释器的优化信息后,会根据优化信息,对字节码进行优化,并将优化后的字节码生成为机械代码,并执行。
-
-
[ 优化策略 ]
-
[ 仅声明未调用的函数 ] js代码中仅声明未调用的函数不会被解析生成抽象语法处。
-
[ 单次调用的函数 ] 单次调用的函数在经过基准编译器执行后,便不会在进行优化,优化编译器会将其无视,因此其是以字节码方式被执行,而非机械码。
-
[ 热点函数 ] 函数只会在多次调用的情况下,被基准解释器在优化信息收集过程中,标记为热点函数,才会被优化编译器优化为机械码。
-
[ 逆向 ] 受JavaScript动态类型特性影响,有可能得到的机械码是存在问题的,在这种情况机械码会逆向(deoptimization)为字节码。 例如一个add函数实现两数相加,在解释过程得到字节码后,基准解释器有预见的发现,该函数需要多次执行,为热点函数,于是进行优化信息收集。在执行字节码过程,多次执行该函数,发现其参数都是int,于是优化编译器得到该函数的参数类型信息为int,而在某次执行中,add函数的参数变为float或者是string,此时机械码已经将函数的参数类型锁死为int,因此需要逆向为字节码进行运行。
-
-
[ 混合执行 ] 在了解现代V8引擎执行过程后,就能够得知,js代码最后执行时,部分为字节码进行执行,部分为机械码进行执行,因此js代码执行时的产物是混合的。
-
3.[ 调用栈 ]
-
[ 概念 ] 调用栈是js引擎追踪函数执行流程的机制,通过调用栈机制,js引擎能够得知哪个函数正在执行,及函数间的相互调用。主线程根据调用栈,自顶向下执行。
-
[ 原理 ]
const add = (a,b) => { return a + b } const getNumber = (a,b) => { const n = add(a,b) const x = n*n const y = n+n return add(x,y) } const a = 2 const b = 4 const number = getNumber(a,b) console.log(number)调用栈伴随js代码的顺序执行过程。因为调用栈只捕捉函数调用,因此声明不会被调用栈捕捉。 上述代码执行过程伴随的调用栈:
- add函数被声明 - 调用栈无动作 - getNumber函数被声明 - 调用栈无动作 - 常量a被声明 - 调用栈无动作 - 常量b被声明 - 调用栈无动作 - 常量number声明需要执行getNumber - getNumber函数入栈 - 局部常量n声明需要执行add - add函数入栈 - 执行add函数返回a+b - add函数执行完毕出栈 - 局部常量x声明 - 调用栈无动作 - 局部常量y声明 - 调用栈无动作 - 返回值需要执行add - add函数入栈 - 执行add函数返回x+y - add函数出栈 - getNumber函数执行完毕 - getNumber函数出栈 - 需要执行console.log - console.log入栈 - 执行console.log - console.log出栈 - 顺序执行完毕上述过程可以发现,js代码的顺序执行过程中,发现需要执行的函数,函数则会入调用栈,函数执行完毕后,则会出调用栈。通过栈顶的函数便能够得知当前正在执行的函数,通过栈内函数的层级关系,便能得知函数的上下文关系。
-
[ 栈溢出 ] 递归是一种函数内重复执行函数的代码结构,该结构会呈现出调用栈在执行前只入不出,而执行后开始逐个出栈的特点,规模过大的递归会导致在调用栈只入不出的过程中溢出的问题,引发网页卡死。
-
[ 单线程单栈 ] JavaScript是单线程执行的,因此调用栈仅有一个。
-
-
[ 单线程 ] JavaScript执行中是单线程进行执行,在大多数场合中认为多线程是更优的,但JavaScript单线程有其必要性。 如果页面上有一个背景色红黄蓝切换的效果,单线程只需要进行背景色红,背景色黄,背景色蓝这个过程即可,而如果是多线程,就要考虑某个线程执行背景色红的过程,其他线程不能执行背景色变更,这样是很繁琐的。 因此在JavaScript的应用场景,单线程是更加适用的。这时就伴随着异步任务与回调任务的任务队列如何安排的问题。
4. [ 任务队列 ]
-
[ 概念 ] 主线程会执行调用栈栈顶的函数,而函数是伴随单线程顺序执行过程,需要执行时入栈。这样会产生一个问题,例如promise,setTimeout等异步任务,要怎么决定其执行时机。 因此需要由队列去决定函数的执行顺序。
-
[ 异步任务队列 ]
-
[ 概念 ] 同步任务与异步任务的出栈后是否执行是有差别的。 同步任务出栈后直接进入主线程执行。 异步任务出栈后将进入异步任务队列,等待调用栈清空后,进入主线程执行。 异步任务队列由分两种,宏任务队列与微任务队列,promise属于异步任务微任务,setTimeout属于异步任务宏任务。 因此在调用栈清空,异步任务可以执行时,优先让微任务队列中的任务执行,再让宏任务队列中的任务执行。
-
[ Event Loop ] 对于出栈后任务的执行顺序,遵循 同步任务 -> 微任务 -> 宏任务 的轮询循环,这种循环被称作Event Loop。
setTimeout(()=>{ console.log('1') }) const data = new Promise((resolve)=>{ resolve('2') }) data.then((res) => { console.log(res) }) console.log('3')上述代码中,setTimeout属于异步任务宏任务队列,promise属于异步任务微任务队列,console.log属于同步任务。 执行过程:
- setTimeout入栈出栈 - setTimeout出栈不执行进入宏任务队列
- new Promise入栈
- promise初始化函数入栈
- resolve入栈出栈 - resolve出栈不执行进入微任务队列
- promise初始化函数出栈
- promise初始化函数入栈
- new Promise出栈
- console.log('3')入栈出栈 - 输出3
- 调用栈清空,主线程执行微任务队列
- resolve入栈出栈 - 执行resolve
- .then入栈出栈 - 输出res,res参数值为2,输出2
- 微任务队列清空,主线程执行宏任务队列
- setTimeout入栈出栈 - 输出1
- 结束
由上述过程可以看到,即便是异步任务执行过程,也会遵循Event Loop,该异步任务内也会做同步任务与异步任务的划分。
-
[ queueMicrotask() ] 该函数是window对象提供的API,其可以使用setTimeout相同的语法创建微任务。
5.[ 内存管理 ]
-
[ 概念 ] 内存管理是每种语言的无法脱离的任务。 对于偏低级的语言,例如C语言,可以通过malloc()和free()这样的内存管理函数,来进行内存管理,这种方式虽然增大了开发者的心智负担,但让开发者更加清楚的知道程序的细节。 而JavaScript的内存管理由其引擎进行自动管理,但并不意味着开发者可以不关心内存问题。
-
[ 内存生命周期 ] 无论哪种语言,其内存管理都无法脱离这一周期: 内存分配(Allocate memory) -> 内存使用(Use memory) -> 内存释放(Release memory)。 所有内存管理都是由操作系统完成,但可以通过较低级的语言对操作系统的内存管理工作进行操作。
-
[ 内存分配 ]
-
[ 静态分配 ] 对于仅读的变量,其在声明时,其值就已经确定,且不再更改,因此其在内存中的大小便是固定的,这种变量将会被分配在栈中存储。 仅读变量存储于栈中。
-
[ 动态分配 ] 变量,函数,对象在声明时,不确保其值在未来不会被改变的,因此其内存的大小便是不固定的,这样的内容将会被分配在堆中存储。 虽然变量,函数,对象的内容是不固定的,但是其引用是固定的,所以其引用的内存大小便是固定的,因此变量,函数,对象的引用被存储在栈中。 变量,函数,对象的引用存储于栈中,内容存储于堆中。
-
[ 堆与栈的特点 ] 栈是有序的,堆是无序的。 因为栈的有序性,所以其能更好的管理内存大小固定的内容,而堆的无序性,可以更便捷的进行内存大小的变更。
-
[ 浅拷贝 ] 因为静态内容在栈中存储,而动态内容在堆中存储,对于静态内容的复制拷贝是在栈中新存储一份,而对于动态内容的复制拷贝,则是复制动态内容的引用,因此动态内容的复制拷贝,仅复制了引用,并没有复制内容,两者都引用堆中同一块内存。
const user = { name: 'Ogas' age: 18 } const ogas = user user.age = 0 console.log(ogas.age)
因此有典型的案例: user为一个对象,ogas是user对象的拷贝,当修改user的age属性值,ogas的age属性值也会被改动,因为其ogas与user都是同一个对象的引用。
-
-
[ 内存释放 ]
-
[ 概念 ] 内存释放的核心逻辑就是确定内存是否在未来不再使用。 对于像C语言这样的语言,内存管理需要开发者去操作完成,因此确定内存是否在未来不再使用,是由开发者确定的,而且也便于确定。 但对于JavaScript高级语言,其内存管理并不由开发者去操作完成,因此需要有垃圾回收器来判断,内存是否在未来不再使用,这是难以准确判断的。
-
[ 引用计数算法 ]
- [ 概念 ] 引用计数是垃圾回收器的判定内存是否可回收的机制之一,当一个动态内容的引用由非零变为零时,则会被判断为垃圾内存。
var o1 = { o2: { x: 1 } } var o3 = o1 o1 = 1 var o4 = o3.o2 o3 = '1' o4 = '1'{ x: 1 } { o2:{ x:1 } }上述代码,注意观察具有属性x的对象,这里称为对象x,同时观察具有属性o2的对象,这里称为对象y。 在o1声明时,堆中分配对象y,对象x,此时对象y被o1引用,对象x被o2引用。 在o3声明时,对象y被o1,o3引用,对象x被o2。 在o1写值时,对象y被o3引用,对象x被o2引用。 当o4声明时,对象y被o3引用,对象x被o4,o2引用。 当o3写值时,对象y不被引用,对象x被o4引用,此时对象y的引用由非零变为零,对象y在堆中的内存空间被清除。 当o4写值时,对象x不被引用,对象x在堆中的内存空间被清除。
- [ 循环引用内存泄漏 ] 引用计数能够解决常规情况的内存回收,但其有不适用的情况。
const fn() = () => { var o1 = {} var o2 = {} o1.p = o2 o2.p = o1 } fn()函数在调用后,则其内部的内容应该被回收。 上述代码,在fn函数调用后,其内部的o1,o2应该被清空,但因为引用计数算法,导致其成为内存泄漏。 o1的属性引用了o2,o2的属性引用了o1。 在这种情况,o1被o2引用,o2被o1引用,其引用计数并没有归零,因此引用计数算法认为,o1与o2的内存是不可清除的,从而造成了内存泄漏。
-
[ 标记扫描算法 ]
- [ 概念 ] 在JavaScript中,所有的变量对象都会被挂载到window对象上,因此形成了window对象这样的根节点。 而标记扫描算法,会扫描根节点及其所有的子节点,任何以根节点为衍生的变量不能访问到的内存内容,都会被视为垃圾内容。 目前的垃圾回收器都是基于标记扫描算法。
-
6.[ 深拷贝 ]
-
[ 概念 ] 受引用数据类型的内存管理所印象,对引用数据类型的拷贝仅能实现对其引用的拷贝,拷贝的变量与拷贝前的变量指向同一块堆中的内存空间。仅拷贝引用,不拷贝内容的拷贝为浅拷贝。而与之相反的便是深拷贝。
-
[ JSON.stringify()深拷贝 ]
- [ 概念 ] JSON对象提供了.stringify()与.parse()两个API,如其API的名称含义一样,.stringify()的作用是将js的数据类型转化为JSON格式的字符串,而.parse()的作用是将JSON格式的字符串转化为对应js的数据类型。
const ogas = { name: 'Ogas', age: 18 } const ogas2 = JSON.parse(JSON.stringify(ogas))通过将js引用数据类型,转化为JSON格式字符串再转换为js数据类型的方式,能够实现对引用数据类型的深拷贝。 但这种方式不适用于过于复杂的引用数据类型深拷贝,因为其有很多局限性。
-
[ 时间类格式错误 ] 在js中有各种时间类,这些时间类在经过JSON转换的方式实现深拷贝后,会被直接转换为字符串,因此其由对象类型变为了字符串类型,挂载在原型上的.getTime()等API都无法使用。 因此属性中含有时间类型的对象不能使用该方式拷贝。
-
[ undefined与function的数据丢失 ] 如果拷贝一个属性中含有undefined或function的对象,其在进行拷贝后会直接丢失。
-
[ NaN、Infinity的错误 ] 如果拷贝一个属性中含有NaN与Infinity的对象,其属性在进行拷贝后会变为null。
-
[ 适用场景 ] 适用于属性不包含时间,undefined,function,NaN,Infinity的对象深拷贝。
-
[ 递归拷贝 ]
- [ 原理 ] 递归拷贝的方式是针对对象与数组的拷贝方式。 其是通过for in 遍历结合递归实现。
const copy = (target) => { let new = null if(typeof (target) !== 'object' || obj === null){ new = target }else { new = target instanceof Array ? [] : {} for( let i in target ){ new[i] = copy(target[i]) } } return new }copy函数为深拷贝函数,其接受的参数target为拷贝目标。 置空一个容器,用于存储拷贝后得到的变量。 判断拷贝目标是否为对象,如果不是对象,则其为非引用数据类型,直接通过赋值方式拷贝。 如果是对象,判断拷贝目标是否为数组,为数组则初始化容器为空数组; 拷贝目标非数组则为对象,则初始化容器为空对象。 for...in遍历拷贝目标,将其属性逐一拷贝至容器; 如果拷贝目标的属性为对象,其也会通过递归方式拷贝其属性。
- [ 缺陷 ] 这种方式实现的深拷贝因为使用到了递归,对于多层对象可能会触发调用栈溢出。因此这种方式是基础实现思路,其还有完事的空间。