浏览器内核
浏览器内核又可以分成两部分:渲染引擎(layout engineer或者RenderingEngine)和JS引擎。
渲染引擎
负责取得网页的内容(HTML、XML、图像等等)、整理讯息(例如加入CSS等),以及计算网页的显示方式,然后会输出至显示器或打印机。浏览器的内核的不同对于网页的语法解释会有不同,所以渲染的效果也不相同。所有网页浏览器、电子邮件客户端以及其它需要编辑、显示网络内容的应用程序都需要内核。
JS引擎
解析和执行javascript来实现网页的动态效果,js引擎有很多。比如谷歌浏览器的(V8),Apple的WebKit内核默认(JavaScript Core 引擎),比如V8做了哪些事:1)解析javascript到中间代码AST,2)再将中间代码AST编译成机器码执行。JavaScript是一种解释形语言,无法做到像java那边整个项目都编译成机器码再执行,所以需要依靠V8边编译,边执行。
V8执行一段JS代码的大致流程
(1)初始化基础环境;
(2)解析源码生成AST和作用域(执行上下文)
(3)依据AST和作用域生成字节码;
(4)解释执行字节码;
(5)监听热点代码;
(6)优化热点代码为二进制机器代码;
(7)去优化生成的二进制机器代码;
(8)短生命周期的会话使用sparkplug优化;
JS的内存空间
分为三类,代码空间,栈stack空间,堆heap空间;
为什么不放在一起?因为放在一起,会影响执行上下文切换和执行效率;
堆(heap)和栈(stack)的区别:
栈:空间较小;先进后出;动态分配的空间 一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收,分配方式类似于链表。
堆:空间较大;队列优先,先进先出;由操作系统自动分配释放 ,存放函数的参数值,局部变量的值等。
原始类型的数据值都是直接保存在“栈”中的,引用类型的值是存放在“堆”中?其实并没有那么简 单,在V8源码中。
字符串: 存在堆里,栈中为引用地址 ,如果存在相同字符串,则引用地址相同。
数字: 小整数存在栈中 ,其他类型存在堆中。
其他类型:引擎初始化时分配唯一地址,栈中的变量存的是唯一的引用。
事件循环
JavaScript有一个基于事件循环的并发模型,事件循环负责执行代码、收集和处理事件以及执行队列中的子任务。这个模型与其它语言中的模型截然不同,比如 C 和 Java。
理论模型:
函数调用形成了一个由若干帧组成的栈。函数调用遇到新的调用会先压栈,然后再从顶到下弹栈执行,当最后一个帧被弹出,栈就被清空,调用结束。
一个 JavaScript 运行时包含了一个待处理消息的消息队列。每一个消息都关联着一个用以处理这个消息的回调函数。
在 事件循环 期间的某个时刻,运行时会从最先进入队列的消息开始处理队列中的消息。被处理的消息会被移出队列,并作为输入参数来调用与之关联的函数。正如前面所提到的,调用一个函数总是会为其创造一个新的栈帧。
函数的处理会一直进行到执行栈再次为空为止;然后事件循环将会处理队列中的下一个消息(如果还有的话)。
while (queue.waitForMessage()) {
queue.processNextMessage();
}
当一个函数执行时,它不会被抢占,只有在它运行完毕之后才会去运行任何其他的代码,才能修改这个函数操作的数据。这与C语言不同,例如,如果函数在线程中运行,它可能在任何位置被终止,然后在另一个线程中运行其他代码。
这个模型的一个缺点在于当一个消息需要太长时间才能处理完毕时,Web应用程序就无法处理与用户的交互,例如点击或滚动。为了缓解这个问题,浏览器一般会弹出一个“这个脚本运行时间过长”的对话框。一个良好的习惯是缩短单个消息处理时间,并在可能的情况下将一个消息裁剪成多个消息。
函数 setTimeout
接受两个参数:待加入队列的消息和一个时间值(可选,默认为 0)。这个时间值代表了消息被实际加入到队列的最小延迟时间。如果队列中没有其它消息并且栈为空,在这段延迟时间过去之后,消息会被马上处理。但是,如果有其它消息,setTimeout
消息必须等待其它消息处理完。因此第二个参数仅仅表示最少延迟时间,而非确切的等待时间。
下面的例子演示了这个概念(setTimeout
并不会在计时器到期之后直接执行):
const s = new Date().getSeconds();
setTimeout(function() {
// 输出 "2",表示回调函数并没有在 10 毫秒之后立即执行
console.log("SetTimeout ran after " + (new Date().getSeconds() - s) + " seconds");
}, 10);
while(true) {
if(new Date().getSeconds() - s >= 2) {
console.log("Looped for 2 seconds");
break;
}
}
// Output:
// Looped for 2 seconds
// SetTimeout ran after 2 seconds
事件循环是 JavaScript 异步编程背后的秘密。JS 在单个线程上执行所有操作,但是使用了一些智能数据结构,它给了我们多线程的错觉。让我们看看后端发生了什么。
每当调用异步函数时,它都会被发送到浏览器 API。这些是内置在浏览器中的 API。根据从调用堆栈收到的命令,API 开始自己的单线程操作。
事件循环促进了这个过程;它不断检查调用堆栈是否为空。如果为空,则从事件队列中添加新函数。如果不是,则处理当前函数调用。
宏任务和微任务
我们上面所谈的task queue都是宏任务队列,当然还存在微任务队列。
每个宏任务之后,引擎会立即执行微任务队列中的所有任务,然后再执行其他的宏任务,或渲染,或进行其他任何操作。
setTimeout(() => console.log("timeout"));
Promise.resolve()
.then(() => console.log("promise"));
console.log("main");
执行顺序如下:
- main:因为它是主线程同步调用(可以看做第一个宏任务)。
promise:
主线程同步调用,但是到了then之后,因为then
里面的执行方法会被queue到microtask中(在promise的resolve时候queue进去)去执行通过微任务队列执行,此处会在主线程执行完立即执行微任务。timeout:
会在微任务执行完之后开始执行新的宏任务。
还有一个特殊的函数 queueMicrotask(func)
,它对 func
进行排队,以在微任务队列中执行。
宏任务和微任务大体上执行顺序如下:
- 从 宏任务 队列(例如 “script”)中出队(dequeue)并执行最早的任务。
- 执行所有 微任务:
- 当微任务队列非空时:
- 出队(dequeue)并执行最早的微任务。
- 当微任务队列非空时:
- 如果有变更,则将变更渲染出来。
- 如果宏任务队列为空,则休眠直到出现宏任务。
- 转到步骤 1。
setTimeout(() => {
console.log("macro timeout")
})
console.log("begin");
queueMicrotask(() => {
console.log("micro1")
})
new Promise(function (resolve) {
console.log("resolve");
resolve("micro executed!");
}).then(function (data) {
console.log(data);
});
queueMicrotask(() => {
console.log("micro2")
})
console.log("end");
执行结果如下:主线程先执行完(过程中会宏任务和微任务queue进对应的队列里),然后把三个微任务按顺序执行完,再执行下一个宏任务(settimeout)
begin
resolve
end
micro1
micro executed!
micro2
macro timeout
Web Workers
对于不应该阻塞事件循环的耗时长的繁重计算任务,我们可以使用 Web Workers。这是在另一个并行线程中运行代码的方式。Web Workers 可以与主线程交换消息,但是它们具有自己的变量和事件循环。Web Workers 没有访问 DOM 的权限,因此,它们对于同时使用多个 CPU 内核的计算非常有用。