【人人都能读标准】8. ECMAScript运行环境:agents、执行上下文、Realm

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


3.宿主环境我们提到过,ECMAScript必须依赖一个宿主环境才能实现完整的功能。下图是在宿主环境中,与执行ECMAScript相关的“组件”:

environment-summary

总的来说,每个ECMAScript程序都必须在一个名为agent的区域内执行。一个agent对应一条执行线程。一个宿主可以有多个agent,当两个agent同处一个agent集群时,它们可以共享内存。

每一个agent都有自己独享的、且唯一的执行上下文栈,也称为调用栈,用来管理agent内代码的执行顺序。栈内可能会有一个或多个执行上下文,栈顶元素是正在执行的执行上下文

每个执行上下文都有一个对应的Realm,来提供代码执行的基础资源。由代码创建的执行上下文,还会有一个词法环境,它是作用域链的起点。

本节,我将会对这些环境中的组件,按照从大到小的顺序进行介绍。


Agents与Agent集群

HTML标准8.1.2中,定义了在浏览器宿主中3类不同的agent:

  • Similar-origin window agent:新建一个浏览器窗口/标签、使用iframe加载一个新的页面都会创建这类agent。这类agent有自己关联的window对象。
  • worker agent:新建一个worker时创建的agent,这类agent有自己专属的 global对象。worker agent还可以进一步分为Dedicated worker agentShared worker agentService worker agent
  • worklet agent: 开发者无法直接使用这个agent,这是浏览器实现自身功能的一个基础设施。

具体的说,一个agent包含:一条执行线程、一个agent记录器(Agent Record)、以及一个调用栈。

执行线程会执行调用栈中执行上下文的任务。 尽管标准提到,不同的agent允许共享同一条执行线程,但现代浏览器基本上都是给每个agent启用一条独立的线程。给以下代码启动一个HTTP服务,你便可以在debugger上观测到这一点:

<body>
    <iframe src="https://example.com"></iframe> 
    <script>
        new Worker("worker-agent.js")
    </script>
</body>

在这里,主页面、worker、iframe所创建的agent都独享一个线程:

thread-example

执行线程另一个更加为人熟知的名字叫「事件循环」,我们在3.宿主环境提到过,“事件循环”这个概念来自于HTML标准而不是ECMAScript标准。如果你想了解HTML标准是如何定义“事件循环”的,可以阅读8.1 浏览器的事件循环,这是来自《人人都能读标准》的第二部 —— HTML篇(重构中)里面的文章。


Agent集群

一个Agent集群(agent clusters)可能包含多个agents,位于同一agent集群中的agent可以通过SharedArrayBuffer等内置对象进行内存共享。

计算机基础告诉我们:「线程(threads)」不能单独存在,它由「进程(process)」启动和管理,位于同一进程中的线程可以共享数据。从这个角度看,Agent集群划定了“进程的边界”。

Agent记录器的所有字段,都与内存操作相关,为了不偏离主题,这里便不再展开,让我们把更多的注意力放在更为重要的地方上,即agent的最后一个组成部分 —— 调用栈以及栈内的执行上下文。


调用栈与执行上下文

我先对执行上下文常见的理解做一个总结:

JavaScript使用执行上下文追踪运行时执行的代码,并使用调用栈来管理执行上下文。比如,每调用一个函数都会创建一个新的执行上下文,并压入栈顶;当这个函数执行完毕,对应的执行上下文就会从栈顶弹出。因而,栈顶永远是正在执行的上下文。当调用栈的空间被占满的时候,就会出现栈溢出的问题。

执行上下文一共有三种创建方式:

  • 执行全局代码会创建全局执行上下文
  • 执行函数会创建函数执行上下文
  • 使用eval方法会创建eval执行上下文。

比如,执行以下的代码:

a()
function a(){
     
     
  console.log(1)
  b()
}
function b(){
     
     
  console.log(2)
  c()
}
function c(){
     
     
  console.log(3)
}

// 连续输出1,2,3

