Author: Au1ge
Blog : http://www.au1ge.xyz/
文章由作者Au1ge整理博客后首发先知社区
下篇传送门: 从Chrome源码看JavaScript的执行(下)

缘由

来源于ph师傅小密圈里的一个问题

例子1:

<!DOCTYPE html>
<html>
<head>
    <title></title>
</head>
<body>
<div id="id"></div>
<script>
var a="<script>alert(1)</s"+"cript>";
//var a="<img src=1 onerror=alert(1)>";
document.getElementById("id").innerHTML=a;
</script>
</body>
</html>

这段代码,虽然成功插入了script标签,但是没有执行,如果改为插入img标签则可以执行

首先的猜想是:

onerror 事件在 DOM 树构建完成之后触发,而 script 标签内的内容在 DOM 解析中触发,但是可以看一下这个例子

例子2:

<script type="text/javascript" id="id2"></script>
<script>
  var a = "alert(/script/)";
  document.getElementById("id2").innerText = a;
</script>

这里正确执行了JS,说明浏览器在解析并执行第二个 script 标签时,回头执行了第一个 script 标签内的内容,而第一个 script 标签此时已经成功加入了 DOM 树里,这可能是因为浏览器在遇到 script 标签,或者对 script 标签进行 DOM 操作的时候,会刷新一次页面渲染,因为浏览器在碰到 script 标签的时候,需要渲染页面保证 script 能获取到最新的 DOM 元素信息,可以看下这个:链接:原来 CSS 与 JS 是这样阻塞 DOM 解析和渲染的 - 掘金

所以,最开始的例子是对 div 元素进行的 DOM 操作,浏览器不会再触发页面渲染并执行动态创建的 script,创建的 script 无法在解析中执行,关于浏览器的执行流程可以看下这个:链接:浏览器的工作原理:现代网络浏览器幕后揭秘 - HTML5 Rocks

再看一个例子:

<div id="id"></div>
<script>
var a = document.createElement('script');
a.innerText = 'alert(/xx/)';
document.getElementById("id").appendChild(a);
</script>

这个例子里JS成功触发,于是可能最开始的猜想就是正确的,对script标签的DOM操作,无论他是被操作还是操作,都会进行一次渲染,那么这个怎么用正确的术语描述呢?

掘金那篇文章的一个评论

可以翻墙的看看下面这篇文章: https://developers.google.com/web/fundamentals/performance/critical-rendering-path/?hl=zh-cn CSSOM树和DOM树是分开构建,之所以把link标签放抬头而script放body尾部,是因为浏览器遇到script标签时,会去下载并执行js脚本,从而导致浏览器暂停构建DOM。然而JS脚本需要查询CSS信息,所以JS脚本还必须等待CSSOM树构建完才可以执行。 这将相当于CSS阻塞了JS脚本,JS脚本阻塞了DOM树构建。是这样子的关联才对。 只要设置CSS脚本提前加载避免阻塞JS脚本执行时CSSOM树还没构建好,同时给script标签设置async就可以解决这个问题

https://developers.google.com/web/fundamentals/?hl=zh-cn 这个貌似比较详细的解释了浏览器

简言之,JavaScript 在 DOM、CSSOM 和 JavaScript 执行之间引入了大量新的依赖关系,从而可能导致浏览器在处理以及在屏幕上渲染网页时出现大幅延迟:

- 脚本在文档中的位置很重要。
- 当浏览器遇到一个 script 标记时,DOM 构建将暂停,直至脚本完成执行。
- JavaScript 可以查询和修改 DOM 与 CSSOM。
- JavaScript 执行将暂停,直至 CSSOM 就绪。

“优化关键渲染路径”在很大程度上是指了解和优化 HTML、CSS 和 JavaScript 之间的依赖关系谱。

看了一些文章,了解了reflow和repaint的概念,可能需要重新审视最开始的代码为什么没有进入JS环境

毫无疑问,innerHTML会执行回流操作,并且 innerHTML 中的东西会被加入DOM树,

innerHTML会解析其中的 html 代码并将其加入 DOM 树:https://developer.mozilla.org/en-US/docs/Web/API/DOMParser

那么就有点奇怪,如果 innerHTML 使 script 加入了 DOM 树,并且触发了回流操作,那么为什么不会执行呢?

同样的,innerText给 script 标签加入代码,也会回流(reflow) DOM 树,并且执行了 JS 代码

需要调试一下chrome,确定他什么时候会进入JS环境

Chrome是如何执行JavaScript的

对于例子1和例子2,猜测他们存在一个分流点,例子1进入了不能调用JS引擎的分支,例子2则进入了能够调用JS引擎的分支,要找到这个地方,就需要找到调用JS引擎的入口函数,从而回溯调用链,为了找到这个入口函数,我们先调试一个简单的例子

