前端精读周刊深度解析:JS 引擎属性访问优化之 Shapes 与 Inline Caches
发布时间:2026/10/2 8:08:06来源:尧图网络
文档技术博客教程【免费下载链接】weekly前端精读周刊。帮你理解最前沿、实用的技术。项目地址https://gitcode.com/GitHub_Trending/we/weekly点击查看免费下载本篇技术指南基于《前端精读周刊》第 62 期 《JS 引擎基础之 Shapes and Inline Caches》系统讲解 JS 引擎从字节码到机器码的执行链路以及引擎为加速对象属性访问而设计的两大核心机制——Shapes形状与 Inline Caches内联缓存。读完本文你将理解解释器与优化编译器如何分工、引擎为何把对象结构与属性值分离存储、属性查找如何被缓存提速以及这些底层原理如何指导日常编码以相同方式初始化对象、避免用Object.defineProperty操作数组、谨慎使用会破坏 Shape 复用的赋值方式。1 引言JS 引擎的完整执行链路在深入 Shapes 之前先建立 JS 代码从源码到执行的全局视图。JS 的运作机制可以分为两步AST 分析语法分析与引擎执行运行阶段。JS 源码首先通过 parser分析器转化为 AST抽象语法树再经过 interpreter解释器解析为 bytecode字节码。为了提高运行效率optimizing compiler优化编译器负责将热路径代码生成为 optimized code优化后的机器码。本文的主要内容从 AST 之后说起。值得一提的是解析环节本身也有优化空间并不是所有函数都需要在初始化时被完整解析主流浏览器都实现了 Lazy Parsing延迟解析只对最外层函数全量编译内部函数仅在调用时才解析相关细节可参考同仓库第 100 期《V8 引擎 Lazy Parsing》 的专门讲解。2 JS 的解释器与优化编译器先点火再提速JS 代码可能在字节码或者优化后的机器码状态下执行生成字节码速度很快而生成机器码就要慢一些。这是解释器与优化编译器之间最根本的取舍——解析时间 vs 执行效率。V8Chrome / Node.js将 interpreter 称为Ignition点火器将 optimizing compiler 称为TurboFan涡轮风扇发动机。可以理解为将代码先点火启动后逐渐进入涡轮发动机提速代码先快速解析成可执行的字节码在执行过程中利用执行中获取的数据比如执行频率将一些频率高的方法通过优化编译器生成机器码以提速。不同引擎选择了不同的多级优化策略引擎所属优化编译器配置优化失败回退目标Ignition TurboFanV8Chrome、Node.js解释器 单个优化编译器回退到字节码两个优化编译器MozillaFirefox先将字节码优化为部分机器码再依据运行时数据生成高度优化的机器码回退到部分优化的机器码两个优化编译器ChakraEdge第二个最终优化编译器同时接收字节码与部分优化机器码产生的数据回退到第一步的字节码而非第二步三个优化编译器JSCSafari、React Native优化一步步渐进回退到第一步部分优化的机器码为什么不同前端引擎会使用不同的优化策略呢原因在于JS 要么使用解释器快速执行生成字节码要么优化成机器码后再执行但优化消耗的时间并不总是小于字节码低效运行损耗的时间所以有些引擎选择了多个优化编译器逐层优化尽可能在解析时间与执行效率中找到一个平衡点。笔者不同前端引擎对 JS 优化方式大同小异。可以结合第 22 期《V8 引擎特性带来的 JS 性能变化》 中多态函数的性能问题一节理解当函数或对象存在多种类型参数时优化收益有限而单态单一结构函数性能大幅提升——这正与本文要讲的 Shape 复用息息相关。3 JS 的对象模型以字符串为 key 的字典JS 是基于面向对象的语言那么 JS 引擎是如何实现 JS 对象模型的呢他们用了哪些技巧加速访问 JS 对象的属性和解析器、优化器一样大部分主流 JS 引擎在对象模型实现上也很类似。ECMAScript 规范确定了对象模型就是一个以字符串为 key 的字典除了其值以外还定义了Writeable、Enumerable、Configurable这些配置表示这个 key 能否被重写、被遍历访问、被重新配置。虽然规范定义了[[]]双括号的写法但这些属性描述符不会暴露给用户暴露给用户的是Object.getOwnPropertyDescriptor这个 API通过它可以拿到某个属性的完整配置Object.getOwnPropertyDescriptor({ x: 1 }, x); // { value: 1, writable: true, enumerable: true, configurable: true }数组是对象的特殊场景在 JS 中数组是对象的特殊场景。相比对象数组拥有特定的下标根据 ECMAScript 规范数组下标的长度最大为 2³²−1即 4294967295。同时数组拥有length属性length只是一个不可枚举、不可配置的属性在数组赋值时length会自动更新数值例如arr[2] 1会把length更新为 3直接给arr.length 0赋值可以清空数组。所以数组是特殊的对象整体结构完全一致。这决定了后续引擎对数组存储可以沿用对象优化的思路同时针对下标做额外优化见第 6 节。4 属性访问效率优化一Shapes属性访问是最常见的操作所以 JS 引擎必须对属性访问做优化。最核心的技巧就是Shapes。Shapes 的本质结构与数据分离JS 编程中给不同对象相同的 key 名很常见访问不同对象的同一个propertyKey也很常见const object1 { x: 1, y: 2 }; const object2 { x: 3, y: 4 }; function logX(object) { console.log(object.x); // ^^^^^^^^ } logX(object1); logX(object2);这时object1与object2拥有一个相同的shape。拿拥有x、y属性的对象来看如果访问object.yJS 引擎会先找到 keyy再查找[[value]]。如果将属性值也存储在 JSObject 中像object1、object2这样结构相同的对象就会出现许多冗余数据每个对象都要重复存一份属性名 → 位置的映射。因此引擎单独存储Shape与真实对象隔离Shape描述对象的结构——有哪些字段key、每个字段对应的存储下标offsetJSObject只按下标存储实际的值。这样具有相同结构的对象可以共享同一个Shape。所有 JS 引擎都是用这种方式优化对象但并不都称为Shape例如 V8 中对应的实现称为Map这一点在第 7 节与第 63 期《React 的多态性》 中会用到%HaveSameMap来验证。Transition chains链式生成新 Shape如果给一个对象增加了 keyJS 引擎如何生成新的Shape呢这种Shape链式创建的过程称为Transition chains以const object {}; object.x 5; object.y 6;为例开始创建空对象时JSObject 和 Shape 都是空的当为x赋值5时在 JSObject 下标0的位置添加了5并且Shape指向了拥有字段x的Shape(x)当赋值y为6时在 JSObject 下标1的位置添加了6并将Shape指向了拥有字段x和y的Shape(x, y)。而且可以再优化Shape(x, y)由于被Shape(x)指向所以可以在自身中省略x这个属性的描述只记录增量字段y从而进一步节省内存。笔者这里说的主要是优化技巧。可以看出JS 引擎在做架构设计时没有考虑优化问题而是在架构设计完后再回过头对时间和空间进行优化这是架构设计的通用思路。Transition trees没有连续父 Shape 的情况如果没有连续的父Shape比如分别创建两个对象const object1 {}; object1.x 5; const object2 {}; object2.y 6;这时要通过Transition trees来优化可以看到Shape(x)、Shape(y)分别继承自同一个Shape(empty)形成一棵以空 Shape 为根的树。当然也不是任何时候都会创建空Shape比如下面的情况const object1 {}; object1.x 5; const object2 { x: 6 };由于object2并不是从空对象开始它通过字面量一次性初始化所以并不会从Shape(empty)开始继承而是直接创建自己的Shape(x)分支。这一细节直接决定了 Shape 能否被共享字面量一次性初始化与先建空对象再逐字段赋值的初始化路径不同产出的 Shape 链就不同。第 63 期《React 的多态性》 用 V8 原生函数%HaveSameMap(a, b)验证了这一点// transition-trees.js let a { x: 1, y: 2, z: 3 }; let b {}; b.x 1; b.y 2; b.z 3; console.log(a is, a); console.log(b is, b); console.log(a and b have same map:, %HaveSameMap(a, b)); // false通过node --allow-natives-syntax test.js执行结果是falsea与b初始化方式不同引擎无法为它们共享 Shape。5 属性访问效率优化二Inline CachesInline Caches大概可以翻译为局部缓存JS 引擎为了提高对象查找效率需要在局部做高效缓存。比如有一个函数getX从o.x获取值function getX(o) { return o.x; }JSC 引擎生成的字节码结构是这样的get_by_id指令用于获取arg1参数指向的对象的x属性并存储在loc0第二步则返回loc0。当执行函数getX({ x: a })时引擎会在get_by_id指令中缓存这个对象的Shape。这个对象的Shape记录了自己拥有的字段x以及其对应的下标offset第一次执行get_by_id时引擎从Shape中查找 keyx对应的下标 offset这就是o.x的完整查找过程但一旦找到引擎就会将Shape保存的offset直接缓存到get_by_id指令中下次访问o.x时只要对象的Shape与缓存中记录的一致引擎直接从指令中缓存的 offset 命中值跳过 Shape 查找这一步。这个缓存在指令中的下标就是Inline Cache。可以把它理解为函数调用点级别的 Shape 命中缓存Shape 相同 → 直接取偏移量Shape 不同多态场景→ 回退到完整的 Shape 查找流程。这也解释了为什么保持对象结构一致单态能让引擎优化收益最大化。6 数组存储优化Elements 与 Dictionary Elements和对象一样数组的存储也可以被优化而由于数组的特殊性不需要为每一项数据做完整的配置。比如这个数组const array [#jsconfeu];JS 引擎同样通过Shape与数据分离的方式存储将数组的值单独存储在Elements结构中。因为数组元素通常都是可读、可配置、可枚举的共享同一份 PropertyDescriptor属性描述符所以并不会像对象一样为每个元素单独保存配置存储非常紧凑。但如果是这种例子// 永远不要这么做 const array Object.defineProperty([], 0, { value: Oh noes!!1, writable: false, enumerable: false, configurable: false });JS 引擎会存储一个Dictionary Elements类型为每个数组元素单独做配置。这样数组的优化就没有用了后续的赋值都会基于这种比较浪费空间的Dictionary Elements结构底层以 HashTable 方式为每个元素独立存储描述符。所以永远不要用Object.defineProperty操作数组。第 239 期《JS 数组的内部实现》 从 V8 视角补全了数组存储优化的更多细节可作为延伸阅读数组内部类型分为PACKED_*连续有值与HOLEY_*存在孔洞、SMI32 位整型与DOUBLE浮点等组合类型降级不可逆PACKED_SMI_ELEMENTS遇到大下标越位写入会降为HOLEY_*插入浮点会降为DOUBLE插入字符串/函数会兜底到HOLEY_ELEMENTS数组元素与Elements结构分离正是本文所述Shape 与数据分离思想在数组上的体现当数组长度很大如arr[3000] 4时V8 会调用ShouldConvertToSlowElements切换到字典模式Dictionary Elements以节省连续内存但会带来额外的查询开销。7 实战结论两件必须注意的事通过对 JS 引擎原理的认识可以总结出两条代码层面的注意事项尽量以相同方式初始化对象因为这样会生成较少的Shapes让更多对象共享 ShapeInline Caches 的命中率也随之提升。不要混淆对象的propertyKey与数组的下标虽然都是用类似的结构存储但 JS 引擎对数组下标做了额外优化Elements 结构把数组当对象操作如Object.defineProperty逐项配置会触发 Dictionary Elements 降级破坏优化。8 深入精读Shapes 不是原型链需要特别说明的是Shapes 并不是原型链。原型链是面向开发者的概念决定属性查找的继承顺序而 Shapes 是面向 JS 引擎的概念决定属性如何紧凑存储与快速寻址。比如如下代码const a {}; const b {}; const c {};显然对象a、b、c之间没有任何原型关联但它们共享同一个空 Shape。站在引擎对立面思考创建对象的方式理解引擎的概念有助于我们站在语法层面对立面的角度思考问题。在 JS 学习阶段我们会执着于思考如下几种创建对象方式的异同const a {}; const b new Object(); const c new f1(); const d Object.create(null);比如上面四种情况我们要理解在什么情况下用何种方式创建对象性能最优。但站在 JS 引擎优化角度去考虑JS 引擎更希望我们都通过const a {}这种看似最没有难度的方式创建对象因为可以共享 Shape。而与其他方式混合使用可能在逻辑上做到了优化但阻碍了 JS 引擎做自动优化可能会得不偿失。监听数组变化为什么 proxy 优于 defineProperty对象级别的优化已经很极致了工程代码中也没有机会帮助 JS 引擎做得更好。值得注意的是不要对数组使用Object对象下的方法尤其是defineProperty因为这会让 JS 引擎在存储数组元素时使用Dictionary Elements结构替代Elements而Elements结构是共享PropertyDescriptor的。但也有难以避免的情况比如使用Object.defineProperty监听数组变化时就不得不破坏 JS 引擎优化。而笔者写 dob 的时候使用proxy监听数组变化这并不会改变Elements的结构。所以这也从另一个侧面证明了使用proxy监听对象变化比Object.defineProperty更优因为Object.defineProperty会破坏 JS 引擎对数组做的优化。延伸Shapes 与 Redux 多态性本文的 Shape 概念直接引出了第 63 期《React 的多态性》 讨论的工程问题Redux 的 reducer 每次 dispatch 都会基于Object.assign({}, state, ...)或解构语法生成新的 store 对象。由于新对象从{}空 Shape 开始构建与原 store 的 Shape 不同全局 store 在频繁更新时无法复用 Shape、无法享受 Inline Caches 优化。文中建议所有对象赋值统一使用Object.assign以保持初始化路径一致从而让引擎可以做 Shape 优化let a Object.assign({}, { x: 1, y: 2, z: 3 }); let b Object.assign({}, a); console.log(a and b have same map:, %HaveSameMap(a, b)); // true需要注意的是这类优化只影响 JS 运行环境中的一小部分因素应仅在确有必要时调整不必为此大规模重构。9 总结本文主要介绍了 JS 引擎两个核心概念Shapes与Inline Caches。Shapes把对象结构与属性值分离存储结构相同的对象共享 Shape并通过 Transition chains / Transition trees 管理对象的动态增删字段是引擎对象模型优化的基石Inline Caches在get_by_id等指令中缓存 Shape 对应的 offsetShape 相同时直接命中偏移量跳过查找过程数组优化数组元素使用共享描述符的 Elements 结构一旦被Object.defineProperty等操作破坏将降级为浪费空间的 Dictionary Elements。通过认识 JS 引擎的优化方式在编程中需要注意以下两件事尽量以相同方式初始化对象因为这样会生成较少的Shapes不要混淆对象的propertyKey与数组的下标虽然都是用类似的结构存储但 JS 引擎对数组下标做了额外优化。本系列文章收录于《前端精读周刊》前沿技术 目录配套阅读 63.精读《React 的多态性》、239.精读《JS 数组的内部实现》、100.精读《V8 引擎 Lazy Parsing》 可构建对 JS 引擎优化体系的完整认知。赞分享文档技术博客教程【免费下载链接】weekly前端精读周刊。帮你理解最前沿、实用的技术。项目地址https://gitcode.com/GitHub_Trending/we/weekly点击查看免费下载相关推荐如何利用Supabase-kt在iOS平台构建跨平台应用SwiftUI与Kotlin的完美融合如何利用Supabase kt在iOS平台构建跨平台应用SwiftUI与Kotlin的完美融合 Supabase kt是一个强大的Kotlin多平台客户端库30 分钟搭好一个 ESP32 温湿度光照监测节点硬件不到 150 元30 分钟搭好一个 ESP32 温湿度光照监测节点硬件不到 150 元 昨晚回家卧室又闷又干空调到底有没有在制热、绿植盆土干了没有全靠感觉。这类人不在嵌入式物联网驱动开发前端精读周刊React性能优化案例分析前端精读周刊React性能优化案例分析 引言为什么你的React应用越来越慢 你是否遇到过这样的情况随着项目迭代React应用越来越卡顿列表滚动不流文档技术博客教程上一篇5大高效解决方案深度解析 oh-my-posh 终端美化配置难题下一篇FreeCAD插件安装终极指南从零到精通的完整解决方案创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
网站建设高端定制企业官网