这是一套关于 Chrome 中的 JS 引擎 V8 的文章系列。谈到 V8 ,估计大部分人会一种畏惧感,首先映入脑海的便是天花乱坠的 C++ 源码,但其实就算你掌握了 C++,也很难读懂源码,读懂里面的源码,并不是语言的问题,而是一个工程复杂度的问题。而且,我认为把 V8 里面的源码全部读懂并不是一个明智的做法,一个是工程量过于庞大,很容易失去耐心,另一方面代码的封装度非常高,导致很多抽象的代码,理解难度大。你想啊,几百个顶尖的工程师开发了十几年的东西,会让你轻易就把源码啃下来吗?
所以,我认为对于这种复杂的引擎来说,首先去看的不是具体的实现代码,而是顶层的设计思想和关键的技术手段。这样,如果对一部分特别感兴趣,也能找到一个切入的点去源码中验证,或者拿到更多的细节。
那还有一个问题,为什么要学习 V8,学习它有什么用?
很遗憾地告诉你,从功利的角度说,对你更好、更快地完成业务,并没什么用。如果说真的有用,可能面试的时候会有一点帮助,但更重要的是,作为一个追求极致的前端来说,这是一项基本功。基本功的重要性不言而喻,如果不知道引擎的实现思路,如何写出高质量高性能的 JS 代码简直是天方夜谭。
基于这样的出发点,我开始了这个系列的分享。主要分为两条主线,一个是 V8 的编译和执行流程,这会逼着你去补充计算机底层架构相关的知识,另一个是在性能优化方面 V8 采取了哪些具体的手段,根据这些手段你就知道如何合适的组织 JS 代码去触发这些优化,达到极致的性能。下面是本系列的思维导图:
今天是第一篇,来分享一下 V8 在宏观上所处的位置(针对 Chorme 环境)。
早期的 Chrome 是单进程的,渲染、插件和网络等功能都是通过里面的线程来完成,这样大家都是共享一份内存的数据,不需要进程间通信,确实比较方便,但是一旦其中有一个线程出现问题,那个整个浏览器就会直接崩溃。为了解决这个问题,Chrome 后来采用了多进程架构,也就是现在的架构,如下所示:
简单介绍一下这些进程的功能吧。
浏览器主进程:负责界面显示、用户交互、子进程管理,以及与外界通信的能力。
网络进程: 主要负责页面的网络资源加载。关于这个进程外界比较有争议,因为之前它并不是一个独立的进程,而是隶属于主进程当中的一个子功能。
渲染进程: 负责将 HTML、CSS 和 JS 转换为用户可见的网页,其中有一个主线程,这个主线程的执行权会在两个引擎间来回切换,一个是 Blink 排版引擎,一个是 V8 引擎,完成 JS 执行和文档的排版。
GPU进程: 用来绘制 UI 界面。
插件进程: 负责插件的运行。
其他进程: 如实用程序网络服务、辅助框架等等功能,可以通过任务处理器看到。
从上面可以看出 V8 实际上是在渲染进程当中,也就是渲染进程的一部分,与排版引擎 Blink共享一个主线程(这个主线程也叫UI线程
)。
所以 V8 所在的宿主环境也就是渲染进程,会给 V8 注入许多运行时的能力,使得在 JS 中能够顺利地调用。
这些能力并不属于 V8, 但对于 V8 的执行确实至关重要的。我们来梳理一下这些能力:
基本数据类型用栈
存储,引用数据类型用堆
存储。
:::tip 提示 闭包变量是个例外,后面专门来介绍实现原理 :::
具体而言,以下数据类型存储在栈中:
而所有的对象数据类型存放在堆中。
值得注意的是,对于赋值
操作,原始类型的数据直接完整地赋值变量值,对象数据类型的数据则是复制引用地址。
因此会有下面的情况:
let obj = { a: 1 };
let newObj = obj;
newObj.a = 2;
console.log(obj.a);//变成了2
之所以会这样,是因为 obj 和 newObj 是同一份堆空间的地址,改变newObj,等于改变了共同的堆内存,这时候通过 obj 来获取这块内存的值当然会改变。
当然,你可能会问: 为什么不全部用栈来保存呢?
首先,对于系统栈来说,它的功能除了保存变量之外,还有创建并切换函数执行上下文的功能。举个例子:
function f(a) {
console.log(a);
}
function func(a) {
f(a);
}
func(1);
假设用ESP指针来保存当前的执行状态,在系统栈中会产生如下的过程:
图示如下:
因此你也看到了,如果采用栈来存储相对基本类型更加复杂的对象数据,那么切换上下文的开销将变得巨大!
分为两部分:全局 API 和 词法环境。
全局 API 包括 window 对象,默认指向 window 的 this 关键字,还包括所有的 Web API,比如 setTimout, XMLHttpRequest,document 等等。你没有听错,这些能力都不是 V8 的,所以跟 V8 的创建和销毁没有关系,当你往这些 API 对象上挂载属性后,会一直会驻留于堆内存中。
词法环境主要包括使用了var、let、const 声明的变量的内容。这些内容存放在栈内存或堆内存中。
回顾一下浏览器中 EventLoop 的调度过程:
这些任务队列和调度的过程都由 Chrome 所提供,V8 只负责将执行具体的任务。
如果安装了 Homebrew, 直接通过:
brew install v8
即可, 期间会自动安装 d8 调试工具。
对于一个 demo 文件:
// index.js
var text = '111';
执行以下命令查看 V8 为这段 js 生成的字节码:
d8 --print-bytecode index.js
输出如下:
也可通过这条命令查看所有的命令集:
d8 --help
推荐使用一个非常好用的脚手架——jsvu, 首先安装:
npm i jsvu -g
然后执行下面的命令,添加~/.jsvu到你的PATH:
export PATH="${HOME}/.jsvu:${PATH}"
现在执行:
jsvu
第一步: 根据提示信息选择你所在的操作系统。
第二步: 选择需要安装的 JS 引擎,选择 v8 或者 v8-debug。(后者的打印信息会更加丰富,比如能打印 AST 信息)
现在你就能使用 v8 或者 v8-debug 来执行调试了。也就是需要将方法一中的d8
换成v8
或者v8-debug
,能够达到和方法一中同样的效果。
首先安装:
npm i jsvu -g
然后添加 .jsvu 的位置到你的 Path,也就是在环境变量中的 Path 中,加入 .jsvu 目录所在的绝对路径(包括 .jsvu 本身)。
(如果环境变量还不知道在哪改的同学,去看百度吧,链接在这里:https://jingyan.baidu.com/article/8ebacdf02d3c2949f65cd5d0.html)
现在执行:
jsvu
第一步: 根据提示信息选择你所在的操作系统。
第二步: 选择需要安装的 JS 引擎,选择 v8 或者 v8-debug。(后者的打印信息会更加丰富,比如能打印 AST 信息)
现在你就能使用 v8 或者 v8-debug 来执行调试了。也就是需要将方法一中的d8
换成v8
或者v8-debug
,能够达到和方法一中同样的效果。
以 v8 命令为例,对于一个 demo 文件:
// index.js
var text = '111';
执行以下命令查看 V8 为这段 js 生成的字节码:
v8 --print-bytecode index.js
输出如下:
也可通过这条命令查看所有的命令集:
v8 --help
V8 处在 Chrome 渲染进程当中,与排版引擎 Blink共享一个主线程。渲染进程作为宿主环境,给 V8 提供了栈内存和堆内存空间
、全局执行上下文
和 EventLoop调度能力
。接着,我们搭建了 v8 的调试环境,能够输出 v8 的中间产物,让我们对 v8 的工作有更加直观的认识。