1. JavaScript 的内存管理
不管是什么样的计算机程序语言,运行在对应的代码引擎上,对应的使用内存过程大致逻辑是一样的,可以分为这三个步骤:
- 分配你所需要的系统内存空间;
- 使用分配到的内存进行读或者写等操作;
- 不需要使用内存时,将其空间释放或者归还。
与其他需要手动管理内存的语言不太一样的地方是,在 JavaScript 中,当我们创建变量(对象,字符串等)的时候,系统会自动给对象分配对应的内存
var a = 123; // 给数值变量分配栈内存
var etf = "ARK"; // 给字符串分配栈内存
// 给对象及其包含的值分配堆内存
var obj = {
name: 'tom',
age: 13
};
// 给数组及其包含的值分配内存(就像对象一样)
var a = [1, null, "PSAC"];
// 给函数(可调用的对象)分配内存
function sum(a, b){
return a + b;
}
当系统经过一段时间发现这些变量不会再被使用的时候,会通过垃圾回收机制的方式来处理掉这些变量所占用的内存,其实开发者不用过多关心内存问题。即便是这样,在开发过程中也需要了解 JavaScript 的内存管理机制,这样才能避免一些不必要的问题,在 JavaScript 中数据类型分为两类:简单类型和引用类型。
对于简单的数据类型,内存是保存在栈(stack)空间中的;复杂数据类型,内存保存在堆(heap)空间中。简而言之,基本就是说明以下两点。
- 基本类型:这些类型在内存中会占据固定的内存空间,它们的值都保存在栈空间中,直接可以通过值来访问这些;
- 引用类型:由于引用类型值大小不固定(比如对象可以添加属性等),栈内存中存放地址指向堆内存中的对象,是通过引用来访问的。
因此总结来说:栈内存中的基本类型,可以通过操作系统直接处理;而堆内存中的引用类型,正是由于可以经常变化,大小不固定,因此需要 JavaScript 的引擎通过垃圾回收机制来处理。
1.1 Chrome 内存回收机制
在 Chrome 浏览器中,JavaScript 的 V8 引擎被限制了内存的使用,根据不同的操作系统(操作系统有 64 位和 32 位的)内存大小会不同,大的可以到 1.4G 的空间,小的只能到 0.7G 的空间。
为什么要去限制内存使用呢?大致是两个原因:
- V8 最开始是为浏览器而设计的引擎,早些年由于 Web 应用都比较简单,其实并未考虑占据过多的内存空间
- 另外又由于被 V8 的垃圾回收机制所限制,比如清理大量的内存时会耗费很多时间,这样会引起 JavaScript 执行的线程被挂起,会影响当前执行的页面应用的性能。
下面来看下 Chrome 的内存回收机制。Chrome 的 JavaScript 引擎 V8 将堆内存分为两类新生代的回收机制和老生代的回收机制
1.2 新生代内存回收
新生代的内存回收的空间,在 64 位操作系统下分配为 32MB,正是因为新生代中的变量存活时间短,不太容易产生太大的内存压力,因此不够大也是可以理解的。首先系统会将分配给新生代的内存空间分为两部分,如下图所示。
图中左边部分表示正在使用的内存空间,右边是目前闲置的内存空间。当浏览器开始进行内存的垃圾回收时,JavaScript 的 V8 引擎会将左边的对象检查一遍。如果引擎检测是存活对象,那么会复制到右边的内存空间去;如果不是存活的对象,则直接进行系统回收。当所有左边的内存里的对象没有了的时候,等再有新生代的对象产生时,上面的部分左右对调,这样来循环处理。
如果是顺序放置的那比较好处理,可以按照上面所说的处理方式。但是如果是下图这样零散的场景怎么处理呢?
图中橙色的块代表存活对象,白色地方代表未分配的内存。正常情况下,由于堆内存是连续分配的,但是也有可能出现上图的这种内存分配情况,这种零散的分配情况就造成了内存碎片,会影响比较大的内存对象的放置。
因此这里介绍一个算法 Scavenge,它主要就是解决上图中内存碎片的情况,在通过算法处理过后,内存中对象的排布都会变成下图这个排列方式。
进行这样的算法处理,明显会让内存排布变得更整齐了,这样就非常方便之后新来的对象的内存分配。
1.3 老生代内存回收
上面讲解了新生代的回收方式,那么新生代中的变量如果经过回收之后依然一直存在,那么就会被放入到老生代内存中。时间长了之后通过几个原因的判断,我们就会把这些变量进行 "晋升",只要是已经经历过一次 Scavenge 算法回收的,就可以晋升为老生代内存的对象。那么在进入老生代的内存回收机制中,就不能再用 Scavenge 的算法了。Scavenge 的算法是有其适用的场景,而对于内存空间比较大的,就不适合用 Scavenge 算法了。
那么老生代内存中的垃圾回收,是采用什么样的策略进行的呢?这里采用了 **Mark-Sweep(标记清除) 和 Mark-Compact(标记整理)**的策略,我们先来看下 Mark-Sweep 标记清除的策略。
标记清除(Mark-Sweep)
通过名字你就可以理解,标记清除分为两个阶段:标记阶段和清除阶段。
首先它会遍历堆上的所有的对象,分别对它们打上标记;然后在代码执行过程结束之后,对使用过的变量取消标记。那么没取消标记的就是没有使用过的变量,因此在清除阶段,就会把还有标记的进行整体清除,从而释放内存空间。
听起来这一切都比较完美,但是其实通过标记清除之后,还是会出现上面图中的内存碎片的问题。内存碎片多了之后,如果要新来一个较大的内存对象需要存储,会造成影响。对于通过标记清除产生的内存碎片,还是需要通过另外一种方式进行解决,因此这里就不得不提到标记整理策略(Mark-Compact)了。
标记整理(Mark-Compact)
经过标记清除策略调整之后,老生代的内存中因此产生了很多内存碎片,若不清理这些内存碎片,之后会对存储造成影响。
为了方便解决浏览器中的内存碎片问题,标记整理这个策略被提出。这个策略是在标记清除的基础上演进而来的,和标记清除来对比来看,标记整理添加了活动对象整理阶段,处理过程中会将所有的活动对象往一端靠拢,整体移动完成后,直接清理掉边界外的内存。其操作效果如下图所示。
可以看到,老生代内存的管理方式和新生代的内存管理方式区别还是比较大的:
- Scavenge 算法比较适合内存较小的情况处理;
- 对于老生代内存较大、变量较多的时候,还是需要采用“标记-清除”结合“标记-整理”这样的方式处理内存问题,并尽量避免内存碎片的产生。
那么以上就是内存的垃圾回收机制的内容了。最后再看看日常在开发中,应该注意哪些问题来避免内存泄漏,从而提升代码的可靠性。
1.4 内存泄漏与优化
平常用 JavaScript 开发代码,内存的泄漏和优化是应该经常留意的。内存泄漏是指 JavaScript 中,已经分配堆内存地址的对象由于长时间未释放或者无法释放,造成了长期占用内存,使内存浪费,最终会导致运行的应用响应速度变慢以及最终崩溃的情况。
内存泄漏的场景:
- 过多的缓存未释放
- 闭包太多未释放
- 定时器或者回调太多未释放
- 太多无效的 DOM 未释放
- 全局变量太多未被发现
这些现象会在开发或者使用中造成内存泄漏,以至于你的浏览器卡顿、不响应、页面打不开等问题产生。那么这些问题该怎么优化呢?我们来看下这些场景都需要注意点什么。
1.4.1 减少不必要的全局变量,使用严格模式避免意外创建全局变量:
function foo() {
// 全局变量=> window.bar
this.bar = '默认this指向全局';
// 没有声明变量,实际上是全局变量=>window.bar
bar = '全局变量';
}
foo();
这段代码中,函数内部绑定了太多的 this 变量,虽然第一眼看不出问题,但仔细一分析,其实 this 下的属性默认都是绑定到 window 上的属性,均为全局变量,这一点是非常有必要注意的。
1.4.2 在使用完数据后,及时解除引用(闭包中的变量,DOM 引用,定时器清除)
var someResource = getData();
setInterval(function() {
var node = document.getElementById('Node');
if(node) {
node.innerHTML = JSON.stringify(someResource));
// 定时器也没有清除,可以清除掉
}
// node、someResource 存储了大量数据,无法回收
}, 1000);
比如上面代码中就缺少清除 setInterval 的代码,类似这样的代码增多会造成内存的占用过多,这是同样也需要注意的一点。
1.4.3 组织好代码逻辑,避免死循环等造成浏览器卡顿、崩溃的问题
例如,对于一些比较占用内存的对象提供手工释放内存的方法:
var leakArray = [];
exports.clear = function () {
leakArray = [];
}
比如这段代码提供了清空该数组内容的方法,使用完成之后可以根据合适业务时机进行操作释放。这样就能较好地避免对象数据量太大造成的内存溢出的问题。
关于内存泄漏这部分,如果想更好地去排查以及提前避免问题的发生,最好的解决方式是通过熟练使用 Chrome 的内存剖析工具,多分析多定位 Chrome 帮你分析保留的内存快照,来查看持续占用大量内存的对象。最好在业务代码上线前做好分析和诊断,之后才能保证线上业务的质量。
2. 浏览器中的 Eventloop
Eventloop 是 JavaScript 引擎异步编程背后需要特别关注的知识点。JS 在单线程上执行所有操作,虽然是单线程,但是能够高效地解决问题,并能给我们带来一种“多线程”的错觉,这其实是通过使用一些比较合理的数据结构来达到此效果的。
2.1 调用堆栈(call stack)负责跟踪所有要执行的代码
每当一个函数执行完成时,就会从堆栈中弹出(pop)该执行完成函数;如果有代码需要进去执行的话,就进行 push 操作,如下图所示:
2.2.事件队列(event queue)负责将新的 function 发送到队列中进行处理
它遵循 queue 的数据结构特性,先进先出,在该顺序下发送所有操作以进行执行。如下图所示:
2.3. 每当调用事件队列中的异步函数时,都会将其发送到浏览器 API
根据从调用堆栈收到的命令,API 开始自己的单线程操作。其中 setTimeout 方法就是一个比较典型的例子,在堆栈中处理 setTimeout 操作时,会将其发送到相应的 API,该 API 一直等到指定的时间将此操作送回进行处理。它将操作发送到事件队列(event queue)。这样,就有了一个循环系统,用于在 JavaScript 中运行异步操作。
2.4. JavaScript 语言本身是单线程的,而浏览器 API 充当单独的线程
事件循环(Eventloop)促进了这一过程,它会不断检查调用堆栈是否为空。如果为空,则从事件队列中添加新的函数进入调用栈(call stack);如果不为空,则处理当前函数的调用。把整个过程串起来就是这样的一个循环执行流程,如下图所示:
通过上面这张图就能很清晰地看出调用栈、事件队列以及 Eventloop 和它们之间相互配合的关系。
简单来说 Eventloop 通过内部两个队列来实现 Event Queue 放进来的异步任务。以 setTimeout 为代表的任务被称为宏任务,放到宏任务队列(macrotask queue)中;而以 Promise 为代表的任务被称为微任务,放到微任务队列(microtask queue)中。
日常工作中经常遇到的哪些是宏任务,哪些是微任务,如下所示
macrotasks(宏任务):
script(整体代码),setTimeout,setInterval,setImmediate,I/O,UI rendering,event listner
microtasks(微任务):
process.nextTick, Promises, Object.observe, MutationObserver
把主要的宏任务和微任务都列了出来,其实 Eventloop 在处理宏任务和微任务的逻辑其实还是有些不一样的,执行的情况大致如下:
- JavaScript 引擎首先从宏任务队列(macrotask queue)中取出第一个任务
- 执行完毕后,再将微任务(microtask queue)中的所有任务取出,按照顺序分别全部执行(这里包括不仅指开始执行时队列里的微任务),如果在这一步过程中产生新的微任务,也需要执行;
- 然后再从宏任务队列中取下一个,执行完毕后,再次将 microtask queue 中的全部取出,循环往复,直到两个 queue 中的任务都取完。
总结起来就是:一次 Eventloop 循环会处理一个宏任务和所有这次循环中产生的微任务。
2.5. Node.js 的 Eventloop
关于在 Node.js 服务端 Eventloop,Node.js 官网是这么描述的:
When Node.js starts, it initializes the event loop, processes the provided input script (or drops into the REPL, which is not covered in this document) which may make async API calls, schedule timers, or call process.nextTick(), then begins processing the event loop.
简单翻译过来就是:当 Node.js 开始启动时,会初始化一个 Eventloop,处理输入的代码脚本,这些脚本会进行 API 异步调用,process.nextTick() 方法会开始处理事件循环。下面就是 Node.js 官网提供的 Eventloop 事件循环参考流程。
整个流程分为六个阶段,当这六个阶段执行完一次之后,才可以算得上执行了一次 Eventloop 的循环过程。我们来分别看下这六个阶段都做了哪些事情。
- Timers 阶段:这个阶段执行 setTimeout 和 setInterval
- I/O callbacks 阶段:这个阶段主要执行系统级别的回调函数,比如 TCP 连接失败的回调
- idle,prepare 阶段:只是 Node.js 内部闲置、准备,可以忽略
- poll 阶段:poll 阶段是一个重要且复杂的阶段,几乎所有 I/O 相关的回调,都在这个阶段执行(除了setTimeout、setInterval、setImmediate 以及一些因为 exception 意外关闭产生的回调),这个阶段的主要流程如下图所示。
- check 阶段:执行 setImmediate() 设定的 callbacks
- close callbacks 阶段:执行关闭请求的回调函数,比如 socket.on('close', ...)
除了把 Eventloop 的宏任务细分到不同阶段外。node 还引入了一个新的任务队列 Process.nextTick()。根据官方文档的解释:
process.nextTick()is not technically part of the event loop. Instead, thenextTickQueuewill be processed after the current operation is completed, regardless of the current phase of the event loop. Here, an operation is defined as a transition from the underlying C/C++ handler, and handling the JavaScript that needs to be executed.
可以认为,Process.nextTick() 会在上述各个阶段结束时,在进入下一个阶段之前立即执行(优先级甚至超过 microtask 队列)
Node.js 和浏览器端宏任务队列的另一个很重要的不同点是:
- 浏览器端任务队列每轮事件循环仅出队一个回调函数接着去执行微任务队列
- 而 Node.js 端只要轮到执行某个宏任务队列,则会执行完队列中所有的当前任务,但是当前轮次新添加到队尾的任务则会等到下一轮次才会执行
2.5.1 EventLoop 对渲染的影响
之前在业务开发中也遇到过 requestIdlecallback 和 requestAnimationFrame,当你开始考虑它们在 Eventloop 的生命周期的哪一步触发,或者这两个方法的回调会在微任务队列还是宏任务队列执行的时候,才发现好像没有想象中那么简单。这两个方法其实也并不属于 JS 的原生方法,而是浏览器宿主环境提供的方法,因为它们牵扯到另一个问题:渲染。
浏览器作为一个复杂的应用是多线程工作的,除了运行 JS 的线程外,还有渲染线程、定时器触发线程、HTTP 请求线程,等等。JS 线程可以读取并且修改 DOM,而渲染线程也需要读取 DOM,这是一个典型的多线程竞争临界资源的问题。所以浏览器就把这两个线程设计成互斥的,即同时只能有一个线程在执行。
渲染原本就不应该出现在 Eventloop 相关的知识体系里,但是因为 Eventloop 显然是在讨论 JS 如何运行的问题,而渲染则是浏览器另外一个线程的工作。但是 requestAnimationFrame 的出现却把这两件事情给关联起来,你可以看下 RAF 的英文解释:
requestAnimationFrame()method tells the browser that you wish to perform an animation and requests that the browser calls a specified function to update an animation before the next repaint.
通过调用 requestAnimationFrame 我们可以在下次渲染之前执行回调函数。那下次渲染具体是哪个时间点呢?渲染和 Eventloop 有什么关系呢?我在 HTML协议对 Eventloop 的规范 里找到了答案。简单来说,就是在每一次 Eventloop 的末尾,判断当前页面是否处于渲染时机,就是重新渲染。而这个所谓的渲染时机是这样定义的:
Rendering opportunities are determined based on hardware constraints such as display refresh rates and other factors such as page performance or whether the page is in the background. Rendering opportunities typically occur at regular intervals.
有屏幕的硬件限制,比如 60Hz 刷新率,简而言之就是 1 秒刷新了 60 次,16.6ms 刷新一次。这个时候浏览器的渲染间隔时间就没必要小于 16.6ms,因为就算渲染了屏幕上也看不到。当然浏览器也不能保证一定会每 16.6ms 会渲染一次,因为还会受到处理器的性能、JavaScript 执行效率等其他因素影响。
回到 requestAnimationFrame,这个 API 保证在下次浏览器渲染之前一定会被调用,实际上我们完全可以把它看成是一个高级版的 setInterval。它们都是在一段时间后执行回调,但是前者的间隔时间是由浏览器自己不断调整的,而后者只能由用户指定。这样的特性也决定了 requestAnimationFrame 更适合用来做针对每一帧来修改的动画效果。
当然 requestAnimationFrame 不是 Eventloop 里的宏任务,或者说它并不在 Eventloop 的生命周期里,只是浏览器又开放的一个在渲染之前发生的新的 hook。另外需要注意的是微任务的认知概念也需要更新,在执行 animation callback 时也有可能产生微任务(比如 promise 的 callback),会放到 animation queue 处理完后再执行。所以微任务并不是像之前说的那样在每一轮 Eventloop 后处理,而是在 JS 的函数调用栈清空后处理。
但是 requestIdlecallback 却是一个更好理解的概念。当宏任务队列中没有任务可以处理时,浏览器可能存在“空闲状态”。这段空闲时间可以被 requestIdlecallback 利用起来执行一些优先级不高、不必立即执行的任务,如下图所示:
当然为了防止浏览器一直处于繁忙状态,导致 requestIdlecallback 可能永远无法执行回调,它还提供了一个额外的 timeout 参数,为这个任务设置一个截止时间。浏览器就可以根据这个截止时间规划这个任务的执行。
3.JS 代码是如何被浏览器引擎编译、执行的
3.1. V8 引擎介绍
编程语言主要分为编译型语言和解释型语言:
- 编译型语言的特点是在代码运行前编译器直接将对应的代码转换成机器码,运行时不需要再重新翻译,直接可以使用编译后的结果。
- 解释型语言也是需要将代码转换成机器码,但是和编译型的区别在于运行时需要转换。比较显著的特点是,解释型语言的执行速度要慢于编译型语言,因为解释型语言每次执行都需要把源码转换一次才能执行。
像 Java 和 C++ 都是编译型语言;而 JavaScript 和 ruby 都是解释性语言,它们整体的执行速度都会略慢于编译型的语言。
为了提高运行效率,很多浏览器厂商在也在不断努力。目前市面上有很多种 JS 引擎,例如 JavaScriptCore、chakra、V8 等。而比较现代的 JS 引擎,当数 V8,它引入了 Java 虚拟机和 C++ 编译器的众多技术,和早期的 JS 引擎工作方式已经有了很大的不同。
V8 是众多浏览器的 JS 引擎中性能表现最好的一个,并且它是 Chrome 的内核,Node.js 也是基于 V8 引擎研发的。V8 引擎很具有代表性。
3.2. V8 引擎执行 JS 代码都要经过哪些阶段:
- Parse 阶段:V8 引擎负责将 JS 代码转换成 AST(抽象语法树);
- Ignition 阶段:解释器将 AST 转换为字节码,解析执行字节码也会为下一个阶段优化编译提供需要的信息;
- TurboFan 阶段:编译器利用上个阶段收集的信息,将字节码优化为可以执行的机器码;
- Orinoco 阶段:垃圾回收阶段,将程序中不再使用的内存空间进行回收。
其中,生成 AST、生成字节码、生成机器码是比较重要的三个阶段,下面就对其进行详细分析,看看每个底层阶段到底做了哪些操作,会影响 JS 代码执行的编译执行。
3.2.1 生成 AST
在日常工作中用过 的Eslint 和 Babel 这些工具每个都和 AST 脱不了干系。V8 引擎就是通过编译器(Parse)将源代码解析成 AST 的。
AST 在实际工作中应用场景也比较多,抽象语法树的应用场景,大致有下面几个:
- JS 反编译,语法解析
- Babel 编译 ES6 语法
- 代码高亮
- 关键字匹配
- 代码压缩。
这些场景的实现,都离不开通过将 JS 代码解析成 AST 来实现。生成 AST 分为两个阶段,一是词法分析,二是语法分析
- **词法分析:**这个阶段会将源代码拆成最小的、不可再分的词法单元,称为 token。比如这行代码 var a =1;通常会被分解成 var 、a、=、2、; 这五个词法单元。另外刚才代码中的空格在 JavaScript 中是直接忽略的。
- 语法分析:这个过程是将词法单元转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树,这个树被称为抽象语法树。
简单看一下解析成抽象语法树之后是什么样子的,代码如下
// 第一段代码
var a = 1;
// 第二段代码
function sum (a,b) {
return a + b;
}
将这两段代码,分别转换成 AST 抽象语法树之后返回的 JSON 格式如下。
1. 第一段代码,编译后的结果:
{
"type": "Program",
"start": 0,
"end": 10,
"body": [
{
"type": "VariableDeclaration",
"start": 0,
"end": 10,
"declarations": [
{
"type": "VariableDeclarator",
"start": 4,
"end": 9,
"id": {
"type": "Identifier",
"start": 4,
"end": 5,
"name": "a"
},
"init": {
"type": "Literal",
"start": 8,
"end": 9,
"value": 1,
"raw": "1"
}
}
],
"kind": "var"
}
],
"sourceType": "module"
}
2. 第二段代码,编译出来的结果:
{
"type": "Program",
"start": 0,
"end": 38,
"body": [
{
"type": "FunctionDeclaration",
"start": 0,
"end": 38,
"id": {
"type": "Identifier",
"start": 9,
"end": 12,
"name": "sum"
},
"expression": false,
"generator": false,
"async": false,
"params": [
{
"type": "Identifier",
"start": 14,
"end": 15,
"name": "a"
},
{
"type": "Identifier",
"start": 16,
"end": 17,
"name": "b"
}
],
"body": {
"type": "BlockStatement",
"start": 19,
"end": 38,
"body": [
{
"type": "ReturnStatement",
"start": 23,
"end": 36,
"argument": {
"type": "BinaryExpression",
"start": 30,
"end": 35,
"left": {
"type": "Identifier",
"start": 30,
"end": 31,
"name": "a"
},
"operator": "+",
"right": {
"type": "Identifier",
"start": 34,
"end": 35,
"name": "b"
}
}
}
]
}
}
],
"sourceType": "module"
}
从上面编译出的结果可以看到,AST 只是源代码语法结构的一种抽象的表示形式,计算机也不会去直接去识别 JS 代码,转换成抽象语法树也只是识别这一过程中的第一步。
前端领域经常使用一个工具 Babel,比如现在浏览器还不支持 ES6 语法,需要将其转换成 ES5 语法,这个过程就要借助 Babel 来实现。将 ES6 源码解析成 AST,再将 ES6 语法的抽象语法树转成 ES5 的抽象语法树,最后利用它来生成 ES5 的源代码。另外 ESlint 的原理也大致相同,检测流程也是将源码转换成抽象语法树,再利用它来检测代码规范。
如果想自己把代码翻译成 AST,提供了一个地址,代码帖进去就可以转换成相应的 AST:AST 在线转换。
3.2.2 生成字节码
将抽象语法树转换为字节码,也就是上面提到的 Ignition 阶段。这个阶段就是将 AST 转换为字节码,但是之前的 V8 版本不会经过这个过程,最早只是通过 AST 直接转换成机器码,而后面几个版本中才对此进行了改进。
如果将 AST 直接转换为机器码还是会有一些问题存在的,例如:
- 直接转换会带来内存占用过大的问题,因为将抽象语法树全部生成了机器码,而机器码相比字节码占用的内存多了很多;
- 某些 JavaScript 使用场景使用解释器更为合适,解析成字节码,有些代码没必要生成机器码,进而尽可能减少了占用内存过大的问题。
而后,官方在 V8 的 v5.6 版本中还是将抽象语法树转换成字节码这一过程又加上了,重新加入了字节码的处理过程。再然后,V8 重新引进了 Ignition 解释器,将抽象语法树转换成字节码后,内存占用显著下降了,同时也可以使用 JIT 编译器做进一步的优化。
其实字节码是介于 AST 和机器码之间的一种代码,需要将其转换成机器码后才能执行,字节码可以理解为是机器码的一种抽象。Ignition 解释器除了可以快速生成没有优化的字节码外,还可以执行部分字节码。
3.2.3 生成机器码
在 Ignition 解释器处理完之后,如果发现一段代码被重复执行多次的情况,生成的字节码以及分析数据会传给 TurboFan 编译器,它会根据分析数据的情况生成优化好的机器码。再执行这段代码之后,只需要直接执行编译后的机器码,这样性能就会更好。
TurboFan 编译器,它是 JIT 优化的编译器,因为 V8 引擎是多线程的,TurboFan 的编译线程和生成字节码不会在同一个线程上,这样可以和 Ignition 解释器相互配合着使用,不受另一方的影响。
由 Ignition 解释器收集的分析数据被 TurboFan 编译器使用,主要是通过一种推测优化的技术,生成已经优化的机器码来执行。通过一张图来看下整个生成抽象语法树,再到转换成字节码以及机器码的一个过程。
上面这张图可以很清晰地看到整个 V8 引擎编译和解析代码的流程。
4. 探究宏任务 & 微任务的运行机制
4.1. 代码执行顺序(一)
console.log('begin');
setTimeout(() => {
console.log('setTimeout')
}, 0);
new Promise((resolve) => {
console.log('promise');
resolve()
}).then(() => {
console.log('then1');
}).then(() => {
console.log('then2');
});
console.log('end');
这段代码应该比较简单,答案就是:
begin
promise
end
then1
then2
setTimeout
其实这个就涉及了 JavaScript 事件轮询中的宏任务和微任务。
宏任务和微任务的执行顺序基本是,在 EventLoop 中,每一次循环称为一次 tick,主要的任务顺序如下:
- 执行栈选择最先进入队列的宏任务,执行其同步代码直至结束
- 检查是否有微任务,如果有则执行直到微任务队列为空
- 如果是在浏览器端,那么基本要渲染页面了
- 开始下一轮的循环(tick),执行宏任务中的一些异步代码,例如 setTimeout 等
结合这个结论,它们的运转流程效果图如下:
Call-Stack(调用栈)也就是执行栈,它是一个栈的结构,符合先进后出的机制,每次一个循环,先执行最先入队的宏任务,然后再执行微任务。不管微任务还是宏任务,它们只要按照顺序进入了执行栈,那么执行栈就还是按照先进后出的规则,一步一步执行。
因此根据这个原则,最先进行调用栈的宏任务,一般情况下都是最后返回执行的结果。那么从上面的代码中可以看到 setTimeout 的确最后执行了打印的结果。
4.2. 宏任务
宏任务是由宿主(浏览器/Node)发起,如果在浏览器的环境下,宏任务主要分为下面这几个大类:
- script ----------------------------->浏览器
- 渲染事件(比如解析 DOM、计算布局、绘制)
- 用户交互事件(比如鼠标点击、滚动页面、放大缩小等)
- setTimeout、setInterval 等
- 网络请求完成(Ajax/Fetch)、文件读写完成事件
为了让这些任务在主线程上执行,页面进程引入了消息队列和事件循环机制,把这些消息队列中的任务称为宏任务。宏任务基本上满足了日常的开发需求,而对于时间精度有要求的宏任务就不太能满足了,比如渲染事件、各种 I/O、用户交互的事件等,都随时有可能被添加到消息队列中,JS 代码不能准确掌控任务要添加到队列中的位置,控制不了任务在消息队列中的位置,所以很难控制开始执行任务的时间。
function callback2(){
console.log(2)
}
function callback(){
console.log(1)
setTimeout(callback2,0)
}
setTimeout(callback,0)
在上面这段代码中,目的是想通过 setTimeout 来设置两个回调任务,并让它们按照前后顺序来执行,中间也不要再插入其他的任务。但是实际情况我们难以控制,比如在调用 setTimeout 来设置回调任务的间隙,消息队列中就有可能被插入很多系统级的任务。如果中间被插入的任务执行时间过久的话,那么就会影响到后面任务的执行了。所以说宏任务的时间粒度比较大,执行的间隔是不能精确控制的。这就不适用于一些高实时性的需求了,比如后面的监听 DOM 变化。
4.3. 微任务
微任务是由JS引擎发起的任务 微任务有:
- Promise.then()/catch()(Promise本身同步,then/catch的回调函数是异步的)
- process.nextTick---->Node发起
- Async/Await
- Object.observe
微任务就是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。
当 JavaScript 执行一段脚本的时候,V8 会为其创建一个全局执行上下文,同时 V8 引擎也会在内部创建一个微任务队列。这个微任务队列就是用来存放微任务的,因为在当前宏任务执行的过程中,有时候会产生多个微任务,这时候就需要使用这个微任务队列来保存这些微任务了。不过这个微任务队列是给 V8 引擎内部使用的,所以是无法通过 JavaScript 直接访问的。
在现代浏览器里面,产生微任务有两种方式:
- 使用 MutationObserver 监控某个 DOM 节点,或者为这个节点添加、删除部分子节点,当 DOM 节点发生变化时,就会产生 DOM 变化记录的微任务。
- 使用 Promise,当调用 Promise.resolve() 或者 Promise.reject() 的时候,也会产生微任务。
通过 DOM 节点变化产生的微任务或者使用 Promise 产生的微任务都会被 JS 引擎按照顺序保存到微任务队列中。
微任务队列是何时被执行的:
通常情况下,在当前宏任务中的 JavaScript 快执行完成时,也就是在 JavaScript 引擎准备退出全局执行上下文并清空调用栈的时候,JavaScript 引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行队列中的微任务。
如果在执行微任务的过程中,产生了新的微任务,一样会将该微任务添加到微任务队列中,V8 引擎一直循环执行微任务队列中的任务,直到队列清空才算执行结束。也就是说在执行微任务过程中产生的新的微任务并不会推迟到下一个循环中执行,而是在当前的循环中继续执行,这点是需要注意的。
以上就是微任务的工作流程,从上面的分析可以得出如下几个结论:
- 微任务和宏任务是绑定的,每个宏任务在执行时,会创建自己的微任务队列
- 微任务的执行时长会影响当前宏任务的时长。比如一个宏任务在执行过程中,产生了 10 个微任务,执行每个微任务的时间是 10ms,那么执行这 10 个微任务的时间就是 100ms,也可以说这 10 个微任务让宏任务的执行时间延长了 100ms
- 在一个宏任务中,分别创建一个用于回调的宏任务和微任务,无论什么情况下,微任务都早于宏任务执行
4.4. 监听 DOM 变化应用场景
MutationObserver 是用来监听 DOM 变化的一套方法,而监听 DOM 变化一直是前端工程师经常要做的事情之一。
虽然监听 DOM 的需求是比较频繁的,不过早期页面并没有提供对监听的支持,所以那时要观察 DOM 是否变化,唯一能做的就是轮询检测。比如使用 setTimeout 或者 setInterval 来定时检测 DOM 是否有改变。这种方式简单粗暴,但是会遇到两个问题:如果时间间隔设置过长,DOM 变化响应不够及时;反过来如果时间间隔设置过短,又会浪费很多无用的工作量去检查 DOM,会让页面变得低效。
从 DOM 4 开始,W3C 推出了 MutationObserver。MutationObserver API 可以用来监视 DOM 的变化,包括属性的变更、节点的增加、内容的改变等。因为上面分析过,在两个任务之间,可能会被渲染进程插入其他的事件,从而影响到响应的实时性。这时候,微任务就可以上场了,在每次 DOM 节点发生变化的时候,渲染引擎将变化记录封装成微任务,并将微任务添加进当前的微任务队列中。这样当执行到检查点的时候,V8 引擎就会按照顺序执行微任务了。
综上所述,MutationObserver 采用了“异步 + 微任务”的策略:
- 通过异步操作解决了同步操作的性能问题
- 通过微任务解决了实时性的问题
4.5. 代码执行顺序(二)
async function async1() {
console.log("async1 start");
await async2();
console.log("async1 end");
}
async function async2() {
console.log("async2");
}
async1();
setTimeout(() => {
console.log("timeout");
}, 0);
new Promise(function (resolve) {
console.log("promise1");
resolve();
}).then(function () {
console.log("promise2");
});
console.log("script end");
async1 start
async2
promise1
script end
async1 end
promise2
timeout
总结
5.如何理解 Process.nextTick 的原理
那么,为了方便你更好地理解本讲的内容,在课程开始前请你先思考:
- Process.nextick 和其他微任务方法在一起的时候,执行顺序是怎么样的?
- Vue 也有个 nextick,它的逻辑又是什么样的?
带着疑问,我们先来了解一下 Process.nextick。
5.1. 基本语法
Process.nextick 的语法有两个参数:
process.nextTick(callback[, ...args])
其中,第一个参数是 callback 回调函数,第二个参数是 args 调用 callback 时额外传的参数,是可选参数。
Process.nextick 的运行逻辑:
- rocess.nextick 会将 callback 添加到“next tick queue”
- “next tick queue”会在当前 JavaScript stack 执行完成后,下一次 event loop 开始执行前按照 FIFO 出队
- 如果递归调用 Process.nextick 可能会导致一个无限循环,需要去适时终止递归
Process.nextick 其实是微任务,同时也是异步 API 的一部分。但是从技术上来说 Process.nextick 并不是事件循环(eventloop)的一部分,相反地,“next tick queue”将会在当前操作完成之后立即被处理,而不管当前处于事件循环的哪个阶段。
思考一下上面的逻辑,如果任何时刻你在一个给定的阶段调用 Process.nextick,则所有被传入 Process.nextick 的回调将在事件循环继续往下执行前被执行。这可能会导致一些很糟的情形,因为它允许用户递归调用 Process.nextick 来挂起 I/O 进程的进行,这会导致事件循环永远无法到达轮询阶段
5.2. 为什么使用 Process.nextTick()
一部分原因是设计理念,Node.js 中的 API 应该总是异步的,即使是那些不需要异步的地方。下面的代码片段展示了一个例子:
function apiCall(arg, callback) {
if (typeof arg !== 'string')
return process.nextTick(callback, new TypeError('argument should be string'));
}
通过上面的代码检查参数,如果检查不通过,它将一个错误对象传给回调。Node.js API 最近进行了更新,其已经允许向 Process.nextick 中传递参数来作为回调函数的参数,而不必写嵌套函数。
我们所做的就是将一个错误传递给用户,但这只允许在用户代码被执行完毕后执行。使用 Process.nextick 可以保证 apicall() 的回调总是在用户代码被执行后,且在事件循环继续工作前被执行。为了达到这一点,JS 调用栈被允许展开,然后立即执行所提供的回调。该回调允许用户对 Process.nextick 进行递归调用,而不会达到 RangeError,即 V8 调用栈的最大值。
这种设计理念会导致一些潜在的问题,观察下面的代码片段:
let bar;
function someAsyncApiCall(callback) { callback(); }
someAsyncApiCall(() => {
console.log('bar', bar); // undefined
});
bar = 1;
用户定义函数 someAsyncApiCall() 有一个异步签名,但实际上它是同步执行的。当它被调用时,提供给 someAsyncApiCall() 的回调函数会在执行 someAsyncApiCall() 本身的同一个事件循环阶段被执行,因为 someAsyncApiCall() 实际上并未执行任何异步操作。结果就是,即使回调函数尝试引用变量 bar,但此时在作用域中并没有改变量。因为程序还没运行到对 bar 赋值的部分。
将回调放到 Process.nextick 中,程序依然可以执行完毕,且所有的变量、函数等都在执行回调之前被初始化,它还具有不会被事件循环打断的优点。以下是将上面的例子改用 Process.nextick 的代码:
let bar;
function someAsyncApiCall(callback) {
process.nextTick(callback);
}
someAsyncApiCall(() => {
console.log('bar', bar); // 1
});
bar = 1;
EventEmitter 在 Node.js 的使用的一个例子
因为 Node.js 直接有 event 模块,其实就是一个 EventEmitter,下面代码是在造函数中触发一个事件:
const EventEmitter = require('events');
const util = require('util');
function MyEmitter() {
EventEmitter.call(this);
this.emit('event');
}
util.inherits(MyEmitter, EventEmitter);
const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
console.log('an event occurred!');
});
无法在构造函数中立即触发一个事件,因为此时程序还未运行到将回调赋值给事件的那段代码。因此,在构造函数内部,你可以使用 Process.nextick 设置一个回调以在构造函数执行完毕后触发事件,下面的代码满足了我们的预期。
const EventEmitter = require('events');
const util = require('util');
function MyEmitter() {
EventEmitter.call(this);
process.nextTick(() => {
this.emit('event');
});
}
util.inherits(MyEmitter, EventEmitter);
const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
console.log('an event occurred!');
});
通过上面的改造可以看出,使用 Process.nextick 就可以解决问题了,即使 event 事件还没进行绑定,但也可以让代码在前面进行触发,因为根据代码执行顺序,Process.nextick 是在每一次的事件循环最后执行的。因此这样写,代码也不会报错,同样又保持了代码的逻辑。
5.3. Vue 的 nextick
Vue 官网最直白的解释:
Vue 异步执行 DOM 的更新。当数据发生变化时,Vue 会开启一个队列,用于缓冲在同一事件循环中发生的所有数据改变的情况。如果同一个 watcher 被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作上非常重要。然后在下一个的事件循环“tick”中。例如:当你设置 vm.someData = 'new value',该组件不会立即重新渲染。当刷新队列时,组件会在事件循环队列清空时的下一个“tick”更新。多数情况我们不需要关心这个过程,但是如果你想在 DOM 状态更新后做点什么,这就可能会有些棘手。
细细地根据 Vue 的官网理解一下,其实是不是有点像 EventLoop 的味道,这里只不过是 Vue 开启了一个队列,当在 nextick 方法中改变数据的时候,视图层不会立马更新,而是要在下次的时间循环队列中更新。
<template>
<div class="app">
<div ref="msg">{{msg}}</div>
<div v-if="msg1">Message got outside $nextTick: {{msg1}}</div>
<div v-if="msg2">Message got inside $nextTick: {{msg2}}</div>
<button @click="changeMsg">
Change the Message
</button>
</div>
</template>
<script>
new Vue({
el: '.app',
data: {
msg: 'Vue',
msg1: '',
msg2: '',
},
methods: {
changeMsg() {
this.msg = "Hello world."
this.msg1 = this.$refs.msg.innerHTML
this.$nextTick(() => {
this.msg2 = this.$refs.msg.innerHTML
})
}
}
})
</script>
将这一段代码放到自己的 Vue 的项目里执行一下,看看通过按钮点击之后,div 里面的 msg1 和 msg2 的变化情况。发现第一次点击按钮调用 changeMsg 方法时,其实 msg2 并没有变化,因为 msg2 的变化是在下一个 tick 才进行执行的。
最后我们再来看下 Vue 中 nextick 的源码。在 Vue 2.5+ 之后的版本中,有一个单独的 JS 文件来维护,路径是在 src/core/util/next-tick.js 中,源码如下:
/* @flow */
/* globals MutationObserver */
import { noop } from 'shared/util'
import { handleError } from './error'
import { isIE, isIOS, isNative } from './env'
export let isUsingMicroTask = false
const callbacks = []
let pending = false
function flushCallbacks () {
pending = false
const copies = callbacks.slice(0)
callbacks.length = 0
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}
// Here we have async deferring wrappers using microtasks.
// In 2.5 we used (macro) tasks (in combination with microtasks).
// However, it has subtle problems when state is changed right before repaint
// (e.g. #6813, out-in transitions).
// Also, using (macro) tasks in event handler would cause some weird behaviors
// that cannot be circumvented (e.g. #7109, #7153, #7546, #7834, #8109).
// So we now use microtasks everywhere, again.
// A major drawback of this tradeoff is that there are some scenarios
// where microtasks have too high a priority and fire in between supposedly
// sequential events (e.g. #4521, #6690, which have workarounds)
// or even between bubbling of the same event (#6566).
let timerFunc
// The nextTick behavior leverages the microtask queue, which can be accessed
// via either native Promise.then or MutationObserver.
// MutationObserver has wider support, however it is seriously bugged in
// UIWebView in iOS >= 9.3.3 when triggered in touch event handlers. It
// completely stops working after triggering a few times... so, if native
// Promise is available, we will use it:
/* istanbul ignore next, $flow-disable-line */
if (typeof Promise !== 'undefined' && isNative(Promise)) {
const p = Promise.resolve()
timerFunc = () => {
p.then(flushCallbacks)
// In problematic UIWebViews, Promise.then doesn't completely break, but
// it can get stuck in a weird state where callbacks are pushed into the
// microtask queue but the queue isn't being flushed, until the browser
// needs to do some other work, e.g. handle a timer. Therefore we can
// "force" the microtask queue to be flushed by adding an empty timer.
if (isIOS) setTimeout(noop)
}
isUsingMicroTask = true
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
isNative(MutationObserver) ||
// PhantomJS and iOS 7.x
MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
// Use MutationObserver where native Promise is not available,
// e.g. PhantomJS, iOS7, Android 4.4
// (#6466 MutationObserver is unreliable in IE11)
let counter = 1
const observer = new MutationObserver(flushCallbacks)
const textNode = document.createTextNode(String(counter))
observer.observe(textNode, {
characterData: true
})
timerFunc = () => {
counter = (counter + 1) % 2
textNode.data = String(counter)
}
isUsingMicroTask = true
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
// Fallback to setImmediate.
// Technically it leverages the (macro) task queue,
// but it is still a better choice than setTimeout.
timerFunc = () => {
setImmediate(flushCallbacks)
}
} else {
// Fallback to setTimeout.
timerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}
export function nextTick (cb?: Function, ctx?: Object) {
let _resolve
callbacks.push(() => {
if (cb) {
try {
cb.call(ctx)
} catch (e) {
handleError(e, ctx, 'nextTick')
}
} else if (_resolve) {
_resolve(ctx)
}
})
if (!pending) {
pending = true
timerFunc()
}
// $flow-disable-line
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}
整体代码不是太多,注释比较多,其核心部分代码比较精简,主要在 40~80 行之间,核心在于 timerFunc 这个函数的逻辑实现,timerFunc 这个函数采用了好几种处理方式,主要是针对系统以及 Promise 的支持几个情况同时进行兼容性处理。处理逻辑情况是这样的:
- 首先判断是否原生支持 Promise,支持的话,利用 promise 来触发执行回调函数
- 如果不支持 Promise,再判断是否支持 MutationObserver,如果支持,那么生成一个对象来观察文本节点发生的变化,从而实现触发执行所有回调函数
- 如果 Promise 和 MutationObserver 都不支持,那么使用 setTimeout 设置延时为 0
总结
最后,针对 Process.nextick() 和 Vue 的 nextick 这两种不同的 tick ,总结了下面这个表格