调用栈会经历下图中的过程:

execution-context1

这里的内容大体上是对的,但也有错误或者说不完整的地方。我们现在再来看看标准对于执行上下文的描述:


执行上下文远不止3种创建方式

如果你对标准进行一次全文检索,你会发现创建执行上下文的方式有这么几种:

  1. 程序初始化的时候会创建一个全局执行上下文;
  2. 普通脚本Script的执行会创建执行上下文;
  3. 模块脚本Module环境初始化时会创建执行上下文;
  4. 模块脚本Module的执行会创建执行上下文;
  5. 函数的执行会创建执行上下文;
  6. eval方法的执行会创建执行上下文;
  7. 会创建迭代器的内置对象方法,如Array.prototype.keys()Map.prototype.values(),执行时会额外创建一个与迭代器关联的执行上下文;

第一种方式与第二种方式常常被混为一谈。我们在前言已经分析过了,如果普通脚本的执行不会创建新的执行上下文,以下的代码就不可能正常按照1、2、3输出:

<script id="test"></script>
<script>
    console.log(1); 
    (function(){
      
      
        document.getElementById('test').innerHTML = "console.log(2);"
        console.log(3)
    })()
</script>

执行这段代码,实际的调用栈变化过程如下图所示:

execution-context2

而模块脚本执行上下文是被普遍忽视的部分。正因为模块的执行也会创建执行上下文,才使得以下的代码得以正常按照1,2,3的顺序输出:

<!-- index.html -->
<script>
    console.log(1);
    (async function(){
      
      
        await import('./test.js')
        console.log(3)
    })()
</script>

<!-- test.js文件 -->
console.log(2)

此时调用栈的变化过程如下图所示:

execution-context3


执行上下文是实现异步编程的基础

我还是先用可视化的方式为你呈现异步执行过程中调用栈的变化:

以下是一个Generator函数:

function* a(){
    
    
    console.log(2);
    yield;
    console.log(4);
}
b = a()
console.log(1)
b.next()
console.log(3)
b.next()

执行过程中,调用栈的变化如下图所示:

execution-context4

以下是一个async函数:

async function a(){
    
    
    console.log(1)
    await 0;
    console.log(2);
    await 0;
    console.log(3);
}
a()

此时,调用栈的变化过程如下图所示:

execution-context5

从上面两个例子你肯定可以看出,当遇到yield或者await语句的时候,栈顶的执行上下文会弹出但不会销毁。待未来的某个时机,比如generator调用next方法,或轮到promise的微任务被执行,此时原来的执行上下文会重新压入栈顶,并从前面中断的部分继续执行。

标准在执行上下文身上定义了许多不同的组件(component)。比如,普通脚本、模块脚本所创建的执行上下文,会把解析后得到的脚本记录器模块记录器存储在执行上下文的ScriptOrModule组件中;而函数创建的执行上下文,会把函数对象存储在执行上下文的Function组件中;这两个组件都记录了执行上下文需要执行的代码逻辑。

与异步相关的关键组件是code evaluation state组件。它用于保存执行上下文中代码执行的状态。当代码执行需要暂停的时候(如遇到yield或者await语句),当前代码的执行状态就会保存在该组件中;待代码恢复执行的时候,就会从这个状态开始继续执行。这个组件使得执行上下文关联的代码逻辑可以实现暂停 -> 恢复执行 -> 再暂停 -> 再恢复执行。

以上就是ECMAScript实现异步编程的核心方式。

我为你找到了标准中关于generator异步执行以及async函数异步执行的核心算法,有兴趣你可以先自行阅读:

这些算法,我都会在应用篇16.生成器18.promise作详细的讲解。


执行上下文中的词法环境是作用域链的起点

严谨的说,上面的调用栈变化图也还是不完整的。因为像console.log()这样的内置函数在执行的时候,实际上也是会创建执行上下文的。

