ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

V8 源码视角下的 JavaScript 对象模型:Primitive、原型链与 ES6 类实现导读

V8 源码视角下的 JavaScript 对象模型:Primitive、原型链与 ES6 类实现导读 V8 源码视角下的 JavaScript 对象模型Primitive、原型链与 ES6 类实现导读【免费下载链接】v8The official mirror of the V8 Git repository项目地址: https://gitcode.com/gh_mirrors/v81/v8本文面向 V8 开发者与对 JS 引擎实现感兴趣的同学基于 docs/javascript-overview.md 展开从 JavaScript 的两大值类别Primitive 与 Object出发结合 V8 内部的Map、JSObject、HeapNumber等核心数据结构讲清原型链的运行时表现、Foo.prototype与foo.__proto__的本质区别并剖析 ES6 class 语法糖在引擎层面的等价实现。读完本文你将具备以引擎视角阅读 V8 对象系统源码如 src/objects/js-objects.h、src/objects/map.h所需的完整知识背景。一、JavaScript 的两大值类别Primitive 与 ObjectJavaScript 中所有值都可以归入两大类原始值Primitives与对象Objects。这一划分不仅是语言层面的规范约定也直接决定了 V8 在堆上如何表示和存储这些值。1. 原始值Primitives原始值具有两个关键特征不可变Immutable原始值本身无法被修改任何修改操作都会产生一个新值按值传递Passed by value赋值或传参时复制的是值本身而不是引用。ECMAScript 规范中原始值包括undefined、null、booleantrue/false、number、string、bigint、symbol。注意一个常见的坑规范名称是大写形式如Undefined、Boolean而 JS 的typeof运算符返回的是小写字符串如undefined、boolean。从 V8 实现角度看原始值的存储方式并不统一这正是理解 V8 性能行为的关键SmiSmall Integer小整数直接存放在指针字pointer word内部无需堆分配访问开销极低。V8 利用指针对齐空出的低位比特来标记 Smi 类型这是一种典型的tagged pointer技巧其他原始值分配在堆上例如超出 Smi 范围的数字会退化为堆上的HeapNumber对象字符串则由String及其子类SeqString、ConsString、SlicedString等表示。src/objects/heap-number.h 中的注释明确写道The HeapNumber class describes heap allocated numbers that cannot be represented in a Smi (small integer)。也就是说当数字无法用 Smi 表示超出 31/32 位整数范围、是浮点数、NaN 等时V8 才把它提升为堆上分配的HeapNumber其内部以double value()的形式保存真正的数值。这也是为什么数字在 V8 里有时是 Smi、有时是 HeapNumber——同一段 JS 代码在不同阶段可能产生不同表示而这种表示差异会直接影响垃圾回收压力与属性访问性能。2. 对象Objects对象的特征恰好与原始值互补可变Mutable属性可以增删改按引用传递Passed by reference赋值或传参传递的是对象引用。JavaScript 的判定规则非常简洁任何不是原始值的值都是对象包括数组Array和函数Function。在 V8 内部对象统一由JSObject及其子类表示。从源码继承关系看src/objects/js-objects.hHeapObject是所有堆对象的基类JSReceiver继承自HeapObjectsrc/objects/js-objects.h它表示所有可以接收属性访问的对象JSObject继承自JSReceiversrc/objects/js-objects.h是普通 JS 对象的直接表示JSProxy同样继承自JSReceiversrc/objects/js-proxy.h内部持有target与handler两个槽位用于代理语义此外还有JSGlobalObject全局对象、JSObjectWithEmbedderSlots带嵌入器槽位的对象用于 C 嵌入场景等专门子类。需要特别警惕的一个陷阱是原始值包装对象Primitive wrapper objects例如new String(foo)、new Number(42)、new Boolean(true)。它们虽然名字里带着原始值类型但本质是真正的对象运行时为JSPrimitiveWrapper之类的堆对象拥有属性和方法与原始值foo、42、true完全不同。typeof new String(foo)返回object而非string这是区分它们最简单的手段。二、原型链V8 如何实现 JavaScript 的原型继承JavaScript 采用原型继承Prototypal Inheritance模型每个对象都有一个指向另一个对象的隐藏链接这个被链接的对象称为它的原型prototype语言规范中写作内部槽[[Prototype]]。1. 属性查找的完整链路当你访问一个对象的属性时引擎执行的查找顺序是先在对象自身上查找未找到则在其原型上查找再未找到继续在原型的原型上查找……由此形成一条原型链Prototype Chain链条终结于null通常情况下Object.prototype的[[Prototype]]就是null。这条链是 JS 继承与方法复用的底层机制Array.prototype上的push、map等方法正是通过原型链被每个数组实例共享到的。2.Foo.prototype与foo.__proto__的区别这是最容易混淆的一组概念务必区分清楚Foo.prototype是构造函数Foo自身的一个属性。它指向的那个对象将作为通过new Foo()创建的所有实例的原型foo.__proto__是实例foo上的访问器getter/setter。它暴露的是foo实际继承到的原型对象二者通过new关联起来如果foo new Foo()那么foo.__proto__ Foo.prototype恒成立。用一张图可以直观看出四者构造函数Foo、Foo.prototype、实例foo、Object.prototype的关系digraph G { rankdirTB; node [shaperecord]; edge []; Foo [labelConstructor: Foo]; Foo_Proto [labelFoo.prototype]; foo [labelInstance: foo]; Obj_Proto [labelObject.prototype]; null_node [labelnull]; Foo - Foo_Proto [label.prototype]; Foo - foo [labelnew Foo, styledashed]; foo - Foo_Proto [label[[Prototype]]]; Foo_Proto - Obj_Proto [label[[Prototype]]]; Obj_Proto - null_node [label[[Prototype]]]; }图中可以看到两个维度的链接Foo - Foo.prototype构造函数与其原型对象之间的.prototype显式属性与foo - Foo.prototype - Object.prototype - null实例沿[[Prototype]]一路向上的原型链。3.__proto__与 V8 的实现细节几点实现层面的重要事实__proto__本质是挂在Object.prototype上的访问器属性accessor property即 getter/setter它负责把内部的[[Prototype]]链接暴露给 JS 代码现代 JavaScript 规范更推荐使用Object.getPrototypeOf()与Object.setPrototypeOf()来读写原型而非直接操作__proto__在 V8 中原型指针存储在对象的Map里。也就是说Map不仅描述对象的形状属性布局还记录着对象的原型。因此修改一个对象的原型会改变它的Map最后一点至关重要。Map即隐藏类Hidden Class是 V8 对象系统的心脏。查看 src/objects/map.h 中关于Map对象布局的注释约第 180253 行可以看到Map的关键字段依次包括instance_size实例大小instance_type实例类型区分JSObject、String、HeapNumber等bit_field包含is_callable、is_constructor、is_undetectable等布尔标志bit_field3包含is_prototype_map该 Map 是否作为某个原型对象使用、is_dictionary_map是否退化为字典模式、is_deprecated、is_extensible等[prototype]指向该 Map 所代表对象的原型即[[Prototype]]的真实存储位置[constructor_or_back_pointer_or_native_context]记录构造函数或原生上下文[prototype_validity_cell]原型有效性单元用于优化当原型变化时会使依赖它的内联缓存失效如果该 Map 是原型 Map则持有[prototype_info]否则持有[raw_transitions]记录形状迁移。正因为原型存放在Map中V8 才能把对象形状 原型打包成稳定的编译期知识只要对象的Map不变TurboFan 等优化编译器就可以放心地对属性访问进行内联缓存与去虚拟化。而一旦通过__proto__或Object.setPrototypeOf()改了原型Map就会变化甚至触发从快属性模式到字典模式的降级相关优化随之失效——这是实际工程中应当尽量避免动态改原型的原因之一。从源码注释也能看到V8 在 src/objects/transitions.h 与 src/objects/map-updater.h 中专门处理了通过__proto__访问器设置原型时产生的 Map 迁移transition逻辑例如对象字面量{__proto__: foo, a: 1}与随后赋值产生的迁移图。三、ES6 Class原型继承的语法糖与其等价形式ES6 引入的class语法主要是对既有原型继承模型的语法糖syntactic sugar并没有引入新的继承语义——class背后依然是构造函数、prototype对象与[[Prototype]]链接的组合。考虑下面的类定义class Animal { constructor(name) { this.name name; } speak() { console.log(${this.name} makes a noise.); } } class Dog extends Animal { speak() { console.log(${this.name} barks.); } }在引擎层面这段代码大致等价于传统的构造函数写法function Animal(name) { this.name name; } Animal.prototype.speak function() { console.log(this.name makes a noise.); }; function Dog(name) { Animal.call(this, name); } Dog.prototype Object.create(Animal.prototype); Dog.prototype.constructor Dog; Dog.prototype.speak function() { console.log(this.name barks.); };逐行解读等价关系class Animal { constructor(name) {...} }对应function Animal(name) {...}——类的构造器就是普通构造函数类体内的speak() {...}方法定义等价于Animal.prototype.speak function() {...}——类方法全部挂载在prototype对象上这正是实例通过原型链共享方法的原因class Dog extends Animal对应两件事Animal.call(this, name)在子类构造函数中显式调用父类构造函数完成初始化Dog.prototype Object.create(Animal.prototype)把子类的prototype对象挂到父类prototype之下从而建立Dog.prototype - Animal.prototype - Object.prototype - null的原型链Dog.prototype.constructor Dog恢复constructor字段Object.create产生的对象默认不指向Dog。需要注意class语法与手写构造函数并不完全等价class声明的类不可直接以普通函数方式调用必须new类体内的方法默认不可枚举non-enumerable且类声明存在暂时性死区等差异。但从对象模型角度看二者共享完全相同的底层机制——prototype对象 [[Prototype]]链接。结合前文源码可以看到这套机制在 V8 中的落点Map的[constructor_or_back_pointer_or_native_context]字段记录了对象与其构造函数的关联src/objects/js-function.h 中关于__proto__的注释也印证了构造函数实例的原型即Foo.prototype将在new时被设置为实例的__proto__这一流程。四、深入 V8 对象系统相关源码文件导读理解上述概念后可以按图索骥深入阅读 V8 源码。下表列出与本文主题直接相关的核心文件及其作用文件路径核心内容src/objects/js-objects.hJS 对象体系定义JSReceiver、JSObject及其众多子类JSGlobalObject、JSObjectWithEmbedderSlots等src/objects/map.hMap隐藏类定义对象形状、[prototype]原型指针、bit_field3标志位is_prototype_map、is_dictionary_map等src/objects/js-proxy.hJSProxy定义target与handler槽位对应 JSProxy语义src/objects/heap-number.hHeapNumber定义堆上分配的、无法用 Smi 表示的浮点/大整数src/objects/transitions.hMap 迁移transition机制含通过__proto__设置原型时的处理注释src/objects/map-updater.hMap 更新器处理__proto__赋值等引发的 Map 迁移图阅读建议先读 src/objects/map.h 中Map对象的布局注释约第 180253 行建立对象 Map描述形状与原型 数据字段的心智模型再到 src/objects/js-objects.h 查看JSObject的类声明与属性访问器接口最后回到 JS 侧用Object.getPrototypeOf()、Object.setPrototypeOf()与Map的概念相互印证即可完成从语言语义到引擎实现的贯通。五、小结本文以 docs/javascript-overview.md 为骨架系统梳理了理解 V8 所需的 JavaScript 对象模型知识Primitive 与 Object 的两分法原始值不可变、按值传递其中 Smi 直接内嵌于指针字其余原始值如HeapNumber、String堆分配对象可变、按引用传递统一由JSObject/JSReceiver体系表示原型链机制属性查找沿[[Prototype]]逐级上溯至nullFoo.prototype是构造函数的属性foo.__proto__是实例暴露原型的访问器二者通过new建立foo.__proto__ Foo.prototype的关系V8 实现落点原型指针存放在对象的Map中改原型即改MapMap的bit_field3还携带is_prototype_map等关键标志class语法糖最终落到构造函数、prototype对象与Map的constructor字段之上。掌握这套模型是进一步阅读 V8 对象系统、属性访问优化内联缓存/隐藏类、TurboFan 类型推断乃至 GC 相关源码的必备前提。【免费下载链接】v8The official mirror of the V8 Git repository项目地址: https://gitcode.com/gh_mirrors/v81/v8创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表