这期好好谈谈 V8 通用情景下的 JS 引擎是怎么工作的
-
JS 运作机制可以分为 AST 分析、引擎执行两个步骤
-
JS 源码通过 parser(分析器)转化为 AST(抽象语法树),经过解释器(interpreter)解析为字节码(byteCode)
-
为了提高运行效率,会将部分使用频率较高的代码直接转换为机器码(optimizing compiler -> machine Code)
- JS 解释器、优化器
- JS 代码可能在字节码或者优化后的机器码状态下执行,生成字节码的速度很快,而生成机器码就要慢一点,所以 V8 将解释器称为
Ignition
(点火器), 将优化器(optimizing compiler)称为TurboFan
(旋涡引擎) - 可以理解为将代码启动解释后,进入旋涡引擎进行加速优化,代码先快速解析为可执行的字节码后,利用执行中获取的数据(如打 Tag 标记哪些代码使用频率最高),将频率较高的代码放入优化编译器生成机器码提速
- Firefox 使用的 Mozilla 引擎有一点不同,使用两个优化编译器,将字节码优化为部分机器码,在根据部分优化后的代码运行拿到的数据进行最终优化,生成高度优化的机器码,优化失败则会做回退
- 微软的 Edge 使用的 Chakra 引擎,和 Mozilla 的有点像,区别是第二个最终优化的编译器同时接受字节码和部分优化的机器码产生的数据,并且在优化失败后回退到第一步字节码而不是第二步。减少回退流程
- ![Chakra引擎优化](/Users/buxiongyu/Desktop/私人文件/weekly_notes/image/ChaKra-engine optimization.png)
- Safari、React Native 使用的 JSC 引擎更为极端,使用三个优化编译器,渐进式的优化,失败后都会回退到第一部分优化的机器码。
- 不同的前端引擎使用不同的优化策略,原因是根据其内核来判断的,JS 本质要么使用解释器快速执行,要么优化成机器码后再执行,优化消耗时间并不总小于字节码低效运行损耗的时间,所以有些引擎会选择多个编译器逐渐优化,得出最优优化执行效率。
- JS 代码可能在字节码或者优化后的机器码状态下执行,生成字节码的速度很快,而生成机器码就要慢一点,所以 V8 将解释器称为
- JS 是面向对象的,JS 引擎实现 JS 对象模型方式类似:
- ES 规范明确对象模型就是一个以字符串为 Key 的字典,除了值以外,还定义了
writeable
Enumerable
- ES 规范明确对象模型就是一个以字符串为 Key 的字典,除了值以外,还定义了
configurable
这些配置,表示 key 是否能被重写,被枚举(遍历访问),被配置。
- 规范虽然定义了
[[]]
这种双括号写法,但是不会暴露给用户,可以通过 Object.getOwnPropertyDescriptor 拿到某个属性的配置 - JS 中,数组是一个特殊的对象(对象结构完全一致),对于对象来说,数组有特定下标,这个下标最大长度为 2 的三十二次方减一,对于一个数组来说,他的 length 是不可枚举,不可配置的,但是在赋值的时候,可以自动更新数值。
-
属性访问频率较高,所以 JS 引擎必须对属性访问做优化
-
Shapes ()
-
给不同对象相同的 key 名很常见,访问不同对象的同一个
propertyKey
也和常见,这里就会有一个形似的概念出现:const object1 = { x: 1, y: 2 }; const object2 = { x: 1, y: 2}; function logX(object) { console.log(object.x) // 会报错 } logX(object1) logX(object2)
-
object1 和 object2 都拥有一个相同的
shape
(形似对象属性), 拥有相同的 x、y 属性,如果属性值也存储在 JSObject 中,那就回出现很多冗余数据,引擎会单独存储shape
,与真实对象隔离 -
JSObjectA: { x: 4, y: 5 } JSOobjectB: { x: 4, b: 6, } => Shape('x', 'y') Offset: 0 / 1 [[Writable]]: true [[Enumerable]]: true [[Configurable]]: true
-
具有同样结构的对象可以共享
shape
所有 js 引擎都是以这种形式优化对象,但是不都叫shape
,原文可以查阅
-
-
Transition chains && Transition Tree
-
给对象添加一个 key,js 引擎是如何生成新的
shape
:-
生成的 shape 链式创建的过程叫做 Transition chains ,派生向下继承
-
JSObject original {x, y} => Shape(empty) => Shape(x) => Shape(y) => Shape(x,y)
-
由于 JS 引擎做架构设计的时候没有考虑优化问题,架构设计完又得回过头来对时间和空间做优化
-
-
Transition Tree
- 如果没有父级
shape
,分别创建的时候需要经过 Transition Tree 优化,两个Shape
需要分别继承 Shape(empty) ,继承空 Shape 的情况仅限于 empty object
- 如果没有父级
-
-
Inline Caches (局部缓存)
-
js 引擎为了提高对象查找效率,需要局部做高效缓存 (LRU 等等 设计模式参考)
-
JSC 生成一个函数并获取值然后生成的字节码如下:
function getX(o) { return o.x; } JSC => get_by_id loc0, arg1, x N/A N/A // 生成的字节码 return loc0 // get_by_id 指令是获取 arg1 参数指向的对象x, 并存储在loc0, 第二步返回loc0 // 执行到 get({x: 'a'})的时候, get_by_id 指令会缓存这个对象的Shape
-
对象的 Shape 记录了自己拥有的字段 x 以及对应下标的 offset:
function getX(o) { return o.x } Offset: 0 [[Writable]]: true [[Enumerable]]: true [[Configurable]]: true get({x: 'a'}) JSC => get_by_id loc0, arg1, x 'x' 0 // 生成的字节码, 字节码下的内存地址存着相关的属性值 return loc0
-
执行
get_by_id
时,引擎从shape
查找下标,找到 x,找到之后,引擎将 Shape 保存的 offset 缓存起来,下次直接跳过 shape 这一步,以后访问o.x
时,只要Shape
相同,引擎就会从 get_by_id 指令缓存的下标可以直接命中查找的值,这个缓存在指令中的下标就是 Inline Cache
-
-
数组缓存优化
-
和对象一样,数组存储也可以被优化,由于数组的特殊性,不需要为每一项数据做完整配置
-
对于数组,JS 引擎同样可以被优化,只是其特殊下标键值性决定不需要为每一项数据做完整性配置
array = ['#jsconfeu'] Shape('length') => JSArray a 1 => Elements '#jsconfeu' Element [[Writable]]: true [[Enumerable]]: true [[Configurable]]: true
-
JS 引擎将数组的值单独存在
Elements
中,通常是可读可写可配置的,所以并不会像对象一样为每一个元素做配置。 -
// 不要为数组下标做如下配置: const array = Object.defineProperty([], "0", { value: "Oh noes!!1", writable: false, enumerable: false, configurable: false, })
-
JS 引擎会存储一个
Dictionary Elements
类型,为每个数组做配置array = Object.defineProperty([], "0", { ... }) array['Shape'].length => [[Writable]]: true [[Enumerable]]: false [[Configurable]]: false Dictionary Elements => [[value]]: '#jsconfeu' [[Writable]]: false [[Enumerable]]: false [[Configurable]]: false
-
数组优化没有太大作用,后续赋值都基于这种比较浪费空间的
Dictionary Elements
结构,所以不要用 Object Api 操作数组
-
-
针对 JS 引擎去进行优化,就是在 JS parser 和 JS optimizing Compiler 尽可能对 JS 代码提效
-
预解析进程下载到 JS 引擎之后,没有进行异步属性设置的 js 脚本会立即开始执行并开始进行解析
-
Shapes
-
Shapes 并不是原型链, 原型链是面向开发者的概念, Shapes 是面向 JS 引擎的概念
const a = {} const b = {} const c = {} // 三者虽然没有关联,但是由于都是进行Shape(empty)初始化,所以共享一个Shape
-
理解引擎的概念有助于我们站在语法层面对立面思考问题, 创建对象方式的异同会进行思考
const a = {} const b = new Object() const c = Object.create(null) const d = new f1()
-
站在 JS 引擎优化角度去考虑,JS 引擎更希望我们通过
const a = {}
这种看似最没有难度的方法创建对象,因为他们可以共享一个 Shape,其他方式混用,虽然逻辑上做到了优化,但是阻碍了 JS 引擎做自动优化,不一定在性能上起到大作用。
-
-
Inline Caches
- 对象级别的优化已经很极限了,工程代码也没有机会帮 JS 引擎做的更好,但是请不要对数组使用 Object API,尤其是 defineProperty,因为 JS 在存储数组元素的时候使用
Dictionary Elements
进行存储,对象使用的是 Elements 单独管理,Elements 结构可以共享PropertyDescriptor
- 不过也有不可避免的地方,如果使用 Object.defineProperty 监听数组变化时,就不得不破坏 JS 引擎渲染了
- 实践中,使用 Proxy 监听数组变化,不会改变 Elements 的结构,这也侧面证明
proxy
监听的对象变化比Object.defineProperty
更优,因为后者会破坏 JS 引擎对数组做优化。
- 尽量以相同方式初始化对象,这样会生成比较少的
Shapes
,const a = {}
这种形式创建多个对象可以共享一个 Shape - 不要混淆对象的
propertyKey
和数组的下标,虽然都是类似的结构存储,但是 JS 引擎对数组下标做了 Dictionary Elements 的优化
- 对象级别的优化已经很极限了,工程代码也没有机会帮 JS 引擎做的更好,但是请不要对数组使用 Object API,尤其是 defineProperty,因为 JS 在存储数组元素的时候使用