从Chrome源码看JavaScript的执行流程(四)结论

chromium是如何执行JavaScript的

好的,目前来看似乎解析JS的过程中有哪些函数已经清楚了,来看一下整个流程吧

第一个断点还是断在HTMLTreeBuilder::ProcessToken(AtomicHTMLToken* token) 这个函数上,首先看一下从浏览器打开页面到开始处理token之间经过了什么,这里不是我们这篇文章的重点,所以准备一笔带过:

首先是创建进程,创建消息循环(https://blog.csdn.net/optman/article/details/5005660 http://r12f.com/posts/learning-chrome-1-threading-model-and-messageloop/)然后是bind_internal,创建渲染线程,创建渲染视图,接着创建Main frame,加载document对象(注意这里的函数是provisional_document_loader_->StartLoading,临时的document ),解析document对象,然后进入token的循环处理阶段(token的循环处理函数定义在html_document_parser.cc里的HTMLDocumentParser::ProcessTokenizedChunkFromBackgroundParser 函数中,可以去看下)

接着,处理第一个token AtomicHTMLToken kEndOfFile,紧接着的是第二个token:script的起始标签 AtomicHTMLToken kStartTag name “script”,然后是第三个token:script标签中的字符 AtomicHTMLToken kCharacter data “alert(1);”,最后,就是script的闭合标签 AtomicHTMLToken kEndTag name “script”,紧接着就会进入js执行环境,从这里开始单步调试

1.一开始是 ProcessEndTag 函数,将token当做参数传递进去

2.进入函数后调用了GetInsertionMode() 检查token的类型,粗略看了一下有 kAfterHeadMode, kInBodyMode 之类的,我们这里没有head和body,所以是 kTextMode

3.首先判断了是否是script标签,然后判断这个script是否可以执行,都判断通过了,则标记为script_to_process_

判断script是否能执行的函数:

4.接着执行了一个函数SetInsertionMode(original_insertion_mode_);这里的original_insertion_mode_竟然是kInHeadMode,难道说对script标签都默认在head里??

接着判断是否已经处理过这个token,没有的话直接return

5.return之后回到了HTMLTreeBuilder::ConstructTree(AtomicHTMLToken* token) 函数中,执行最后一步tree_.ExecuteQueuedTasks()

奇怪的是在判断size那里直接return了,回到了最开始的HTMLDocumentParser::ProcessTokenizedChunkFromBackgroundParser 函数,触发了pause并且是script的pause,进入判断script的函数HTMLDocumentParser::IsWaitingForScripts()

判断了是在tree_builder里触发的script pause还是在script_runner里,这里是在tree_builder里触发的block,waitingforscripts返回true,暂停DOM树的构建,转去执行JS

6.首先判断script是否是最后一个token,然后进入RunScriptsForPausedTreeBuilder()函数

到script_runner_->ProcessScriptElement(script_element, script_start_position);(html_parser_script_runner.cc)这一步即执行了JS

ProcessScriptElementInternal(script_element, script_start_position); 执行完后 JS执行成功

因为ProcessScriptElementInternal函数有点长所以不全放进来了,最重要的是这一段:

其中调用了script_loader->PrepareScript,这是一个非常重要的函数,里面进行了多次判断当前的script是否可以执行,并对当前的执行上下文进行了检测和分类,可以说从这个函数开始就是每个script执行时必须要经过的地方,最后调用 ScriptLoader::ExecuteScriptBlock -> script->RunScript(frame, element_->GetDocument().GetSecurityOrigin()); 执行了JS

7.之后,因为当前的token都已经被处理,所以ProcessTokenizedChunkFromBackgroundParser执行完成,回到了HTMLDocumentParser::PumpPendingSpeculations继续下一步,进行一个检查后也结束了PumpPendingSpeculations的过程

但是token的循环仍然没有结束,还有最后一个kEndOfFile的token需要处理,当这个token处理完成后,DOM树构建完成,流程再次进入bind_internal调用callback函数,如果这时继续单步调试可以发现call stack里的函数越来越少

DOM操作

回到正题,通过调试找到文章最开头两个例子的区别,先回想一下刚才调试的过程中我们拿到了什么能够帮助我们的信息
1.浏览器在获取到script end tag的token的时候会进入JS环境
2.浏览器在每次ProcessToken的时候都会Flush一遍当前的DOM树
3.如果浏览器在解析过程中遇到了可以执行的script,会在HTMLDocumentParser::ProcessTokenizedChunkFromBackgroundParser这个函数中pause并且进入JS环境
4.PrepareScript可能是所有JS执行前需要执行的函数

那么就来调试一下简化的能触发alert的版本

首先看一下一共有多少token需要处理:

可以发现,alert(1)单独作为一个token出现了,

这次我们断在script的入口点 ScriptLoader::PrepareScript ,很明显的,进入了三次 ScriptLoader::PrepareScript 函数,每进入一次scriptloader,chromium都会执行几十个判断来决定这个script能否被执行,第一次进入scriptloader的时候没有通过这个判断

所以没有执行,而第二,三次进入scriptloader都通过了所有判断最后执行

先来看第三次进入scriptloader的时候,即执行alert的时候通过了什么函数

经过层层判断,并且按照是inline还是有src属性对script进行分类之后,进入  ScriptLoader::ExecuteScriptBlock(TakePendingScript(), script_url) 这个函数里

然后又是经过一系列的判断和check…进入script->RunScript(frame, element_->GetDocument().GetSecurityOrigin());

给RunScript下个断点,继续深入

进入了classic_script.cc里

获取了sourcecode,baseurl和options之后,终于要进入v8的世界了

script_controller.cc

ScriptController::ExecuteScriptInMainWorld -> ScriptController::ExecuteScriptAndReturnValue -> V8ScriptRunner::RunCompiledScript -> Script::Run(Local context) -> MaybeHandle Execution::Call -> MaybeHandle CallInternal -> V8_WARN_UNUSED_RESULT MaybeHandle Invoke -> IntrinsicsGenerator::Call

一整条很长的调用链,暂时就看到这里,我们先看看另一个html在目前这个阶段和当前的html有什么不同:

还是先断在ProcessToken,看下当前这个html有几个token需要处理

这下看出了一些问题,被DOM操作添加的script标签,竟然也在token里面,但是他并没有执行,这就比较好办,直接单步看最后一个script闭合标签是如何处理的,和上一个html的alert token对比就行,要是没有这个token的话,就需要看执行DOM操作之后的回调操作了,这里也就验证了最开始的一个观点:

那么来看一下这里为什么没有进入JS的执行环境吧

把断点打在ProcessToken和PrepareScript上,执行到最后一个script EndTag

好的,出现问题了,最后一个script闭合标签根本没有走到PreoareScript里,需要再往前打断点

试试打在这里:HTMLParserScriptRunner::ProcessScriptElement

也没进入….需要直接在ProcessToken后单步调

在HTMLDocumentParser::PumpTokenizer() 这个函数里的IsPaused() 是false,所以没有进入JS环境,同时注意到这里的函数是PumpTokenizer而不是之前执行JS的时候进入的HTMLDocumentParser::ProcessTokenizedChunkFromBackgroundParser,而在PumpTokenizer里isPaused()函数就算判断成功了好像也不会进入JS环境?

PumpTokenizer的判断

ProcessTokenizedChunkFromBackgroundParser的判断

是在处理token之前就进行的判断吗

看下PumpTokenizer的函数调用栈,发现果然出现了 V8Element::innerHTMLAttributeSetterCallback ,

在其中的 V8Element::innerHTMLAttributeSetterCustom 下个断点,对比两个html的执行差异

先来看不能执行JS的

整个调用链是:V8Element::innerHTMLAttributeSetterCallback -> V8Element::innerHTMLAttributeSetterCustom -> Element::setInnerHTML(这里对value的判断是string而不是htm;) -> Element::SetInnerHTMLFromString -> ReplaceChildrenWithFragment

然后发现了封装的地方,是ContainerNode::AppendChild :

首先remove需要操作的node的所有children,然后再把当前node添加进去,其中container_node的数据类型是blink::HTMLDivElement *一个指向div元素的指针

其中ContainerNode类包含了大多数对DOM操作的封装,appendChild, getElementById, getElementByName…

之后经过了许多判断后返回,这里因为我们首先调试的是不能执行JS的,不好判断是哪里触发,所以准备先通过调试能执行JS的判断触发点

接着来看下能执行JS的回调链

整个调用链是:V8Element::innerHTMLAttributeSetterCallback -> V8Element::innerHTMLAttributeSetterCustom -> Element::setInnerHTML -> Element::SetInnerHTMLFromString -> ReplaceChildrenWithFragment -> ContainerNode::AppendChild

和前面的一样,我们在DidInsertNodeVector(targets, nullptr, post_insertion_notification_targets); 执行后触发了JS,可以从这个函数开始调

DidInsertNodeVector调用了container_node里的ChildrenChange结构体的ForInsertion函数进行对node的添加

这里node.IsElementNode() 是false,因为这里的node只有alert(1),而前面不能执行JS的例子这里就是true,因为他是一个完整的script node

继续往下走,直到这个函数 html_script_element.cc

直觉这可能就是我们要找的地方,对script标签的单独的一个函数ChildrenChange,调用了loader里的ChildrenChanged() script_loader.cc

看到了熟悉的PrepareScript,我们前面说过,PrepareScript可能是所有script执行前必须经过的函数,而在单步执行完这个函数后JS执行成功

于是我们可以猜测,有一个类似的 HTMLDivElement::ChildrenChanged 或者是其余常规标签单独一个ChildrenChanged,并且不会进入JS环境,调试看看

是后一种猜测,如果没有单独的Element::ChildrenChanged函数,则会走进HTMLElement::ChildrenChanged:

至此,关于开头提出的问题已经解释清楚了,我们发现最开始的猜测其实是正确的,简而言之,对script标签进行的DOM操作并且更改了这个节点的节点结构,同时这个script标签之前没有执行过,那么就会在DOM操作之后执行这个script标签里的内容

思考延伸

1.除了script,还有什么标签独立设置了ChildrenChanged,为什么?

没有调用 HTMLElement::ChildrenChanged,有自己的处理的:

styleElement:

Object:

Menu:

调用了 HTMLElement::ChildrenChanged 但是有其他操作的:

Input_element:

textarea:

title:

Svg:

2.innerHTML+=和innerHTML=会有什么不同

innerHTML+=和innerHTML在DOM树上的操作基本一样,都需要对目标节点里的东西重新添加一遍,而如果目标节点是script标签的话:

在PrepareScript函数中,第一步就是判断这个script是否已经start

在第九步的时候,设置already_started_为true

所以,如果对一个已经执行过的scirpt标签用innerHTML+=添加一个javascript语句,这个语句也是不会执行的,如果对一个空的script标签用innerHTML+=的话则会执行,因为在第五步的时候会判断script标签是否有内容,没有的话直接return,则没有进入第九步设置already_started_处

3.什么时候会进入JS环境

如果需要进入JS环境,则必须要有PrepareScript的过程(存疑),通过查找PrepareScript函数的调用则可以明确知道什么时候的JS有机会执行,什么时候没机会执行
4.在文章的最开头缘由部分我还举了这样一个例子:

这样是可以成功执行的,那么这里是为什么可以执行,JS的触发点在哪里?

根据之前的调试经验,先猜测这里一共会有这几个token:

并且很可能执行alert是在appencChild的回调函数里

来调试试试,首先断在ProcessToken处

出现了一个问题,token里并没有使用appendchild加入的script节点,这令我十分惊讶

要注意的一个细节是,前面的例子里innerHTML的回调是在处理完当前所有的token之后才进行的,并且alert成功加入了token里,那么这里的JS触发点到底在哪,有了前面的经验,可以直接在PrepareScript处下一个断点,查看调用栈,PrepareScript一共调用了3次,第三次时alert触发,这不意外,因为每一次刷新DOM树都会导致script节点进入PrepareScript函数,但是已经执行过的script节点则不会走到最后的执行阶段,于是断点下的再深一点,下在ScriptLoader::ExecuteScriptBlock函数里的script->run处,这次只调用了两次,第二次调用时alert执行

查看执行alert的时候的调用栈,直接调用PrepareScript函数的地方是这个

往上看,在执行完 ContainerNode::AppendChild 之后,也就是插入子节点之后,调用了这个函数:

其中 descendant->DidNotifySubtreeInsertionsToDocument(); 即是调用的ScriptLoader::DidNotifySubtreeInsertionsToDocument(),所以又验证了一个猜想,如果appendChild的child是script标签,那么就会进入JS环境

发表评论