【人人都能读标准】11. 原理篇总结:一个程序的完整执行过程

本文为《人人都能读标准》—— ECMAScript篇的第11篇。我在这个仓库中系统地介绍了标准的阅读规则以及使用方式,并深入剖析了标准对JavaScript核心原理的描述。

我们一路走了很远很远,终于到了原理篇的最后一站。

在原理篇中,我们先讲了语言的文法模型、阅读规则以及基于这个文法模型构建的解析树;然后我们扩展到标准使用的两类算法,分别是抽象操作以及用于表达解析树语义的语法导向操作;随后,我们马不停蹄地讲到标准用来表示算法中间结果以及语言抽象概念的规范类型;最后,我们使用3个章节从大到小讲了ECMAScript的执行环境,分别是agents、调用栈、执行上下文、Realm、作用域与作用域链。

有了这些基础,我们就有了理解ECMAScript程序整个执行过程的所有“拼图”,本节是对原理篇讲的所有内容的一个梳理与串联,我会先概括性地讲ECMAScript程序执行的一般过程,然后我会使用一段著名的代码片段作为案例,为你展示ECMAScript程序实际的执行过程。

一个程序的执行过程

一个典型的ECMAScript程序,执行时会经历以下三个阶段:

  1. 初始化Realm环境:InitializeHostDefinedRealm()

  2. 解析脚本:ParseScript()

  3. 执行脚本:ScriptEvaluation()

初始化Realm环境是所有程序执行前的必经阶段,Realm会给程序提供最少必要的运行资源。在这个阶段中,会创建一个全局执行上下文以及一个Realm记录器。Realm记录器包含了由ECMAScript标准定义的所有固有对象、一个很大一部分由宿主定义全局对象、以及一个全局环境记录器

初始化完毕的Realm,可以在未来给多个脚本使用,比如一个HTML页面中不同的Script标签都会使用同一个Realm。

而每一段脚本执行前,需要先解析脚本,脚本解析主要经历以下两个过程:

  1. 语法解析:基于词法文法对代码进行词法分析,得到代码中所有的输入元素;以目标符Script对这些输入元素进行句法分析,并最终得到一颗解析树;
  2. 对解析树所有的节点进行先验错误的检查:
    1. 如果有错误,返回一个列表,这个列表包含一个或以上的SyntaxError对象,表示所有提前发现的错误,并直接终止程序的执行;
    2. 如果没有错误,则返回一个脚本记录器,记录上一步创建的Realm记录器以及这一步得到的解析树等信息。

在执行脚本的阶段,会先进行全局声明实例化,把全局标识符绑定到全局环境记录器中。接着会调用解析树根节点Script的求值语义,并最终完成整个程序的执行。

以一段著名的程序为例

防抖/节流是特别火的面试题,它们都用于限制事件的频繁触发。防抖的作用是:当事件在短时间内多次发生时,只触发最后一个事件的逻辑。

一段实现防抖的代码如下:

function debounce(func, delay) {
  let timer = null;
 
  function closure(){
    clearTimeout(timer);
    timer = setTimeout(() => func(...arguments), delay || 250)
  }
  
  return closure
}
function handleScroll() {
  console.log("heavy work")
}
window.addEventListener('scroll', debounce(handleScroll))

在这里,debounce() 是防抖函数,handleScroll()是我打算让scroll事件监听的逻辑。通过调用debounce(handleScroll)会返回一个闭包函数closure,该闭包通过”私有变量”timer控制handleScroll()的执行频率。

下图是这段程序的执行过程的总结:

【人人都能读标准】11. 原理篇总结:一个程序的完整执行过程

这段代码的执行会经历我们前面讲的程序执行的三个过程:初始化Realm环境、解析脚本、执行脚本。在执行脚本的过程中,最终会通过debounce(handleScroll)创建函数closure,用作scroll事件的绑定逻辑。等到用户触发scroll事件的时候,这段防抖程序早已执行完毕,但宿主环境会帮助我们触发closure的逻辑。

在这张图中,插了红旗的地方是我在下面重点关注的步骤:

1. 初始化Realm环境

这段防抖代码显然是在浏览器宿主中执行,浏览器宿主所创建的全局对象是我们非常熟悉的window 对象。因此,在这一步中,得到的Realm记录器重要的字段如下:

{
  [[Intrinsics]]: {...固有对象}
  [[GlobalObject]]: window
  [[GlobalEnv]]: 一个全局环境记录器
}

全局环境记录器记录了全局对象的属性方法,我们在这段代码中使用的windowclearTimeoutsetTimeout这些标识符,在此时已经绑定在全局环境记录器的[[ObjectRecord]]字段当中,静候我们的调用。

完成初始化后,调用栈如下图所示:

【人人都能读标准】11. 原理篇总结:一个程序的完整执行过程

2. 解析脚本

通过语法解析后,上面的代码得到这样一颗树,不必担心这颗树看起来有点复杂,我们后续都会进行逐一拆解的。

【人人都能读标准】11. 原理篇总结:一个程序的完整执行过程

你也可以使用我在5.文法汇总提到的方法,利用js解析器acorn自行解析得到这颗树,并使用JSON可视化工具来可视化这颗树。

这段代码通过了所有先验错误的检查,最终我们得到脚本记录器如下:

{
  [[Realm]]: 第一步得到的Realm记录器
  [[ECMAScriptCode]]: 解析树 
}

3. 执行脚本

执行脚本的过程会由ScriptEvaluation()触发,我们在9.作用域其实已经拆解过这个抽象操作了:

【人人都能读标准】11. 原理篇总结:一个程序的完整执行过程

