收藏文章 楼主

「目前全网唯一&2万字长文」从JS上下文到Chromium源码的极限拉扯!兄弟姐妹们接好了!!

版块:网站建设   类型:普通   作者:小羊羔links   查看:675   回复:0   获赞:0   时间:2022-01-24 21:53:03

本篇文章创作背景

自从我 对C语言有过编译原理上的实践经验 && 对js和浏览器的表层(非chromium源码级我都统称表层) 有了更深的理解后,对于JS及其相关的很多事物愈发的产生了疑惑,比如"执行上下文"、"渲染流水线"、"浏览器多进程到底怎么玩的(ipc等)"等等,网上现有的理论(包括ES官方[1] ,已经不能解决我产生的各种疑惑了。我需要的是chromium源码级的解释!!

我想先解决"执行上下文"等疑惑,又奈何,截止2021.11.1(我得严谨一点嘛,世事难料,我们永远不知道下一刻会发生什么,更何况过去了一个月呢 我竟然没有搜到一篇关于"执行上下文"的源码级解释(我需要的不是八股文也不是ES官方规范呐 。准确说是关于Chromium源码解释的资源本身就太少太少太少了,????

为什么要读源码呢?大家要先明白,ES规范的官方标准[2],它只是一个理论标准。它也是在某个技术实现后,才出的标准。所以最终实现上是怎样的婀娜多姿,只有相应开发者知道。其他人只能通过阅读源码窥得一二。而且源码中充斥着大量的最佳实践,以及为了极限性能而各种"骚操作"(导致难以阅读),可以瞻仰瞻仰。

没辙了,由于自身强烈的强迫症,我只好硬着头皮自己上了(虽然只有大一的C基础,也没学过C++,并且我也只是个仍在实习的小FEer。但是!这并不能阻挡我探索chromium的决心,Fighting!Fighting! 。????

经过一个多月(每天完成公司任务后,自己加的2 3hour 对chromium的折腾(虽然本地都准备好了,但未能在本地调试。于是我在 chromium[3] 【需要科学上网】,干巴巴的读。文末对chromium的阅读经验中会解释为何未能调试???????? ,于2021.12.5 10:24 开始创作这篇文章。

打个不恰当的比方 如果其他矿工无暇分享来自chromium这座矿山的"资源",那我愿意学着如何去当一名矿工。戴上小钢帽儿,背上行囊,提起小铁铲儿,把挖到的、探索到的"资源"分享给大家。

Hi 我是本文向导

(: 后来想了想,不太推荐纯小白观看噢,纯小白慎入??。最好是熟悉js&懂一点编译和c++。编译和C++不了解也没关系,了解js就行,其他的我来讲????

首先关于 chromium 和 chrome 的关系大家可自行搜索哈。

“无忌,我教你的还记得多少?”“回太师傅,我只记得一大半”

“ 那,现在呢?”“已经剩下一小半了”

“那,现在呢?”“我已经把所有的全忘记了!”

“好,你可以上了…”

  • 先忘掉你之前学过的js上下文和作用域等知识,包括ES官方说的,也忘掉!我只能说,官方描述的"样子"源码里有对应的体现,但是具体称呼、约定、实现,官方无法强约束。再次强调,是chrome( 各大浏览器厂商)先有的实现,然后ES组织后来才出的官方标准。所以希望你最好和我一起,我们重新探索,重新学习。

本篇文章主要是从chromium源码中的v8(JavaScript引擎,主要内容都是由C++实现,还涉及Chrome自研的Torque语言.tq ,去梳理js上下文&作用域、对象及数组等内容。还会涉及一些编译原理的知识。为了尽量同步最新源码&风格统一,所有截图均为chromium[4]里最新实时截图,就不看我本地代码了。

行文至此,俺懵了,my mind goes completely blank.????。因为这是写给大家看的,"你要我怎么做怎么说,你才能爱我 " ????。

把所有本文涉及的东西都截图出来--不太可能,那样这篇文章体积就太大了。但我会尽量的截图源码。

为了帮助兄弟姐妹萌过渡,我会先宏观介绍一些知识,再结合微观探索细节,最好不要跳着看噢????

?????v8源码宏观铺垫与分析--揭开神秘魔法石

我会先为大家讲解一下各种铺垫知识,来慢慢揭开v8这神秘魔法石的一部分 -- js Context&Scope&others,

注意下文中我画的图、翻译的话、我打的注释希望XDJMM可以一个字一个字的扫一遍,很重要的,我长时间的摸索才得来的经验。不读一下的话,我担心后文你们直接懵了??????????????

????Handle--大智若愚的"憨豆"先生

首先 Handle用于管理v8对象的内存地址,它的本质就是一个指向指针的指针。

重要的前菜--指针介绍

C/C++之所以那么汹涌澎湃、大海无量,我个人认为指针(针哥 占了50%的功劳。

我们的程序、变量等,最终一定是存到物理硬件中的。运行程序一般都是在内存中,分配的空间也在内存中。你要找到某个东西,就一定需要地址,C/C++是偏底层的语言,因为它们能通过指针直接操作内存单元!

指针就是地址,地址就是指针。地址就是内存单元的编号。指针变量就是就是一个存放指针/地址的变量。申请内存空间后的返回的地址都是内存单元的起始地址,然后通过偏移量即可访问具体数据。

指针的几点好处 可以访问硬件、快速的传递数据(通过指针可以直接找到一个复杂的数据结构的起始地址)..

那么为何需要"憨豆"先生呢?

首先Handle应取其句柄含义,起源于Handle-C。我觉得你可以直接理解为,联想到什么了吗?手提袋的柄?打魂斗罗的游戏机手柄?书包上面的柄?无所谓,差不多就那个意思,啥意思?有点拿捏了&&四两拨千斤的韵味。

  • v8中重新实现了一个Handle类,然后我们先来看源码中的一段注释,看看为啥需要"憨豆"先生

大致是说

从 v8 返回的所有对象都必须由垃圾收集器跟踪,为了知道它们是否还活着。又因为垃圾收集器可能会移动对象,直接指向一个对象是不安全的。相反,所有对象都存储在垃圾收集器所知道的句柄中,并在对象移动时更新句柄。Handles应该总是按值传递(地址也是值,一般用16进制)(除了像out-parameters这样的情况--我并不知道这句个out-parameters是指什么,不过对本文应该没影响 ,并且它们不应该在堆上分配。

这有两种类型的句柄,local 和 persistent 类型的句柄。Local类型的句柄是轻量且瞬态的,通常用于local(我理解应翻译为 局部)操作。它们都由HandleScope管理。这意味着当Handles们被创建在有效的HandleScope内部时,一定是位于栈上。要将local句柄传递给外部 HandleScope,必须使用 EscapableHandleScope 及其 Escape() 方法。

本文涉及的源码涉及的代码里都是Local类型句柄,先不用管Persistent

当存储对象跨越多个独立操作时,可以使用持久句柄,并且在不再使用时必须明确释放。

通过取消引用句柄来提取存储在句柄中的对象是安全的(例如,从Local<Object>中提取Object*),该值仍将由幕后的句柄控制,并且相同的规则适用于这些值的句柄。

Type* temp; 这代表的是一个叫temp的变量, 持有一个指向Type类型的指针;temp就是那个指针变量!通过 (*temp) 就可以拿到那个指针/地址所在的内存单元上存储的数据!但是这么做比较危险!因为GC可能移动对象从而导致产生新地址,原地址将会存着啥,谁都不知道。就是说,如果temp指向一个对象,GC换了地址存储这个对象,那开发者拿到的原地址上是啥,谁都不知道了。难道我们开发者不要面子的吗??!!所以得蹦出个Handle,来帮你管理这个对象的地址,你拿着Handle就行。你可以理解为Handle是一个指向指针的指针;一个智能的指针。????

我没讲明白是吧?行,给宝宝我愁的啊,上图!

我讲清楚了吗,兄弟姐妹萌????? 再不理解,俺也没辙啦,就先跳过吧

所以你Get到“憨豆”先生的大智若愚了吧

憨豆先生的几部作品中的片段我仍历历在目,始终觉得他是一位有大智慧的人。

回归正题。难道你没发现 你给js对象添加属性,js数组push元素之后,再去做恒等比较时,结果仍为true

  • 你没想过为啥吗?你总不会认为它不是分配新的内存空间,而是在原有内存后面加内存吧??????虽然对象和数组一开始都会分配固定默认容量的内存,但是你超过这个容量后,就得重新分配内存了。你不会想问为啥得重新分配而不是追加内存 者覆盖其后地址上的数据吧?兄弟姐妹萌。。你们怎么比我还天真呢。稚嫩呐。。真好。????

首先,追加是不可能的。因为物理内存一个挨着一个,你凭什么无中生有呢?你顶多是把x号单元后连着的单元,覆盖上你想要的数据。但是这也不行,为啥?你根本没权利这么干,就算有,你也绝对不能这么干!!

打个不恰当的比方 地铁上,这排5个座位,就剩中间一个了,你女朋友坐了上去。到了下一站,你也上来了,想坐在你女朋友身边。你总不能让它旁边某个人起开,然后给你腾座位吧?凭啥?人家先来的,人家凭啥让给你,而且这是公共场所。至少有一个人会"遭殃"。

但反映到内存上,它可不是这么简单的让开就行,你能知道这块“需要让座”的内存单元正在存储着什么吗?你能知道它是为了什么而存储吗?你有权利动人家吗?你一冲动,可能后面n个内存单元都错了,那就不是一个单元的事儿了!而且这些内存所服务的事情也都会出错,可怕吗?我就问你怕不怕!为了一点点内存,至于这么大动干戈吗。当然不至于!所以才要重新分配内存空间!!也就是你和你女朋友去找一个2连座,然后再享受欢乐时光。

拿数组举例 当你push后,达到扩容条件时,v8就得这么干

  1. 申请新容量的内存空间,如果失败,报错并直接return;
  2. 申请成功,则把旧地址上的数据按序复制到新内存单元上,并把新内存单元的起始地址交给Handle,"憨豆"先生帮你管理;
  3. 无论后续怎么变,新的内存的起始地址都是交给"憨豆"先生;
  4. 你的变量拿着的始终是Handle先生的初始地址(从初始化之后都是 。所以你 === 的比较,结果肯定是true。

这次我讲明白了吧,小哥哥小姐姐?Handle哥帮你做了那么多事,任劳任怨,还得保证你不会出错,你说人聪不聪明?大智若愚

本杰明·巴顿奇事--编译原理科普

实现一门编程语言,实际上就是在实现一个翻译程序--编译器,因为CPU只认识0和1,汇编可以认识指令。

这里以编译型语言为例,我简单描述下编译程序的流程

不断的退化,回归原始至臻,是不是有点本杰明的韵味呢?????

虽然JS是解释型语言,但也有"编译"的部分,采用了JIT技术。大家可自行在掘金搜索介绍v8对js如何解释执行的相关文章。

????百花齐放--v8 部分类关系图概览

可以结合上文和下文内容后再读下图。兄弟姐妹萌!每个方块都是一个类。其实绿色的(我们这次不care 你们不理解也没事,其他颜色的没看懂,也没事;毕竟这个图只是为了大家有个宏观的认识而已,而且后面我会拉扯出非绿色的其中几个模块 -- 就是我们这次要聊的js上下文&作用域。(????????两张图,画了一天多,颈椎痛,裂开)

image.png


????精美包装--js上下文Context概览

上述类图中对js Context我也写了一点描述,是js代码执行的小型沙箱,因此我美其名曰 精美包装

请XDJMM先仔细阅读我的翻译,因为有些话不在原英文中(宝贝们也可读它个20遍,若不理解,没关系,后续我会慢慢"拉扯出来"),然后可以自己去翻译一下。大致是说

JSFunction 是成对的(上下文、函数代码 ,有时也称为闭包(注意 这里不要狭隘了,JSFunction和这个closures不是单纯指大家理解的js函数和闭包,这个JSFunction还包含了很多字节码、优化代码的相关操作等)。Context 对象常常用于表示函数上下文和动态推送“with”上下文( ECMA-262 中的“scope” 。

在运行时,上下文们会构建一个与执行栈并行的栈!(注意注意 这就有提示了,上下文的栈最初不是诞生在执行栈里!! 栈顶的上下文是当前上下文。所有上下文有以下插槽(理解为字段/属性吧)

[ scope_info ]

这是描述当前上下文的作用域信息。它包含静态分配的上下文槽的名称(提前说一下,很多时候我们的变量会被直接放在 context 中),和栈分配的locals。名称需要用于存在“with “eval”时的动态查找,以及为了调试器。

[ previous ] 指向前一个上下文的指针。

[ extension ] 附加数据。此插槽仅在以下情况下可用

ScopeInfo::HasContextExtensionSlot 返回 true。

对于 native 上下文,它包含全局对象。(其实只是Browser把全局对象的指针暴露给了v8,v8又暴露给了native上下文;native上下文你们可以类似理解为你们以前所学的"全局执行上下文")

对于 module 上下文,它包含模块对象。

对于 await 上下文,它包含生成器对象。

对于 var block(varblock这东西太难找了,后面再说)上下文,它可能包含一个“extension object”。

对于 with 上下文,它包含一个“extension object”。

“extension object”用于动态扩展一个带有附加变量的上下文,也就是在'with'结构和'eval'结构的实现中。

例如, Context::Lookup 也搜索扩展对象的属性。

存储扩展对象是这个上下文槽的原始目的,因此得名。

此外,带有草率的 eval 的函数上下文可能会静态地分配上下文槽,来存储要从内部函数(通过静态上下文地址 通过 'eval'(动态上下文查找 访问的局部变量/函数。

native上下文包含用于快速访问native属性的附加插槽。

最后,在和谐的scope氛围下(我淦,我真不知道怎么翻译这个Harmony了),JSFunction 表示顶级脚本将具有 ScriptContext而不是 FunctionContext。所有来自顶级scripts的ScriptContext都被收集在脚本上下文表 ScriptContextTable 中。

所以说,每个Context一定至少有三个共同的槽位 scope_info, previous, extension

  • 那么你猜????,主要信息都在哪儿呢?Bingo!当然一定以及肯定的在scope_info中了啊!!!

所以Context本身是没有大家以前认为的所谓的那些什么 变量环境、词法环境、this、outer;都没有!! 这些东西倒是在Scope中都有涉及!但是在Scope中分得非常细致,所以,请不要叫词法环境,你可以称呼为词法标识。也不要叫变量环境,这些概念都太大,源码中没有对应实现。因为在源码实现中拆分了各种细致的东西。如果你不拆分为各种标识,那么绝对无法应对千奇百怪的各种语法和语义!拥有足够多细腻的信息,你就可以"金刚不坏"了。

  • 现在我们通过判断上下文类型的方法,反向看看都有哪些类型的Context

总共有这10种: native,function,catch,with,debugEvaluate,await,block,module,eval,script

  • 其实Context中已经声明了获取GlobalObject及其代理的方法了


但是!其实最终还是从native_context的扩展对象里的拿的global_object啊,如下

出现了个全局代理,它和全局对象啥关系呢?global_proxy_object.prototype指向global_object,如下

v8期望只有只有全局对象能作为全局代理的原型,所以v8不希望我们改变全局代理对象的原型的指向,否则可能发生意向不到的事,因为很可能破坏虚拟机!!

ps global_object 就是 ES规定的全局对象,也是Browser暴露给v8的window

一人之下万人之上--NativeContext

上述类图中,我已经画了,NativeContext继承自Context,然后他重写了获取global_object方法,并为它又重载了一个方法。但其实还是调用的是Context里的gobal_object(); 自己拿自己的东西还得绕一圈,可还行?行,估计是为了某种安全,chrome这么注重性能的团队,不会做不必要的事情。

716 721在说,722行的方法忽略了是否加载完毕的tag。可以在并发(并发噢,没毛病 情况下安全的读取全局对象,因为在它初始化后就不可变了(指的是指针不可变,即地址不会变了 。722行的方法不能用于heap-refs.cc以外的地方。估计是其他地方如果你瞎调用,可能还没load完,你去操作全局对象就要出事。所以723行来了个重载函数,有标识的时候,就调723的的方法就行了。

并且NativeContext中还有微任务队列的指针你们想知道微任务具体是在什么情况下、什么时候运行的吗?欸,我就是不说,就是玩儿,就是皮。????????

为啥说 "一人之下万人之上",请继续往下看↓↓

跨脚本共享的关键--ScriptContext及其Table

首先ScriptContext和ScriptContextTable都可以通过NativeContext拿到

再来看下面一段注释

就是说ScriptContextTable存放着所有加载完毕的顶级脚本下的顶级词法声明变量(let/const)。在源码中,我看它首先是通过JSFunction(它不是单纯我们说的js的function,它承载着v8对我们整个js的掌控,包括优化信息、字节码等编译后端的底层知识,我目前没继续深入了解JSFunction),拿到native_context,然后native_context 访问ScriptContextTable,遍历上面说的顶级词法变量,表中没有的就存进去,有的就共享。但是注意!!其实script_context仍然会为这些变量开辟一个槽位占位,但是他们共享访问table中对应变量修改的也是table中对应的变量。这就是为什么你在html中引入了多个script标签后,可以跨script去使用顶级let/const的变量的原因。注意,这只是你可以跨脚本调用,本质上这些顶级词法变量是没有被安装到global_object中的!!比如你跨脚本使用顶级词法变量temp 直接用temp, 但你不能用window.temp!!因为它本质上就不在global_object中!!

那为什么没提到var声明的变量呢?首先在源码中,顶级脚本是需要被安装到native_context中的,然后JSFunction会拿到native上下文,继而拿到native上下文扩展对象里的 global_object,之后通过一系列函数,最后遍历脚本中的顶级var声明存储进global_object。所以你既可以跨脚本直接使用var变量,也可以利用window.xxx使用。

关于函数有些复杂,还得看你最初怎么定义的,然后就会有不同的体现,总之思路要点都是一致的 通过JSFunction拿到native_context进而拿到script_context_table 者global_object去操作 者不操作。

还是再贴一点点源码吧,在 NewScriptContext 方法里,框起来的都是经过一堆操作后的入口,每个入口后面又会调N个函数,我贴的其他源码图也是这样,毕竟v8这么大,chromium那么那么大。。

现在是不是觉得native_context几乎权势滔天了?只有JSFunction比他官儿大。当然最大的是v8,v8上面还有Chrome Browser,????????

颤栗吧!不可思议的再次声明let/const!

但是你发现没有,你在控制台的话

为什么第一和第三种模式居然可以通过,第二种和第四种就是错的?其实控制台算是一种调试模式!是Devtools照顾大家的。会在一定情况下触发repl模式作用域,然后你每敲一次回车之后,相当要新建立一个ScriptContext,然后在源码中


就看我框起来的3个地方小方框,就是说,(let和let || const和const) && 此种特殊情况下,就可以重复声明,否则会报相应错误。

????,?? 我为什么要举这么个破玩意儿例子呢?不是来刷存在感的,只是想证明,N多情况都不是我们未经源码考证时可以解释的。其实还有N多小细节,我只是随便举了一个例子而已。希望大家对一切都抱有敬畏之心!

其他Context就不讲了,主体和精髓都是各种Scope,待会scopes里面讲讲就行了。

先有鸡还是先有蛋--context、scope、ast的诞生顺序

context好说,肯定在前,但是难道你不疑惑scope和ast出生的顺序吗?我们以 hello-world.cc 文件为入口来大致分析下,因为它启动后能完成v8最小功能流程。

我先解释下三个我定义的名词,牢记以下

预解析阶段 PreParser,为scope内的各种信息打上标识,生成预解析的scope;

正式解析阶段 Parser,大概率复用预解析生成的scope,然后依据各种信息开始分配变量空间并且更新部分信息及标识!

函数正式执行 上一步的所有该分配的内存分配完才能进入函数正式执行。initializer_postion开始移动,动到合适的区间范围时,才能访问对应的词法变量,否则报错。

39行-40行打错了,不是"进入上下文开始编译运行"而是"进入这个上下文,为编译和运行做准备"

先诞生的v8,然后是v8的隔离容器实例isolate,之后是根上下文context,再进入context,格式化js代码并开始编译它,再将将编译结果执行,然后在c++里打印脚本返回值,后面部分代码我没截图,跟我们关系不大。

  • 现在我们直接去到开始解析程序的入口函数,ParseProgram

542行先初始化了词法分析器scanner并在Initialize方法里扫描出了第一个Token供给语法分析开启DFA(确定的有限自动机--v8是用的很多个switch-case+循环实现的)。然后543行DoParseProgram把isolate和基础的parse_info传进去,Parser开始语法分析,最后返回一个ast的指针给result。所以FunctionLiteral作为类时,千万不要认为它是函数字面量!而是一个ast!!

然后进入DoParseProgram

然后ParseStatementList里面还有个ParseStatementListItem函数,用于解析一条语句

对不同的Token采取不同的应对措施,总之就是一个句子一个句子的解析,同时将不断的更新scope、ast等数据。外面有自动机控制着让它最终解析完,最终生成完整的scope信息,ast信息,context信息。

时间不够,这块我后一阵子才看的,确实没梳理完。但我看得出来 v8对于js的编译前端部分,是词法、语法、语义分析基本同时更新。所以语法分析采用的应该是自上而下的递归下降/预测分析法。 除了第一个Token以外,后续都是边生成Token边做语法分析,而scope等信息,相当于是语义层面,所以语义分析也被同时更新!!并且要不停的判断是否要报错。而之前我为C写的一个小型编译器的时候,采用的是LR分析--自下而上的移进规约,所以第一时间没反映过来v8在干吗????。后面具体v8怎么干我确实没看,不过他们能这么干的首要条件还得是他们的js文法(可以搜搜文法是什么意思)设计的非常优秀!!i了i了?( ′???` )????。

  • 但之前很多人是不是都认为scope在ast之后诞生?看到上文图片中我的红色标记,你应该发现了 scope诞生在ast之前!此时连源代码分词都还没完成,为什么就能在最初就创建scope呢?

上面我写过 Scope构造函数内部首先就SetDefaults()一次,并做其他操作,我们要明白,这只是创建/诞生一个作用域。一开始将scope内所有信息初始化,后续遇到各种情况不断地更新scope的各种信息即可

打个比方

scope是这片大地,一棵ast只是一片大地上的国家罢了

没有??这个初始环境,怎么可能诞生人类?只是地球和人类都在不断成长、改变!

没有华夏大地,哪儿来的中国,又哪儿来的"中国人"之说?

一定是现有了环境铺垫,我们才能诞生于世。环境造就了我们,但我们只能影响环境!除非你想两败俱伤。好好敬畏大自然吧。兄弟姐妹萌!!

而且scope的完整完成也是在ast之前,其实很好理解,以函数为例 ast扫描的最后一个token肯定是'}',但是在扫描到这个'}'前,scope就已经把所有要更新的数据更新完了,你这个'}'相当于是结束那一刻的状态。我们用v8的调试工具d8,打印一段代码,证明一下scope完全体是在ast完全体之前完毕

//shell: d8 --print-ast --print-scopes a.js
//为了保证命令参数不会影响打印顺序,我又
//shell: d8 --print-scopes --print-ast a.js
//结果顺序仍不变,scope就是在ast完全完成前,完全完成的!就不放再多放一次截图了

function a() {
  let b = 3;
  console.log("a");
}
a();
复制代码

总结一下吧,看到这里你是不是明白了

v8创建完context之后,在语法分析前就创建了scope,接着得到第一个Token后,词法、语法、语义分析同时进行,只需要不断更新scope、context、ast等信息即可(因为人有指针,想怎么操作就怎么操作),scope比ast早一点点完全完成,最后再将编译前端生成的结果都放到了FunctionLiteral的实例上,也就是ast。但这个ast真的与众不同啊,它是以"函数"为一个最小单位,(如果你不是函数,那就把你包装一下咯)每个"函数"执行前一刻都会生成对应的完整的scope--分配完各种所需的内存空间了(内部通常嵌套多种scope,也有需要动态的改变信息的情况,后续再看)和一棵ast!

????Scope及其Info--Context家里的顶梁柱

在前文就已经说过了,Context中的精髓槽位ScopeInfo就是描述Scope的,弄懂Scope家族才是关键,所以后文中五颗星的大标题才是重中之中!

丰富的GD地图--ScopeInfo概览

GD地图确实好用,GD打车也好用!我可没收过代言费啊! 呃?GD要不给我点 费?????这么说是因为,ScopeInfo存储着Scope的很多信息,用于在runtime时,快速的找到Scope里的细节信息,像个地图一样。我们先看源码其中一二

总之它描述着Scope里的各种信息、各种flag、各种index,比如,你在哪个上下文,是不是某个类型的Scope,内部Scope调用eval了吗,等等等等信息。。。后面重点了解Scope就好

再补充一点 一个context里一定能找到一个对应的scope-info。但是!但是!一个scope/scope-info存在时,不一定直接对应着一个context!请给我背5遍,麻溜的,GKD,XDJMM;

奇门遁甲之术--Scope概览

因为scopes种类多样,也是各种奇异现象的根源地,而且它还可以让人摸不着头脑的"移动",所以我才说它是奇门遁甲之术,且听我娓娓道来。

  • 相信在"鸡蛋之争"那里大家已经看出来了,作用域一定是静态时就诞生了。内部各种信息是跟随Parser解析不断更新的,但这些操作都是静态下的!!以函数为例,只有当函数执行前一刻,才会去根据对应的scopes信息,分配各种内存(先不算立即初始化的情况,下文再说 ,当一切准备完毕,函数才进入正式执行。

抛开debug相关模式,scope类型大致细分为以下8种

class, eval, fucntion, module, script, catch, block(block_scope是我们常说的块级作用域 , with scope

我们再来看看重点关注对象,"犯罪嫌疑人" -- Scope


大致就是

AST 构造后有个全局不变的策略

对 JavaScript 变量(包括全局属性 的每个引用(即标识符 由 VariableProxy 节点表示。在 AST构造之后和变量分配到内存之前,大多数 VariableProxy 节点都“未解析”,即未绑定到相应的变量(尽管有些在解析时绑定 。变量分配将每个未解析的 VariableProxy 绑定到一个变量并赋予一个位置。请注意,许多 VariableProxy 节点可能引用相同的JavaScript变量(因为ast是静态编译产物,一定是一个小节点对应一个小token。而我们js代码里总在反复用同一个变量嘛,所以就有多个节点也就得有多个VariableProxy,只是它们可能引用同一个变量罢了。 。

AST不能直接引用变量,它必须通过对应的变量代理间接的得到变量的各种信息!而变量其实是被作用域--scopes去维护的。麻烦记一下我说的这句话哟

JS 环境在解析器中使用 Scope、DeclarationScope 和 ModuleScope 表示。DeclarationScope用于能够 真正承载/真正持有 (品味下我对这个词的翻译,这是我结合源码总结出来的。比如 虽然表面上你写了{var a;},但block并不能承载var) “var”声明的任何scope。这包括脚本、模块、eval、varblock(这个小东西我找了很久才找到) 和函数作用域。ModuleScope 进一步特殊化了DeclarationScope。

所以,重点在声明式作用域DeclarationScope,它包括: script、module、eval、varblock、function scope

作用域当然是可以嵌套的!!我们用v8的调试工具d8打印一下

function a() {//function_scope -- a
  {//block_scope
    let a = 3;
  }
  let b = 3;
  console.log("a");
}
a();
复制代码

第一个红方块内少了个 预 字。那里是脚本内顶级函数a预解析。


大家可以看下Scope部分代码。这只是Scope;DeclarationScope是继承的Scope,然后又拓展很多信息。

//Scope构造函数
Scope::Scope(Zone* zone, Scope* outer_scope, ScopeType scope_type)
    : outer_scope_(outer_scope), variables_(zone), scope_type_(scope_type) {
  DCHECK_NE(SCRIPT_SCOPE, scope_type);//凡是DCHECK都不用管,是调试时做的检查,提供一些信息
  SetDefaults();//构造函数中最先做的就是初始化默认值
  set_language_mode(outer_scope->language_mode());//strict模式吗
  private_name_lookup_skips_outer_class_ =
      outer_scope->is_class_scope() &&
      outer_scope->AsClassScope()->IsParsingHeritage();//查找私有名称时需要跳过从外部类查找
  outer_scope_->AddInnerScope(this);//把自己加到outer作用域的inners里
}
复制代码
//默认值都是false or nullptr;就是要么取否,要么指向空指针;nullptr和null不太一样,但不影响大家理解
void Scope::SetDefaults() {
#ifdef DEBUG//意思是 下面这三行在debug模式下才执行
  scope_name_ = nullptr;//作用域名称
  already_resolved_ = false;//已经解析完毕了吗
  needs_migration_ = false;//需要迁移吗
#endif
  inner_scope_ = nullptr;//第一个儿子的作用域
  sibling_ = nullptr;//下一个兄弟的作用域
  unresolved_list_.Clear();//清空未解析的节点

  start_position_ = kNoSourcePosition;//起始位置设为-1
  end_position_ = kNoSourcePosition;//终止位置设为-1

  calls_eval_ = false;//内部调用eval了吗
  sloppy_eval_can_extend_vars_ = false;//宽松模式(就是非strict模式)下调用了eval 能扩展变量吗
  scope_nonlinear_ = false;//非线性作用域吗
  is_hidden_ = false;//被隐藏了吗
  is_debug_evaluate_scope_ = false;//调试时候用到, 不用管

  inner_scope_calls_eval_ = false;//内部作用域有调用eval的吗
  force_context_allocation_for_parameters_ = false;//需要把参数强制分配到context的槽中吗

  is_declaration_scope_ = false;//是不是声明式作用域

  private_name_lookup_skips_outer_class_ = false;//私有名称需要跳过查找, ES新语法 类里变量前加#就会私有化

  must_use_preparsed_scope_data_ = false;//一定要使用预解析的作用域的数据吗
  is_repl_mode_scope_ = false;//是否是repl模式作用域,用于调试(调试概念很大)模式下禁用const内联

  deserialized_scope_uses_external_cache_ = false;//要用外部缓存来反序列化作用域吗

  needs_home_object_ = false;//这是当类继承时,需要用到第一个对象吗
  is_block_scope_for_object_literal_ = false;//是否是为了对象字面量而生成的块级作用域,这里我已经透题了

  num_stack_slots_ = 0;//栈分配的槽的数量
  num_heap_slots_ = ContextHeaderLength();//堆分配的槽的数量,基准数量是ContextHeaderLength

  set_language_mode(LanguageMode::kSloppy);//语言什么模式(有时有strict嘛),默认是草率的/宽松的
}
复制代码
//查找一个变量后返回的结果信息包括 
struct VariableLookupResult {
  int context_index;//你在哪个上下文
  int slot_index;//在存储这个变量的位置上的哪个索引槽
  // repl_mode flag is needed to disable inlining of 'const' variables in REPL
  // mode.
  bool is_repl_mode;//上面说过了
  IsStaticFlag is_static_flag;//是静态吗,用于判断类中static修饰的变量
  VariableMode mode;//变量是 var?let?const?... 声明的吗
  InitializationFlag init_flag;//立即初始化 还是 需要初始化 还是暂时不要初始化呢?
  MaybeAssignedFlag maybe_assigned_flag;//被赋值了吗,这个标识很复杂,很多情况都在修改这个标识
};
复制代码

上面这些信息扫一眼就行了,你们不用记,但我得记???? 再来看看我们的变量,都有哪几种声明的模式

关注前3个就好let const var,第四个跟参数有关,带Dynamic基本都得跟eval/with有关

  • 我们看看v8是怎么定义词法变量的

看到了吗mode<=VariableMode::LastLexicalVariableMode;而LastLexicalVariableMode看上图1266行,它是kConst,而这个enum class VariableMode:uint8_t里面的东西,是从0逐渐+1递增,也就是说,词法变量模式就是let or const声明的变量。

  • 再来说说块级作用域,只要{}里包含let or const声明的变量; 者{}里包含函数字面量的声明; 者是class,它也会生成一个block_scope; 者是for、for-in/of,循环中在圆括号中声明了变量,也会创造块级作用域;这个块就是一个块级作用域。这里见证了函数处于{}下就会被作为let形式处理。还有for-in情况下有词法变量声明。

  • 再来看一段英文


就是说ES6指出,声明式环境分为可变和不可变绑定,可用这两种状态描述 已经初始化和未初始化。(初始化要做的事情就是赋予变量初始值,但前提是变量必须已经被分配了空间。不过对于函数来说,分配空间都是在函数执行前一刻才开始根据Parser正式解析阶段得出的scope信息分配的变量,当一切都准备好时,函数才能正式执行。预解析状态并不会分配内存给变量!!更别提初始化了。预解析只分析信息。并存起来,供正式解析时可能复用。

当访问一个绑定的时候,需要检查初始化标志,但是有8种情况下当变量被分配内存后要把绑定立即初始化(就是说当Parser第一次扫描到这几种情况,立即就为变量分配内存空间,初始化为相应的值,没有就是undefined ,然后导致初始化检查需要被跳过!那8种,你们看图吧。俺累了,写不动了????

就是var变量,函数名字,函数参数,catch里的唯一变量,显示的this和显式的函数实参。

  • 都到这儿了,如果是你你会怎么设计所谓的"暂时性死区"呢?首先源码里当然没这个玩意儿,但人得实现你们说的这个功能对吧。怎么做呢?其实很简单,有了 变量声明模式+初始化标识+判断当前执行到的position是否在这个变量的pos之后就行,pos在Parser做完分析的时候就已经处在每个token和ast_node里了

那个initializer_position就是我所说的"当前执行到的position",let/const声明的变量,在静态解析期间已经存在变量声明表里了,只是还未分配空间没初始化而已。至于你是否可以访问?访不访问的到?只需要借助一些作用域里的信息以及变量自身的信息,就可以采取相应的行为。这其实也是作用域玩法的精髓 他们把所有信息、标识都静态的初始化好了,Parser一步步的解析,慢慢完成词法语法语义分析,这期间不断的更新相应东西(scope、ast、context 的信息及标识就好了

  • 关于两种报错
console.log(aaa);
let aaa = 10;
复制代码

这也是为何报上述两种错误的根本原因了。第一个是因为在作用域中声明了,Parser扫描到了对应节点就会将其声明放到表里(这就是大家所谓的"let/const变量的声明创建被提升了,但是初始化未被提升" ,只是初始化器的位置还没到达aaa之后的位置,所以v8不让你访问。而你直接调用bbb报错未定义,根本原因在于声明表里没有你这个变量。

  • 下述是变量可能被分配到的所在位置,这先忽略,等下面的步步惊心系列你们就能看明白点了
enum VariableLocation {
//变量被分配前, 者是查找global上不存在的属性,就会被法分配到unallocated插槽里
UNALLOCATED
//函数的参数默认被分配到parameter位置, parameter[index]
PARAMETER
//有些情况下局部变量会被存到local的槽中, local[index],这里一定是栈分配的变量
LOCAL
//有些情况,变量必须被分配到context的槽中,此时一定是堆分配的。比如一个变量被分配到了context[3] 这里
CONTEXT
//一般是用于动态查找的变量就会被分配到lookup的插槽里
LOOKUP
//模块导出的变量就被分配到module的插槽里
MODULE
//在脚本上下文的插槽里。关于前文提到的跨脚本引用时用到的
REPL_GLOBAL
//这个不用管
kLastVariableLocation
};
复制代码

(: ????????????裂开,我其实已经不知道我在讲什么了,夜又深了,我有今晚不得不通宵干完的原因!大家先歇息会儿吧,吃吃喝喝????。我时间确实不够了,行文可能已经紊乱,希望后续兄弟姐妹们在评论区帮我指正出来&批评我的不足之处!

??????????步步惊心--从v8源码细节处再探Scope

如果你觉得 scopes 没啥可说的,那我很痛心????。其实上文的一切,都是我为了讲这一节而做的铺垫。就是希望d大家先有个笼统的认识,再来看scopes。保证持续刷新你认知!!

scope三大指针与react fiber的情 意合

额外说一嘴 window、console、setTimeout 这种东西,不是v8原生的,是Chrome Browser 注入给v8的

其实每个scope里有三个重要的指针

  1. outer_scope: 指向父级作用域 -- 对标react fiber的return
  2. inner_scope: 指向第一个子作用域 -- 对标react fiber的child
  3. sibling_scope: 指向下一个兄弟作用域 -- 对标react fiber的sibling

对吧?情 意合!????????????

上文也提到过,作用域可以随便嵌套(栈不爆就行 ,每个scope就是通过这三个指针来建立关系,也需要这三个指针不断配合做一些"增删改查"。

我只想,找到你??--Lookup

我们来看看变量被存储的地方VariableMap

  1. Declare方法是在这个map中存储变量(需要分配空间 。Declare方法里,会先在该map中查找是否有这个变量,有的话就直接返回不再重新分配空间。没有的话,就申请分配空间然后在这个表中插入该变量并配合其他参数信息做相应处理(是否需要初始化)。


为什么先要判断map里是否有这个变量呢?因为 var变量可以重复声明呀!而在v8中,var重复声明的话,会按序继续存在链表里(注意噢!这里没有覆盖关系!v8自己当时也打注释说了,这可能会多浪费一点点性能,因为每个重复的var依然被添加到链表里了 ,到时遍历链表取出对应信息再调用Declare声明并分配空间。所以要先判断该变量是否已经存在于map中。

  1. Lookup方法用于查找变量,仅仅需要变量名和它的hash,找到就返回对应变量,没找到就返回nullptr。


移出和添加变量没啥说的必要了,一点v8对hash的操作都封装好了,传相应参数进去即可。

我讲了Declare和Lookup之后,大家没什么??吗。没有觉得为什么一切都太过顺利、太顺理成章了吗?不觉得很可疑吗?我很负责任的告诉你,一点都不可疑,一点问题都没有,只是有个东西我还没讲,但我得一步步引导大家思考呀。

你觉得按某种说法 在词法环境中找,再去变量环境里找,再怎么怎么样,合理吗?XDJMM,你们觉得合理吗?如果我想找你,我有你手机号我一个电话我就给你打过去了,我要干嘛要折腾啊?难道你不存在这个世上了吗?你断了一切联系吗?如果你真的生在了这个世上,只要你没跟这个世界断开联系,那么一定能快速找到你!这才是符合人性的逻辑!v8完全符合!

什么意思?就是说 在scopes里,一切信息都在静态的时候做好了标记(你哪个作用域的、什么类型声明的变量、初始化了没有。。但也有极少量信息和flag需要在运行时处理 ,事先判断过是否声明冲突(待会讲,这才是我们能放心查找变量的关键所在)等错误,然后又把变量声明&存储的内存也给你准备好了,一切环境、联系,人v8都给你准备好了!你现在,要做的就是告诉map,你要找谁!通过hash直接就帮你找到了。没找到就不存在呗。一个变量一定唯一对应一个hash(如果哈希冲突了v8会解决的 ,因为每块内存地址的物理地址都是独一无二的。

世界和平由我守护,Tiga--解决声明冲突!

"根本赢不了,我听不懂。" -- 大古

兄弟姐妹萌!!难道你们不相信光吗!!为了世界的和平,我只好。。变身迪迦!解决冲突!????(: 这个函数非常重要,检查声明表是否有冲突的。请仔细看我每一行的注释哟!!

Declaration* DeclarationScope::CheckConflictingVarDeclarations(
    bool* allowed_catch_binding_var_redeclaration)
 
{
  if (has_checked_syntax_) return nullptr;//如果该作用域已经被解析完毕,直接返回
  for (Declaration* decl : decls_) {
  //遍历这个声明式作用域名的声明表,这个表里的声明只是承载着一些静态信息啊!
  //可还没有达到上述那个Variable::Declare真实分配出空间的地步呢
  
    //词法和词法性质的变量冲突已经在Parser::Declare中解决了,不过这个Declare可不是我们刚才讲的Declare!!
    //唯一仍然需要解决的冲突就是词法性质的变量和嵌套的var变量。我举个例子吧:
    //function w () { { {var x = 1;} let x = 1; } };w();
    //必须让上述这样的代码报错 标识符x已经被声明
    
    //如果是变量声明(var/let/const) && 是被嵌套的情况下
    if (decl->IsVariableDeclaration() &&
        decl->AsVariableDeclaration()->AsNested() != nullptr) {
        //保存这个变量的原始出生作用域,不是什么"提升变量后的scope"啥的,冲突都还没解决呢,你提升个什么劲儿呢
      Scope* current = decl->AsVariableDeclaration()->AsNested()->scope();
      DCHECK(decl->var()->mode() == VariableMode::kVar ||
             decl->var()->mode() == VariableMode::kDynamic);

      //不断向上层遍历所有的作用域直到声明式作用域,如果在此期间找到了一个词法性质(let/const)的同名变量,就直接把这个变量的声明结构数据返回,然后外层会做相应的处理
      do {
        Variable* other_var = current->LookupLocal(decl->var()->raw_name());
        if (current->is_catch_scope()) {
          *allowed_catch_binding_var_redeclaration |= other_var != nullptr;
          current = current->outer_scope();
          continue;
        }
        if (other_var != nullptr) {
          DCHECK(IsLexicalVariableMode(other_var->mode()));
          return decl;
        }
        current = current->outer_scope();
      } while (current != this);
    }
  }

  if (V8_LIKELY(!is_eval_scope())) return nullptr;
  if (!is_sloppy(language_mode())) return nullptr;

  //能走到这儿,说明声明表里暂时没冲突,但是仍然有情况需要检查,下述都是关于eval的
//如果var声明的是在非严格模式下的eval里,那么这个变量的声明
//会被提升到这个eval的外层中第一个非eval的声明式作用域(这个我在上文已经说过了有哪几种是声明式scope)
//比如function w () { let a = 2; {let m = 3; eval(`var a = 2;`);} } w();这必须要报错
  
  //从当前作用域的outer_scope出发,不断的向外找第一个非eval的声明式作用域,然后
  //把找到的scope的outer_scope的指针赋给end
  //为什么还要一层outer作为end呢?想想?注意下面循环边界条件
  Scope* end = outer_scope()->GetNonEvalDeclarationScope()->outer_scope();

//同样,先遍历变量声明表,如果是词法性质变量,不用管,反正你也不能提升到外层。
//如果是var变量, 并且在向上找到end scope前,你在当前所找的scope里遇到了词法性质的变量,
//这个时候就说明冲突了,就直接return出去了这个变量;然后外层会做相应的处理。
//如果到达end了,都没return,那最后就return一个nullptr,说明scope harmony。我维护了世界和平!!
  for (Declaration* decl : decls_) {
    if (IsLexicalVariableMode(decl->var()->mode())) continue;
    Scope* current = outer_scope_;
    do {
      Variable* other_var =
          current->LookupInScopeOrScopeInfo(decl->var()->raw_name(), current);
          
      if (other_var != nullptr && !current->is_catch_scope()) {
        if (!IsLexicalVariableMode(other_var->mode())) break;
        return decl;
      }
      current = current->outer_scope();
    } while (current != end);
  }
  return nullptr;
}
复制代码

上述总结 var变量声明提升过程中,所经过的作用域中不能存在同名的词法性质的变量(let/cosnt)!!因为它得一层一层往上找。这很符合人性化,你还能瞬间转移不成?直接从地球转移到外太空?宇航员哥哥姐姐登空途中也得慢慢"飞"呀,也不能撞到啥东西呀!向全体宇航人员,敬礼!(: 我从椅子上跳起来敬礼了,该你们了,3 2 1 ,礼毕!)

六边型全能战士--declaration_scope

之所以这么说,大家也看到了,script/module/function/eval/varblock都属于声明式作用域,它的权力也很大。动不动就得找 第一个非eval的声明式作用域,然后在里面处理一些关键操作。那顺便贴一下怎么找吧

简单吗,就这么简单,从当前scope开始,只要不是非eval的声明式作用域就继续找outer。AsDeclaration()也是返回this,其实没变化,就是明确告诉你,这个scope是作为第一个非eval的声明式作用域拉。

  • 再补充个很重要的知识点 以function_scope内为例 静态Parser驱动Scanner逐个Token解析,所以最先添加到声明表里的一定是参数!!默认情况下(所以当然有其他情况拉)把参数声明为var模式,然后继续解析,只要没冲突(我们上面也讲了v8会如何检查冲突 ,就不断的按序把变量声明 Declarations(链表结构)里面,ps 函数字面量形式会按序的为其声明一个var类型的函数名(然后标记上惰性解析 。如果最终相安无事。函数调用前就可以开始分配空间了。那么为这些变量分配空间的顺序呢?
  • 先分配了this的空间(??吧,那个Receiver用于原型的,我在类图里写了 ,然后再为参数分配空间,然后逐一为非参数的内部变量分配空间,最后才是为这个函数本身分配空间(函数本身一定一定是最后一个分配空间的 !

源码证明

只是想告诉你们,不要对目前网上广为流传的"预编译三部曲"深信不疑。其实并不正确,最主要的是大家平时不会写各种复杂场景(其实就是不应该写的代码 ,各种千奇百怪的嵌套,function配合block、with、eval、catch..导致根本没有一种统一的理论能说明到底是怎样,因为只有源码里才有!源码中也确实是耗费了大量的代码去处理各种case。但其实我觉得精髓就是,有足够的信息及其标识 && 有足够的指针,所以chrome只要尽全力的满足人性化地去解决各种case即可。剩下的就是技术实现上的事儿了呗。对吧``

看不见的客人--varblock_scope

????终于该揭秘这位"看不见的客人"了。其他作用域名称大家都能望文生义,但是唯独这个varblock,你个小小东西,你到底是个嘛呢?是{var x;}吗?当然不是!我当初真是找了四五天才在源码里找到。真是隐秘啊????

上述这个formals是指参数列表,就是说参数列表不是简单模式就会创建一个varblock_scope。也就是说关键要找到is_simple为false的时候,那什么时候is_simple是false呢?参数是复杂类型数据?不是! 在预解析的时候会判断 此时是检查函数的参数,上述两种情况表明, 如果你给参数赋予默认值 者用了剩余参数,就是非简单模式。就会创建varblock_scope! 终于!破案了!!????

  • 那这个varblock_scope具体是用来干什么的呢?我们先来看一道前段时间比较??的一道题,我改编了下
var x = 'global X';
function test(
  x = 4,
  y = function (
{
    console.log('in y first,', x);
    x = 'newX from y';
    console.log("in y changed,", x);
  },
) {
  console.log(x); //4
  var x = 2;
  function newFunc(a = 3, b = 2{
    console.log("in newFunc first,", x);
    x = 'newX from newFunc';
    console.log('in newFunc changed,', x);
  };
  y();//in y first, 4;  in y changed, newX from y;
  console.log(x);//2
  newFunc();//in newFunc first, 2;    in newFunc changed, newX from newFunc
  console.log(x);//newX from newFunc
  x = 'middle changed';
  y();//in y first, newX from y;     in y changed, newX from y
  console.log(x);//middle changed
  newFunc();//in newFunc first, middle changed;    in newFunc changed, newX from newFunc
  console.log(x);//newX from newFunc
}
test();
console.log('most outer X, ', x);//most outer X, global X
复制代码

我是先自己写的注释还没看控制台噢,而且我还没上更大难度的题噢。现在我们一起来看看控制台结果

网上现在呼声最高的一种解释是什么嘛?参数作用域和函数里面的作用域相互独立?。。首先,源码已经表明了,没有参数作用域这种东西,而且按网上的说法,你这"参数作用域"和"里面的作用域"是平级的关系?都不正确哈。现在揭晓正确答案,d8打印一下作用域信息

这张是test函数的scope预解析结果和global对象的立即解析。

这张是test函数scope的正式解析

始终牢记scope所有信息(除了with/eval还有一点点特殊情况下)都是静态解析并更新完的!!所以你就抓住,这个变量(函数也是变量)诞生(定位在js源程序的文本位置 在怎样一个作用域里,var提升到第一个非eval的声明式作用域(提升过程不能与词法性质声明冲突)。先抓住这些。

我们再来看看我梳理的源码思路。我和宝贝们讲啊,????这段我觉得真的是Chrome团队用心良苦了啊,为了尽可能的人性化。。裂开,我花了一周才大致理解明白啊!!希望你们一定要好好读!我觉得以下8点算是这篇文章中最值钱的部分之一了呀!!不过可能我表述的也有误,而且可能对源码的理解的我也还是不到位,希望有缘人指正!!

  1. 预解析阶段已经为函数体代码包裹上了一层varblock_scope,变量x, y现在默认仍为var模式且在test作用域下的直接范围内!所以并不是为参数新建立了一个scope!而是为函数体新建了一个varblock_scope!!
  2. test函数的正式解析,将test参数默认值存放到了temporary(保存临时变量 槽中,只是这个槽里存的值是取自parameter对应索引处存的值。
  3. 修改参数的声明模式为let,并按序为scope内的变量分配内存空间。但y打上尚未赋值标识,为什么?因为 对于参数默认参数以及变量声明语句,它们的 "=" 不会被定义为 赋值"="含义!!那为什么x没被打上尚未分配的标识呢?有两个原因都会导致这个情况 ①由于此种代码行为满足为x强行分配至上下文插槽中,所以在预解析阶段就已经为x打上强迫上下文分配标识,所以可以在正式解析阶段直接初始化x为参数默认值(从temporary中取)。②由于在y函数内对x进行赋值了,所以x没有尚未赋值标识。只是在本代码中,根本原因是由于情况①导致的!
  4. hole initializetion elided,跳过对洞的初始化 let类型变量会先用hole填充,但是目前代码尚不属于运行时,初始化器的position未到达指定范围区间,所以不可初始化y为那个匿名函数。
  5. 所有空间分配完毕后,该正式执行test了,当初始化器走到参数上的时候,就会把temporary中存的y参数默认值,赋值给相应参数(因为y不是强制分配到上下文槽中的,它仍在local中,需要执行态的初始化器走到对应位置才能为她初始化赋值)!!第一个打印的4是因为 varblock_x中的x最初也是4 《==》 因为,它在之前的分析中也满足强迫上下文分配变量,所以正式解析时也直接将其初始化为temporary中的变量了
  6. 当你把var x = 2;那行去掉之后,你又会问怎么差这么多?自己试试打印下,想想为啥。答案都在前5点写完了。其实很简单 因为被varblock_scope包裹的函数体里没有声明过x了,newFunc要用的话就得通过outer_scope从作用域链往上找,取的就是test作用域下的参数x。这时候,y这个函数和newFunc这个函数共用那个x,所以此时你改的x都是那个在正式解析阶段被改成let模式的参数x了!如果test参数里也没有呢?一样啊,往上找啊,没找到就报错。
  7. 变量的分配位置上文讲过几种,但是都是看情况的,不一定在context还是local还是temporary等中,这个大家也不必太在意,反正放到context本上,是为了方便有时需要跨上下文访问变量。但有时确实需要强迫上下文分配。
  8. with、eval里的变量,这种变量,一般都会被打上 DYNAMIC DYNAMIC_GLOBAL,动态的标识。先不用管。

到这里,我讲清楚了吗?明白为啥我取 “看不见的客人 电影名作为这个小节的标题吗?我淦,我个人认为真的是太贴切了。????

消失的爱人--block_scope for object_literal

来吧,看看我们的"消失的爱人",这电影,你别把自己爱情观和婚姻观看扭曲了

function a() {
  // console.log(func);//not defined
  var x = 3;
  function other () {
    let o = 2;
  }
  let obj = {
    func() {
      var f_a = 2;
      return x;
    },
  };
  // console.log(func);//not defined
  other();
  return obj.func;
}
const f = a();
console.log(f());
复制代码

重点看obj里的func,这本是一个稀松平常看不出毛病的代码,但是,无意之间我发现一件事,上图

这是我们 a函数正式解析后的scope,发现什么了吗?Bingo!我之前讲过,函数字面量默认变量声明模式是var,一定会有一个这个函数的名字被提升到上面的第一个非eval声明式作用域,在此代码里,也就是函数a的scope里,但是!!

发现了吗,有var other,却没有var func;你要是连第二个红框中的信息也没有,那我倒也能接受,为什么现在提示了function func(){}(ps 虽然这里它只是普通的字符串。它现在可没分配内存呢,分配内存是在func执行前一刻才完成的。而且不可能分配到a scope中,一定是挂到了obj的func上! 那这是为啥呢?来,源码

如果方法出现在函数字面量里面的时候,就会先创建一个block_scope,然后,看下面????????

//如果是我上述的obj与function字面量的写法,就会调用下述函数
Scope* Scope::FinalizeBlockScope() {
  if (variables_.occupancy() > 0 ||
      (is_declaration_scope() &&
       AsDeclarationScope()->sloppy_eval_can_extend_vars())) {
    return this;
  }

  DCHECK(!is_class_scope());

  // 1. 把当前新建的这个block_scope从a函数的作用域中移出
  outer_scope()->RemoveInnerScope(this);

  // 2. 重定向 这个block_scope下面的所有子作用域及我们的func函数 的outer_scope为a函数的scope
  if (inner_scope_ != nullptr) {
    Scope* scope = inner_scope_;
    scope->outer_scope_ = outer_scope();
    while (scope->sibling_ != nullptr) {
      scope = scope->sibling_;
      scope->outer_scope_ = outer_scope();
    }
    scope->sibling_ = outer_scope()->inner_scope_;
    outer_scope()->inner_scope_ = inner_scope_;
    inner_scope_ = nullptr;
  }

  // 3. 移出未解析完的节点,这步也是重中之中!否则你的var func就从步骤2,带到了a函数的scope中
  // 只有block_scope里的func被分配了空间才func才算解析完,因为需要绑定变量代理给ast节点,它后续要用到,所以block_scope中的var func,就被移出了。
  if (!unresolved_list_.is_empty()) {
    outer_scope()->unresolved_list_.Prepend(std::move(unresolved_list_));
    unresolved_list_.Clear();
  }
//后面是关于var时的处理,先不管
  if (inner_scope_calls_eval_) outer_scope()->inner_scope_calls_eval_ = true;

  // This block does not need a context.
  num_heap_slots_ = 0;
  return nullptr;
}
复制代码

经过上述我注释里的分析,v8完美的完成了这样的事情 因为func是obj的属性,所以我们不能在a函数的任何位置直接引用func。但是呢,这个func最后又被 了a函数作用域,并且在a的scope中找不到var func(因为被移出了),这样a函数里既无法直接引用func,而且func还能通过outer_scope查找到a函数里的变量,甚至再return出去形成闭包。????为了在现象上符合人性化,v8真的努力了,细腻啊。i了i了????????????

现在大家伙儿理解,为啥我要叫 消失的爱人 了吧?"曾经我们形影不离,如今你却悄无声息的消失????"

二傻大闹scope--eval&with scope

有些"骚语法"情况下的scope chain想想还是不讲了,也没啥必要。补充几点吧

  1. **with\_scope里声明的var变量都会被提升至第一个非eval的声明式作用域**。
  2. `如果with的{}里面出现了词法性质的变量--let/const/函数字面量, 则除了with本身生成的一个with_scope外,它下面还会生成一个block_scope包裹住with的大括号里的代码;`
  3. 小心with\_scope的内存泄漏->global,大家自行搜索吧,这个没啥好说的。
  4. `eval_scope中的var变量会被保存至lookup的槽位中,而lookup可以在执行期间动态的被搜索`,就是说 
function a(){
//console.log(e);//报错,因为此时尚未被 到lookup槽位
eval(`var e = 1;`);
console.log(e);//1, 因为执行到这里, a的scope就会去lookup的槽位中查找e,
}
a();
复制代码

懂了吧,二傻大闹scope,三傻大闹宝莱坞。。

孤单又灿烂的神 闭包--Closure

"每年初雪的时候,乙方要回应甲方的召唤,因为甲方会等待 ",噢,对不起,走错片场了,????????

(虽然死了一次,但又没死透??而且功能强大。阿加西和闭包是不是很像?没看过鬼怪的同志,这段话就别看了,当我在抽风????)我外层上下文函数都销毁了(阿加西),你内层return出去的函数(chi en tao jingaoyin?),居然还有机会调用我的东西(阿加西)?那必须要有一个"契约",来召唤阿加西,是什么呢,嗒嗒嗒嗒!最关键的当然是我们的 三大指针 啦!

以下述代码为例

function w1 () {
  let w1_1 = 1;
  const w2 = function () {
     let w2_2 = 2;
      console.log(w1_1);
      return w1_1;
  }
}
const w2 = w1();
console.log(w2());
复制代码

执行w1会创建完context等信息,这时把w2函数return出去了,w1执行完是需要销毁上下文的,所以要在它销毁前干一些事。肯定是要保存整个现场,以及保存好要返回出去的信息。在源码的Scope::DeserializeScopeChain方法里,这方法含义就是反序列化作用域链,它大致做的事情是

  • 按照原先的作用域链/树(因为三个指针嘛,也可以说是树 的关系,从我们的最内层scope(即要return出去的w2 ,一直到当前的script_scope(不含 ,全部申请新的空间克隆一遍,然后把离script_scope最近的克隆出来的那个scope加到script_scope内(在本代码实例中也就是w1的scope了),然后把要return出去的scope(在本代码实例中也就是w2的scope)作为反序列化作用域链方法的返回值给return出去。这样即便是旧函数上下文销毁了,在外面也能重新找到闭包对应的数据。因为它将整个链路都克隆了一遍,又存给了script_scope

这儿也有个得到闭包作用域的方法,给大家看下吧,有时也会用到。但真正做到闭包的是刚才那个反序列化方法!就是从调用这个方法的作用域开始向外找到第一个声明式作用域 者是块级作用域,再直接返回就行了

吃百家饭长大的--JSArray

之所以说它吃百家饭长大,是因为JS数组,有点????啊。你看哈

能装各种类型的数据,能动态扩容缩容;还能作为栈 push&pop;又能当队列 unshift&shift;也能当"字典";最后还暴露了各种方便的高阶函数API。 ????

我只带大家一起看看关于js数组遍历和sort api的源码噢,push api就看下面那个链接里的就行

收放自如真气足--动态扩容与缩容

数组push、pop可能会引发动态的扩容与缩容又不会导致自身出问题,"收放自如真气足",对吧。??

内存变化后为何恒等,我在上文"憨豆"先生那里解释过。主要内容大家读这篇就好,这大哥写的挺不错的。探究JS V8引擎下的“数组”底层实现[5]

我简单补充一点,数组需要动态扩容缩容,其实根源在于对象需要动态扩容缩容。

万水千山我陪你看--iteration api

好吧,其实就是想带你们一起看看人v8是怎么陪我们"遍历数组天下"的。看看人家这工厂,一个函数实现8种迭代方式

我服了,之前我本来单独整了个文件一行行打了注释!后来不小心未经垃圾篓给删除了!那你们只能自己看看了???? 。chromium对于js数组一些遍历的方法是直接用js实现的,这样就比c++简便些,但是效率肯定没c++高

//third_party/devtools-frontend/src/node_modules/core-js-pure/internals/array-iteration.js
var bind = require('../internals/function-bind-context');
var IndexedObject = require('../internals/indexed-object');
var toObject = require('../internals/to-object');
var toLength = require('../internals/to-length');
var arraySpeciesCreate = require('../internals/array-species-create');

var push = [].push;

// `Array.prototype.{ forEach, map, filter, some, every, find, findIndex, filterOut }` methods implementation
var createMethod = function (TYPE{
  var IS_MAP = TYPE == 1;
  var IS_FILTER = TYPE == 2;
  var IS_SOME = TYPE == 3;
  var IS_EVERY = TYPE == 4;
  var IS_FIND_INDEX = TYPE == 6;
  var IS_FILTER_OUT = TYPE == 7;
  var NO_HOLES = TYPE == 5 || IS_FIND_INDEX;
  return function ($this, callbackfn, that, specificCreate{
    var O = toObject($this);
    var self = IndexedObject(O);
    var boundFunction = bind(callbackfn, that, 3);
    var length = toLength(self.length);//上面基本都是做容错处理的,光上面三行都跳了十来个文件..
    var index = 0;
    var create = specificCreate || arraySpeciesCreate;
    var target = IS_MAP ? create($this, length) : IS_FILTER || IS_FILTER_OUT ? create($this0) : undefined;
    var value, result;
    //这个if里是防止啥 --> 比如: arr.map我在它的参数--一个函数里,arr.pop();这样的话就能避免被pop后还执行
    for (;length > index; index++) if (NO_HOLES || index in self) {
      value = self[index];
      result = boundFunction(value, index, O);
      if (TYPE) {//我只能说,人家这iteration写的真漂亮
        if (IS_MAP) target[index] = result; // map
        else if (result) switch (TYPE) {
          case 3return true;              // some
          case 5return value;             // find
          case 6return index;             // findIndex
          case 2: push.call(target, value); // filter
        } else switch (TYPE) {
          case 4return false;             // every
          case 7: push.call(target, value); // filterOut
        }
      }
    }
    return IS_FIND_INDEX ? -1 : IS_SOME || IS_EVERY ? IS_EVERY : target;
  };
};

module.exports = {
  forEach: createMethod(0),
  map: createMethod(1),
  filter: createMethod(2),
  some: createMethod(3),
  every: createMethod(4),
  find: createMethod(5),
  findIndex: createMethod(6),
  filterOut: createMethod(7)
};
复制代码

?生于乱世当定天下--sort api

????就是想说,我们一起看看,v8是怎么把 平定"乱世"数组的。

首先最新的v8是用它们自己的研发的语言Torque,写的 v8/third\_party/v8/builtins/array-sort.tq[6]

用的是起源于Python的一个叫做Timsort的混合排序算法,已经不再使用插入+快排,现在是二分插入+归并排序,我估计是因为快排不够稳定,而且时间复杂度最坏情况是O(n^2);归并排序稳定而且是保持O(nlogn)。


  1. 数组长度,小于2则直接return
  2. 只要剩余子数组不为0,就得到一个子数组,简称run,长度为 currentRunLength
  3. 计算最小合并序列长度 minRunLength (它是根据array的length动态变化,在32 64之间
  4. 比较 currentRunLength 和 minRunLength ,如果 currentRunLength < minRunLength,采用插入排序并补足数组长度至 minRunLength,否则就将 run 压入栈 pending-runs 中。
  5. 每当新 run 被压入 pending-runs 时,保证栈内任意3个连续的 run(run0、run1、run2 满足run0 > run1+run2 && run1>run2,否则就调整至满足。
  6. 若剩余子数组个数为0,则结束循环 && 合并栈中所有 run,排序完成。

临时补充一个点 array.sort(()=>( 0.5-Math.random() )); 记住它并不能做到完全等比例随机打乱数组!具体可以去做下这题 384\. 打乱数组[7]

你的优质对象--JSObject

其实网上也不少文章解释的不错的(虽然可能有些地方不准确 ,我本来是想带你们一探究竟的,我最初真的是这么想的,????相信我,我爱你,我爱你们,听见没有!!????

但是吧。。我裂开了啊!!XDJMM。我时间真不够了,熬了这么多天的夜,今天都通宵了,还是没能肝完,我明早(12.11)的????,这js对象我只能????。我有愧啊,苍天啊,大地啊,我对不起你们。????????


??你想阅读chromium源码?供你借鉴我的经验:

其实真的不推荐像我这样的雏鸟(好吧,我连雏都算不上???? 读chromium!最好是计算机基础良好并且对上层应用也有较深理解的人去研究。

真的不推荐!!!!真的不推荐!!!!真的不推荐!!!!重要的话讲三遍!我来解释为什么我这么说。

一、阅读前的准备

  1. 需要科学上网!而且??质量一定要好!网速要快!(这一条件已经拦下很多人了
  2. 配置环境,安装devtools,用它fetch源码(我没fetch历史提交记录,也有21个G ,再配环境
  3. 利用gn和ninja,构建&编译chromium(这一步我花了12hour ,断了可以用gclient sync重连(但你最好别断
  4. 按照上述步骤(具体步骤看官网 一切顺利,没意外情况下,也要2-3天。我最初不是上述步骤,而且中间有意外,当时加起来间接花了有效的一周吧。最后我的整个chromium文件夹 69.22G
  5. 然后你就可以直接点击启动chromium了

ps: v8可以单独fetch下来调试运行(肯定比chromium容易 ,都处理完后,我的v8文件夹体积是14.09G

二、关于调试

很遗憾??????,我没能在mac的xcode(要用xcode调试的话,还要使用一些命令处理一下 上调试起来,一进去电脑就卡死在indexing了。chromium没调试起来就算了,我单独fetch的v8也没调试起来(这个好点,卡在run building了 。!!因为编译器都没建立起索引,很多功能用不了,最终选择在 chromium[8] 这里硬读(它有索引跳转、查找引用等功能 。

经我排除分析,应是以下两个原因导致

  1. 不管是整个chromium,还是我单独fetch的v8,xcode都未能建立完整索引(没索引,你就不能跳转,和纯文本没区别了 ,两种可能 一个是损失了索引信息,另一个就是文件太大索引跑不动????
  2. 可能是这台电脑配置不够。储存空间是256的。现在就剩10G了,啥都不敢下了,得留给工作内容啊

image.png

三、阅读

如果你已经可以用IDE调试起来,又懂C++。那我非常推荐你跟着调试模式去读源码,????然后分享给大家。

其实只要能调试起来,干什么都会方便。但是如果你没调试起来,难道也要向我一样傻乎乎的去干读?我当时差点放弃了,头两周都快崩溃了。别像我一样傻的头铁,还是要理智。

如果在v8,可以先从 v8/samples/hello-world.cc 入手,看看流程,我也是后来才发现这个文件,????

四、衡量ROI-- 入回报比

如果你 对于你想读的东西有使用层面上较深的理解 && 可以完美调试起来 && 具有C++基础 && 计算机基础(最好懂些编译原理和OS ,那你去吧,ROI肯定高

但是绝大部分人可能都调试不起来,甚至在前几步就被拦在门外进而放弃了。而且最新chromium太大,为了性能->奇技淫巧,'456' ? '5' : 'normal'; 就不适合让人读。

这时你比较下你的 入和回报,你觉得值吗?除非你想向我一样不考虑ROI,就是特别感兴趣,就是强迫症特别严重,为了抓住一些东西的本质,为此去打好其他基础,就是要读。OK,那不管你是哪位兄弟姐妹,咱俩同道中人,I like you。????????

此情此景下(不能调试,硬读时 ,以我阅读chromium/v8为例,分享几点我的经验供你借鉴

  1. 如果你是开发者,你会怎样命名这个变量,然后去chromium搜,说不定有蛛丝马迹
  2. 抗击打 第1 2周你绝对会读的怀疑人生到自闭,挺过第一周,后续会好很多
  3. 积攒关联->延迟享受 你不断的寻找蛛丝马迹,然后逐行阅读,你会逐渐积累知识碎片,但短时间内你肯定无法将其拼凑成图。因为你又没调试器,你也看不见堆栈具体记录等情况。但是你一定要及时的记下来,信息量慢慢攀升,之后你就能将这一个个碎片化散为整。这时你便"渐入佳境",可以享受后续阅读的乐趣了。
  4. 实践 看看实际情况是否如阅读所悟;如果有向d8这样的工具也是不错的选择
  5. 不断循环上述四步即可

"W挖矿之路"第一季第一集大结局--总结

我不知道为什么这么多个深夜能够平静的

敲击

这指尖的跳动

末了也仍是最初的那份信念..

ok,fine,小小总结一下吧。

本次分享中,担心我讲不清楚导致大家不知所云,所以我首先做了一个宏观上的铺垫与解释 Hanlde先生的大智慧、编译原理知识科普、类(及其简单描述)关系图、Context&Scope基本描述、v8示例宏观执行流程。

之后呢,就从微观--代码细节处,开始解释 Context&Scope,讲了几种情况下作用域的布局以及行为。

之后又夹带了一点js数组api和对象(??了??了)的源码分析。最后分享了我的chromium阅读历程供大家借鉴。

由于我未能调试起来,所以可能某些方面我的理解有误,特别特别希望有了解的同志指正我一下哈????

Chromium探索之路还远远没有结束,人生成长之路也看不到边际。希望大家能怀揣赤诚之心不断前进!????

之后我会抽空把C++系统性的学一下(毕竟当时我只是花了3小时临时入了下门,然后这一个月里边读源码边学的C++语法而已 ,毕业后换台高配mbp(公司这台mbp我受不了了。。 ,再去认真阅读&梳理chromium内容并分享给大家但是在此之间对chromium的"攻略"就先暂停咯,毕竟ROI太低。况且明年要毕业,即将迎来更多事情,估计毕业前无暇再看了。????但期间我可能会不定期分享其他知识。

(: ps 本篇文章所有内容均为原创,引用/转载请注明出处哈!侵权必究喔!!!!\-- W

自我介绍

Hello,大家好,你们可以叫我 W(我姓吴呗)。为了不妨碍大家的阅读时间及体验,特意将此放到文末。毕竟我是第一次公开创作文章,总得来点仪式感对吧,小小的纪念一下啊????。

目前大四,FEer实习生,实习四五个月了。不太喜欢八股文,喜欢自己实践探索。频繁的业务,导致频繁的沟通与敲代码,我也一直在不断提高代码质量(这里也要感谢我的mentor,一个需求N次CR;并且在其他方面也帮助了我很多 。我也时常注重代码层面的微优化,对编程有着自己的理解 -- 我认为代码世界本身就是真实世界的另一种诠释,所以它在设计之初,一定是尽全力的人性化&&不反人类

奉行 实践是检验真理的唯一标准。扒开源码是检验真实情况的唯一选择。

其实也有想,自己再多读读,等更深入了解后再来分享。但奈何我明早就要????,做别的事,之后半月多应该都不会碰代码,所以才想趁着热乎分享给大家。不然后续再看可能又忘了好多东西,重新整理又是新的 入。

其实我对你们的心意,有那那那那那么多,好大好大,但是由于时间仓促只分享了那那那那么点(其实也有90%拉 ,小小年纪的我留下了不甘、愧疚、开心(因为我不用码字拉 的泪水。????????

我们总说站在巨人的肩膀上看世界,可是,哪里那么多巨人承载我们这么多人的重量。况且已存在的巨人也会疲惫,终将也会被岁月褪去生命的光泽。所以需要我们不断地呵护巨人,同时提升自己,就算不能成为下一个巨人,也要为引导出下一个巨人做出贡献!人终将逝去,总要在这世上留下点印迹吧。

这也是我不计ROI去读&分享的原因之一吧。我曾被人无私的帮助过,所以我也希望有机会可以为这个社会作出一点绵薄贡献。希望自己的探索与分享可以为社区多营造点 分享、共享、开源的思想氛围。

??????求三连+评论

求求各位看官(英俊小哥哥、貌美小姐姐、成熟稳重大哥哥、温柔贤淑大姐姐,总之就是又酷又飒的你),给个三连(点赞收藏关注)+评论吧!!!孩子求求了!!求求了!!你们的支持与回应就是我分享的最大动力!!

最后奉上我的宝儿--无敌可爱又迷人的猪小屁镇楼,同时预祝大家元旦快乐!未来 一帆风顺、二龙腾飞、三阳开泰、四季平安、五福临门、六六大顺、七星高照、八方来财、九九同心、十全十美、万事如意、心想事成!!????????????(咳咳,嗓子快哑了...)


本次分享就告一段落咯, See you????????????

源自 https://juejin.cn/post/7039850183244382216

声明 文章著作权归作者所有,如有侵权, 小编删除。

感谢 · 转发欢迎大家留言

小羊羔锚文本外链网站长https://seo-links.cn 
回复列表
默认   热门   正序   倒序

回复:「目前全网唯一&2万字长文」从JS上下文到Chromium源码的极限拉扯!兄弟姐妹们接好了!!

Powered by 小羊羔外链网 8.3.7

©2015 - 2024 小羊羔外链网

免费发软文外链 鄂ICP备16014738号-6

您的IP:34.204.3.195,2024-03-28 21:44:56,Processed in 0.06138 second(s).

支持原创软件,抵制盗版,共创美好明天!
头像

用户名:

粉丝数:

签名:

资料 关注 好友 消息