我没有在图中显示它的执行上下文,一个主要原因,当然是为了简洁性。但我之所以可以把它省略掉,是因为它创建的执行上下文对我的分析毫无影响。console.log的逻辑是由原生代码编写的,也许它执行的过程中还创建了其他的上下文,但我无法观察它的行为,也无法控制它的行为,正常情况,它的执行也不应该影响我的行为。而那些由我自己编写的函数就不一样了,这种函数不仅可能会影响我其他代码执行的先后顺序,还可能会改变其他代码变量查找的结果。

在ECMAScript中,把那些由开发者编写的代码所创建的执行上下文称为ECMAScript代码执行上下文(ECMAScript code execution contexts)。在我们前面列举的7种创建执行上下文的方式中,使用(2)普通脚本、(3、4)模块脚本、(6)eval方法、以及由开发者编写的函数,所创建都是ECMAScript代码执行上下文。

与由内部代码创建的执行上下文相比,这种执行上下文多了三个组件:

  • LexicalEnvironment:词法环境,标识符解析的起点。
  • VariableEnvironment:变量环境,绑定使用var声明的变量。
  • PrivateEnvironment:私有环境,绑定class的私有字段。

这里面最重要的就是词法环境,它是每一条作用域链的起点。全局执行上下文以及内置函数执行上下文,没有这样的组件,所以在分析作用域链的时候,我们不需要考虑到它们。

当然,这一部分内容,需要更多的作用域相关知识才能解释清楚。我会在9.作用域/10.作用域链两个章节中为你介绍这部分的知识。


而关于执行上下文,还剩最后一个重要的组件,就是Realm组件。


Realm

每个agent中的代码得以执行前,都必须先初始化它的Realm,这个过程由抽象操作InitializeHostDefinedRealm完成。这个抽象操作主要做的事情是:

  1. 新建一个Realm记录器
  2. 创建所有ECMAScript的固有对象,存储在Realm记录器的[[Intrinsics]]字段上。固有对象是那些帮助完成语言基础功能的对象(如Object、Array、Number…),标准的第19~28章定义了这些固有对象。
  3. 创建一个全局对象,存储在Realm记录器的[[GlobalObject]]字段上。我们在3.宿主环境中已经提到,不同的宿主创建的全局对象可能不同,浏览器宿主创建的是window。
  4. 创建一个全局环境记录器,存储在Realm记录器的[[GlobalEnv]]字段上。全局环境记录器用于绑定全局标识符,与全局作用域相关,这部分的内容会留在9.作用域中解释。

从Realm的创建过程你可以看出,Realm是用来给程序的执行提供最基础的资源。

每一个执行上下文都有一个Realm组件,指向该执行上下文关联的代码执行时所使用的Realm。这同时也暗示了,同一调用栈内,不同的执行上下文可能会指向不同Realm。比如以下的代码:

<!-- a.html - 主页面-->
<iframe src="b.html"></iframe>
<button onclick="frames[0].hello()">Hello</button>

<!-- b.html - iframe页面 -->
<script>
  window.hello = () => {
      
      
    debugger; // 
  };
</script>

当点击button时,调用栈上的执行上下文,内部的Realm是不一样的:

realm-stack

你也可以使用以下代码观察到这一点。Function.prototype是一个固有对象,一般来说,函数创建的时候会以它为原型。但从下面的代码你可以看到,b页面中的hello方法,其原型与a页面的中Function.prototype是不等的:

// a.html中
let helloFn = frames[0].hello
console.log(Object.getPrototypeOf(helloFn) === Function.prototype) // false 
console.log(Object.getPrototypeOf(helloFn) === frames[0].Function.prototype) // true

这是因为不管a页面还是b页面,在初始化Realm的时候都各自创建了一套自己使用的固有对象,所以b页面的函数在创建的时候,使用的是b页面中的Function.prototype

在HTML标准中,使用一个settings object对象来标识不同的Realm,setting Object会存储在Realm的[[HostDefined]]字段上。

猜你喜欢

转载自blog.csdn.net/weixin_44384265/article/details/129618894
今日推荐