它会先(2)创建全局执行上下文,(3~8)初始化执行上下文中的组件,(10)把执行上下文压入调用栈,接着进行(12)全局声明实例化。

我们可以从解析树的片段中看到,全局代码有三个语句,分别是2个函数声明语句以及1个表达式语句。

【人人都能读标准】11. 原理篇总结:一个程序的完整执行过程

在进行全局声明实例化的时候,函数声明语句会被识别为变量声明,因而函数对象会被创建,函数标识符会被绑定在全局环境记录器中,并初始化值为函数对象。在这颗解析树上,我们看到两个函数声明语句的标识符分别是debouncehandleScroll,因而,当完成全局声明实例化时,调用栈的样子如下图所示:

【人人都能读标准】11. 原理篇总结:一个程序的完整执行过程

此后,开始(13.a)执行Script求值语义。我们在6.算法提到过,基于链式产生式的特点,对Script的求值最终会导向对其“后代节点”语句列表StatementList的求值。在7.规范类型中,我又进一步给你展示了语句列表求值语义的详细过程,总的来说就是依次执行语句列表中的语句,直到执行完毕或被“硬性完成”提前终止。在这段防抖代码中,对语句列表的求值便是依次执行两个函数声明语句以及1个表达式语句

函数声明语句的求值语义会直接返回一个空值,不会产生任何实际效果。这是因为函数声明语句在全局声明实例化的时候已经发挥作用了。

而相对复杂的是后面的表达式语句。从下面的解析树片段你可以看到,这个表达式语句包含的是一个函数调用表达式CallExpression,并可以进一步分解成一个成员表达式MemberExpression以及一个参数表达式Arguments

【人人都能读标准】11. 原理篇总结:一个程序的完整执行过程

从CallExpression的求值语义我们可以看到,它主要做这么两个事情:

  1. (下图红色标号1)它会先对MemberExpression求值,获取对应的函数,这里得到的是一个宿主的内置函数window.addEventListener
  2. (标号2)然后通过抽象操作EvaluateCall执行函数,此时会把参数也传入这个抽象操作中使用。

【人人都能读标准】11. 原理篇总结:一个程序的完整执行过程

在抽象操作EvaluateCall实际执行window.addEventListener前,它会先对参数进行求值,获得参数的值(下图框出部分)。在我们防抖代码的例子里,此时便是开始执行debounce(handleScroll)的时候。

【人人都能读标准】11. 原理篇总结:一个程序的完整执行过程

执行debounce(handleScroll)

函数执行的核心逻辑来自于它的[[call]]方法,这个方法主要完成以下这么几个事情:

  1. 创建并初始化执行上下文;
  2. 函数声明实例化;
  3. 执行函数语句列表;
  4. 弹出执行上下文

当然,这只是一个大致算法轮廓,并不完整。在应用篇14.函数中,我会对函数的执行过程有更加详细的介绍。

函数声明实例化的过程我在9.作用域做了非常详细的介绍,它不仅会像全局环境一样绑定4种典型的声明语句的标识符,还会初始化参数,并按需创建一个arguments对象。当完成debounce函数声明实例化的时候,环境中的调用栈如下图所示:

【人人都能读标准】11. 原理篇总结:一个程序的完整执行过程

在创建closure函数的过程中,会将函数对象的[[Environment]]内部插槽设置为debounce函数环境记录器,用于后续构建closure函数的作用域链。

此后,我们开始依次执行函数的语句列表内的语句。

【人人都能读标准】11. 原理篇总结:一个程序的完整执行过程

从上面的解析树片段我们可以看到,该函数有三个语句(绿色部分):

  1. 词法声明语句。用于初始化词法标识符timer,执行完这个语句之后,timer才可以被其他代码使用。
  2. 函数声明语句,在函数创建阶段已经发挥作用了,此时直接跳过。
  3. return语句,返回closure函数对象,结束函数的执行。

完成debounce函数的执行后,debounce执行上下文会弹出调用栈,并被销毁。

随后,就是内置函数window.addEventListener的执行,他会给window添加一个scroll事件的监听,监听的逻辑就是执行debounce返回的闭包函数closure。

4. scroll事件触发

当scroll事件触发的时候,宿主会自动触发闭包函数closure的逻辑。而执行一个函数实际上还是经过以上四个步骤:

  1. 创建并初始化执行上下文;

  2. 函数声明实例化。在函数声明实例化之前,closure函数会先创建函数环境记录器,并把[[OuterEnv]]指向自己[[Environment]]内部插槽中保存的环境记录器(即debounce函数环境记录器),从而使得closure函数可以访问已经执行完毕的debounce函数内部的变量。当closure函数第一次被触发时,调用栈如下所示(此时timer仍为null):

    【人人都能读标准】11. 原理篇总结:一个程序的完整执行过程

  3. 执行函数语句列表;closure有两个表达式语句。

    • 第一个函数调用表达式语句会执行内置函数clearTimeout(),用以重置定时器timer。

    • 第二个赋值表达式会创建新的定时器并把定时器的序号赋值在变量timer上,而定时器设定的逻辑,就是触发我们的handleScroll()

      【人人都能读标准】11. 原理篇总结:一个程序的完整执行过程

  4. 弹出执行上下文。

在此之后,如果在定时器设定的时间内没有再触发过这个closure函数,那么handleScroll()的逻辑就会被触发,从而实现了防抖的效果。

原文链接:https://juejin.cn/post/7212872945504976952 作者:水鱼兄

(0)
上一篇 2023年3月21日 下午7:15
下一篇 2023年3月21日 下午7:28

相关推荐

发表回复

登录后才能评论