<script>alert(1)</script>

第一个断点断在 HTMLTreeBuilder::ProcessToken(AtomicHTMLToken* token) 这个函数上,看一下从浏览器打开页面到开始处理token之间经过了什么,这里不是我们这篇文章的重点,所以准备一笔带过,关于token是什么以及浏览器如何构建DOM树,可以看这篇文章:https://zhuanlan.zhihu.com/p/24911872

打开页面之后首先是创建进程,创建消息循环,创建渲染线程,创建渲染视图,接着创建Main frame,加载document对象,解析document对象,然后进入token的循环处理阶段(token的循环处理函数定义在html_document_parser.cc里的HTMLDocumentParser::ProcessTokenizedChunkFromBackgroundParser 函数中)

接着,处理第一个token AtomicHTMLToken kEndOfFile(只在第一次打开这个标签页的时候需要处理,刷新当前标签页的话没有这个token),紧接着的是第二个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 之类的,我们这里的script闭合标签是 kTextMode

case kTextMode:
      if (token->GetName() == scriptTag &&
          tree_.CurrentStackItem()->HasTagName(scriptTag)) {
        // Pause ourselves so that parsing stops until the script can be
        // processed by the caller.
        if (ScriptingContentIsAllowed(tree_.GetParserContentPolicy()))
          script_to_process_ = tree_.CurrentElement();
        tree_.OpenElements()->Pop();
        SetInsertionMode(original_insertion_mode_);

        if (parser_->Tokenizer()) {
          // We must set the tokenizer's state to DataState explicitly if the
          // tokenizer didn't have a chance to.
          parser_->Tokenizer()->SetState(HTMLTokenizer::kDataState);
        }
        return;
      }
      tree_.OpenElements()->Pop();
      SetInsertionMode(original_insertion_mode_);
      break;

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

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

static inline bool ScriptingContentIsAllowed(
    ParserContentPolicy parser_content_policy) {
  return parser_content_policy == kAllowScriptingContent ||
         parser_content_policy ==
             kAllowScriptingContentAndDoNotMarkAlreadyStarted;
}

4.接着执行了一个函数SetInsertionMode(original_insertion_mode_);,把之前的kTextMode改成了kInHeadMode,难道说对script标签都默认在head里??

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

void HTMLConstructionSite::ExecuteQueuedTasks() {
  // This has no affect on pendingText, and we may have pendingText remaining
  // after executing all other queued tasks.
  const size_t size = task_queue_.size();
  if (!size)
    return;

  // Fast path for when |size| is 1, which is the common case
  if (size == 1) {
    HTMLConstructionSiteTask task = task_queue_.front();
    task_queue_.pop_back();
    ExecuteTask(task);
    return;
  }

  // Copy the task queue into a local variable in case executeTask re-enters the
  // parser.
  TaskQueue queue;
  queue.swap(task_queue_);

  for (auto& task : queue)
    ExecuteTask(task);

  // We might be detached now.
}

在判断task_size那里直接return了,因为队列里没有其他任务了,回到了最开始的HTMLDocumentParser::ProcessTokenizedChunkFromBackgroundParser 函数,触发了pause并且是script类型的pause,进入判断script的函数HTMLDocumentParser::IsWaitingForScripts()

bool HTMLDocumentParser::IsWaitingForScripts() const {
  // When the TreeBuilder encounters a </script> tag, it returns to the
  // HTMLDocumentParser where the script is transfered from the treebuilder to
  // the script runner. The script runner will hold the script until its loaded
  // and run. During any of this time, we want to count ourselves as "waiting
  // for a script" and thus run the preload scanner, as well as delay completion
  // of parsing.
  bool tree_builder_has_blocking_script =
      tree_builder_->HasParserBlockingScript();
  bool script_runner_has_blocking_script =
      script_runner_ && script_runner_->HasParserBlockingScript();
  // Since the parser is paused while a script runner has a blocking script, it
  // should never be possible to end up with both objects holding a blocking
  // script.
  DCHECK(
      !(tree_builder_has_blocking_script && script_runner_has_blocking_script));
  // If either object has a blocking script, the parser should be paused.
  return tree_builder_has_blocking_script ||
         script_runner_has_blocking_script ||
         reentry_permit_->ParserPauseFlag();
}

判断了是在tree_builder里触发的script pause还是在script_runner里,这里是在tree_builder里触发的block,判断依据是之前第二部标记的script_toprocess ,tree_builder_has_blocking_script返回true,暂停DOM树的构建,转去执行JS

if (IsPaused()) {
      // The script or stylesheet should be the last token of this bunch.
      DCHECK_EQ(it + 1, tokens->end());
      if (IsWaitingForScripts())
        RunScriptsForPausedTreeBuilder();
      ValidateSpeculations(std::move(chunk));
      break;
    }

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

void HTMLDocumentParser::RunScriptsForPausedTreeBuilder() {
  DCHECK(ScriptingContentIsAllowed(GetParserContentPolicy()));

  TextPosition script_start_position = TextPosition::BelowRangePosition();
  Element* script_element =
      tree_builder_->TakeScriptToProcess(script_start_position);
  // We will not have a scriptRunner when parsing a DocumentFragment.
  if (script_runner_)
    script_runner_->ProcessScriptElement(script_element, script_start_position);
  CheckIfBodyStylesheetAdded();
}

到scriptrunner->ProcessScriptElement(script_element, script_start_position);(html_parser_script_runner.cc,跟进去

void HTMLParserScriptRunner::ProcessScriptElement(
    Element* script_element,
    const TextPosition& script_start_position) {
  DCHECK(script_element);

  // FIXME: If scripting is disabled, always just return.

  bool had_preload_scanner = host_->HasPreloadScanner();

  // Spec: An end tag whose tag name is "script" ... [spec text]
  //
  // Try to execute the script given to us.
  ProcessScriptElementInternal(script_element, script_start_position);

  // Spec: ... At this stage, if there is a pending parsing-blocking script,
  // then: [spec text]
  if (HasParserBlockingScript()) {
    // Step A. If the script nesting level is not zero: ... [spec text]
    if (IsExecutingScript()) {
      // Step A. ... Set the parser pause flag to true, and abort the processing
      // of any nested invocations of the tokenizer, yielding control back to
      // the caller. (Tokenization will resume when the caller returns to the
      // "outer" tree construction stage.) [spec text]
      //
      // TODO(hiroshige): set the parser pause flag to true here.

      // Unwind to the outermost HTMLParserScriptRunner::processScriptElement
      // before continuing parsing.
      return;
    }

    // - "Otherwise":

    TraceParserBlockingScript(ParserBlockingScript(),
                              !document_->IsScriptExecutionReady());
    parser_blocking_script_->MarkParserBlockingLoadStartTime();

    // If preload scanner got created, it is missing the source after the
    // current insertion point. Append it and scan.
    if (!had_preload_scanner && host_->HasPreloadScanner())
      host_->AppendCurrentInputStreamToPreloadScannerAndScan();

    ExecuteParsingBlockingScripts();
  }
}

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

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

if (!IsExecutingScript())
      Microtask::PerformCheckpoint(V8PerIsolateData::MainThreadIsolate());

    // Spec: ... Let the old insertion point have the same value as the current
    // insertion point. Let the insertion point be just before the next input
    // character. ... [spec text]
    InsertionPointRecord insertion_point_record(host_->InputStream());

    // Spec: ... Increment the parser's script nesting level by one. ... [spec
    // text]
    HTMLParserReentryPermit::ScriptNestingLevelIncrementer
        nesting_level_incrementer =
            reentry_permit_->IncrementScriptNestingLevel();

    // Spec: ... Prepare the script. This might cause some script to execute,
    // which might cause new characters to be inserted into the tokenizer, and
    // might cause the tokenizer to output more tokens, resulting in a reentrant
    // invocation of the parser. ... [spec text]
    script_loader->PrepareScript(script_start_position);

最后一行调用了script_loader->PrepareScript,这是一个非常重要的函数,里面进行了多次判断当前的script是否可以执行,并对当前的执行上下文进行了检测和分类,同时看到这里的注释:这一步可能导致JS的执行并增加更多的token,可以说从这个函数开始就是每个script执行时必须要经过的地方,最后调用

ScriptLoader::ExecuteScriptBlock
script->RunScript(frame, element_->GetDocument().GetSecurityOrigin())

执行了JS,建议看下PrepareScript这个函数里的几十个判断,能理解chromium对script的执行有什么限制

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

// Always check isParsing first as m_document may be null. Surprisingly,
    // isScheduledForUnpause() may be set here as a result of
    // processTokenizedChunkFromBackgroundParser running arbitrary javascript
    // which invokes nested event loops. (e.g. inspector breakpoints)
    CheckIfBodyStylesheetAdded();
    if (!IsParsing() || IsPaused() || IsScheduledForUnpause())
      break;

    if (speculations_.IsEmpty() ||
        parser_scheduler_->YieldIfNeeded(
            session, speculations_.front()->starting_script))
      break;

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

下篇将解析DOM操作以及相关思考。

源链接

Hacking more

...