前言本文主要对W3C规范中脚本标签和事件循环的长度进行了简单的讨论,并适当标注了一些必要的相关概念和说明。虽然之前也接触过,但是都太零散了。希望借此机会对这些概念有更全面的了解,也希望能与大家多多交流。由于知识的深度和广度,以及英语水平的不足,如有错误,还请大家多多包涵,指正。小波折虽然之前查过W3C和WHATWG的关系,但是当翻译的差不多的时候,我向WHATWG提了一个issue,被domenic告知我可能读了“fakespecification”--(For详情请参考链接一、链接二、Fork跟踪),最新的规范在这里,大部分基本相同,增加了一些新的内容如type=module等,排版也有一些变化,一些说明等等,有兴趣的可以去看看。HTML解析浏览器的HTML解析过程如下:由于大多数历史原因,这些属性的确切处理细节有些不平凡,涉及HTML的许多方面。因此,实施要求必然分散在整个规范中。可以看到注意到规范中也提到了,规范只是一个参考,具体实现因人而异。在测试过程中,我列出了以下我发现并期待讨论的主题,希望对自己和其他人有所帮助:Scripttags关于scripttags的一些基本信息的描述这里就不介绍了,但是我有几个比较关心的点如下所列。defer和async属性针对普通脚本、defer脚本、async脚本总结如下:1、对于普通脚本,有两点需要注意。第一:在fetch时并没有完全“阻塞”后续标签的解析。我们从时间轴上可以看出,在第一个praseHTML一开始,页面需要的静态资源请求就已经全部发送出去了(详见浏览器预解析加载机制)。因此脚本无法“阻止”发送在后续标签中引用外部资源的请求。并且在脚本解析到加载完成的这段时间里,还有很多其他的操作,比如扩展程序的脚本执行,一些VM语句的执行,安装脚本之前脚本中的定时器等等。第二:在fetch之后接收到所有的数据包,最后完成finishload之后,并没有立即执行这个脚本中的内容。而是首先要判断这个脚本在所有脚本中的顺序,在执行这个脚本之前必须确定这个脚本之前的所有普通脚本都已经执行过了。脚本标签处理模型处理模型有以下7个状态属性:“已经启动”>>“parser-inserted”>>“非阻塞”>>“readytobeparser-executed”>>“thescript'stype"->>"isfromanexternalfile"->>"thescript'sscript"最后一步是将预处理脚本(见下文)的结果异步设置为脚本的脚本属性的值,无论是否这个值是正确的还是错误的,应该将脚本标记为就绪,这意味着之后可以触发其他操作。浏览器推迟加载事件,直到所有脚本准备就绪。关于这些状态的描述不多。例如,开头没有“alreadystarted”,HTML解析器解析后立即设置为“alreadystarted”。开头没有“parser-inserted”。当HTML解析器插入节点到达父节点时,将其设置为“parser-inserted”。当HTML解析器创建节点对象时,默认为“非阻塞”。当HTML解析器将节点插入到父节点时,设置为“blocking”(其实设置为false,好理解伪装成这样翻译,别打我。。。),如果脚本具有异步属性,则将其设置为“非阻塞”以避免阻塞解析等。详情建议查阅文档。这里讨论最关键的部分——preprocessingscript(原文是prepareascript,感觉翻译成preparation不合适,所以假装是这个翻译,如果有更好的翻译,望指正it),"thescript'stype","fromanexternalfile","thescript'sscript"都是在这个阶段确定的:预处理脚本当一个没有标记为“parser-inserted”的脚本元素遇到以下3种情况中的任何一种时events,浏览器必须立即对这个script元素进行预处理:1.script元素是在这个script的脚本插入dom树之后按顺序先于(之前指的是按照前序深度优先遍历))在dom节点中插入文档。2.插入所有脚本元素后,脚本元素在文档中,其他节点插入到脚本元素中。3.script元素已经在文档中,之前没有src属性,现在设置了src属性。为了预处理脚本,浏览器必须执行以下步骤:前1-18步的主要考虑是中断预处理过程,以便在不需要执行或执行时不执行脚本条件不满足。比如发现没有“alreadystarted”,比如没有src属性并且脚本内容为空或者只有注释,文档中没有script元素,type和language属性做不符合规范,用户禁用了JS,等等。除了这些,还有一些设置比如脚本有charset,如果没有则使用文档本身的charset。还有一些只在规范中提到,但是没有浏览器或者不是所有浏览器都实现的,比如for,event,nonce属性等。另外还有一些其他的注意事项,我就不一一赘述了一个在这里,你可以参考规范的详细信息。让我们关注第19-20步:第19步:如果脚本元素没有src属性,继续执行以下步骤:让源文本等于yourScriptElement.text的值。将脚本的类型属性设置为“经典”让脚本作为使用源文本和设置创建的脚本的结果。将脚本的脚本设置为上一步中的脚本。让脚本处于就绪状态Step20:然后,选择满足以下条件的第一个执行:Type1:脚本的类型是否有src属性是否有defer属性是否有async属性other条件“经典”是元素是否具有“解析器插入”将脚本元素添加到将要执行的脚本集合的末尾。当脚本处于就绪状态时,设置脚本元素的“准备好被解析器执行”标志。解析器将处理执行此脚本。类型2:脚本的类型是否有src属性,defer属性是否有async属性,其他条件“经典”,元素是否“parser-inserted”,script元素处于“等待解析”状态被阻止的脚本”(见步骤末尾),一次只能存在一个这样的脚本。当脚本处于就绪状态时,设置脚本元素的“准备好被解析器执行”标志。解析器将处理执行此脚本。类型3:脚本的类型是否具有src属性?它有延迟属性吗?它有异步属性吗?预处理脚本从添加脚本元素开始,以便在要执行的脚本集合的末尾添加脚本元素。当脚本处于就绪状态时,执行以下步骤:1.如果脚本不是要执行的脚本集合的第一个元素,则将脚本标记为就绪,但中断其余步骤,不执行脚本。2.执行脚本。3.删除要执行的脚本集中的第一个元素。4.如果要执行的脚本集合仍然不为空,第一个元素被标记为就绪,则跳回到步骤2。类型4:脚本的类型是否有src属性是否有defer属性是否有异步属性其他条件“经典”是或否是或否不适用尽快开始预处理脚本时将脚本元素添加到将在集合结束时执行的脚本。脚本准备就绪后,执行脚本并将其从集合中移除。类型5:脚本的类型是否具有src属性?它有延迟属性吗?它是否具有异步属性其他条件“经典”否是或否是或否元素已被“解析器插入”,并且XML或HTML解析器的脚本嵌套级别比率低或等于创建此脚本。创建此脚本的解析器的文档具有阻止脚本执行的css。脚本元素处于“等待解析被阻止的脚本”状态。一次只能存在一个这样的脚本。设置脚本元素的“准备好被解析器执行”标志。解析器将处理执行此脚本。类型6(其他情况):立即执行此脚本,即使其他脚本正在执行。总共这6种情况,这里对上面提到的Waitingtoparseblockedscripts的概念做一个补充说明:如果一个阻塞解析的script元素在它停止解析之前被移动到另一个文档,然而,它仍然阻塞解析直到原因它的阻塞被移除。(例如,如果script元素因为有css阻塞而变成了parse-blocking脚本,但是随后在css加载完成之前将脚本移动到另一个文档,脚本仍然会阻塞解析直到css加载完成(但是其他文档的解析被阻塞),但在此期间,原始文档的脚本执行和HTML解析是顺利的)规范用户代理中的事件循环是指实现这些规范的应用程序。为了更好的描述,我们暂时用浏览器来代替这个描述。为了协调事件、用户界面、脚本、渲染、网络等,浏览器必须使用事件循环。对于事件循环,有两种,一种是针对浏览器上下文的(一定要先理解这个概念),另一种是针对Wokrer的。由于我们对Worker不熟悉,所以这里主要讨论浏览器相关的东西,下面就不描述Worker相关的内容了。一个事件循环有一个或多个任务队列。任意队列是一系列有序的任务集合。这样的队列通过以下算法工作:事件:通常对于一个专门的任务,一个事件对象被分派到一个特定的EventTarget对象。另外,并不是所有的事件都是通过任务队列来派发的(哪些不是,请参考区别)。解析:HTML解析器将一个或多个字符转换为标记表并进行处理。这个过程是一个典型的任务。回调:经常调用回调函数,通常适用于专有任务。使用资源:在获取资源时,如果获取发生在非阻塞方法中,一旦部分或全部资源可用,它也会作为任务执行(即在时间轴中接收数据并完成加载)。ReactingtoDOMmanipulation:响应dom变化,一些元素也会产生任务。例如当一个元素被插入到文档中时。(意思是插入后,浏览器会重新计算布局,渲染,也会触发一些监听节点变化的事件,这些都是任务。)浏览器上下文的事件循环中的每个任务都与Document对象相关(准确的说是实现了Document接口的对象,规范中也提到为了描述方便没有使用这个准确的术语,因为它太长了)association。如果将任务添加到元素的上下文队列中,则文档对象是该元素的节点文档。如果将任务添加到浏览器上下文的上下文队列中,则文档对象在排队时是浏览器上下文的活动文档。如果任务是通过脚本或针对脚本的,那么文档对象就是脚本的配置对象指定的负责文档。对任何事情负责)。当浏览器将任务排入队列时,它必须将任务排入关联事件循环中的任务队列之一。每个任务在定义时都会有一个指定的任务源(一共有4种,DOM操作任务源、用户交互任务源、联网任务源、历史遍历任务源)。所有来自特定任务源的任务都必须加入到特定的同一个事件循环中(比如Document对象产生的回调函数,触发Document对象上的mouseover事件,Document中等待解析的任务等,它们都具有相同的事件源——文档),但是来自不同任务源的不同任务可能会被添加到不同的任务队列中。例如,浏览器可能有鼠标和键盘任务队列(均来自用户交互任务源)和其他任务队列。然后,相对于其他任务队列,浏览器可能会给予鼠标和键盘事件更高的优先级以持续响应用户交互,但这不会饿死其他任务队列。并且永远不要颠倒来自同一任务源的事件顺序(这意味着任务必须按照添加的顺序执行)。每个事件循环都有一个当前正在执行的任务。初为空。用于处理重入(reentrancy,类似于generator,在inline脚本中直接使用document.write时会出现这种情况,因为它把write的参数写入到之前的输入流(也就是未解析的字节流)里面).每个事件循环还有一个执行微任务检查点的标志,该标志最初为false。它用于防止对算法执行微任务检查点的重入调用。关于microtask:每个事件循环都有一个microtask队列,microtask队列中而不是普通任务队列中的任务称为microtask。这里的微任务分为两种,一种是单回调函数微任务,一种是复合微任务。注意,规范中只有对单回调函数微任务的具体描述。一个事件循环在其存在期间必须不断重复以下步骤:1.取出某个任务队列(如果存在)头部的任务。如果与浏览器上下文的事件循环关联的文档对象未完全激活,则忽略此任务。浏览器可以选择任何任务队列。如果没有要执行的任务,请跳至步骤6。2.设置事件循环当前运行的任务为上一步选择的任务。3.运行任务。4.将事件循环当前运行的任务设置为null。5.从任务队列中删除在步骤3中运行的任务。(这也说明取任务时执行的队列操作是peek,不是poll)6.执行一个microtaskcheckpoint操作。因为很多,为了避免混淆,我会在这7个步骤完成后写位置。7.更新渲染:如果事件循环是浏览器上下文事件循环而不是Worker事件循环,则执行以下步骤:让now等于now()方法的返回值。(可以理解为时间轴中的开始时间)让docs等于这个事件循环关联的Document对象的集合。本藏品为随机排序,但必须遵循一定的原则,详见规范。例如DocumentA嵌套B和C,B嵌套D。那么顺序可以是A,B,C,D或者A,B,D,C。只要保证C在B的后面,B,C就是在A后面,D在B后面。遍历文档,对于其中的每个文档。如果存在顶层浏览器上下文B(在嵌套浏览器上下文的情况下,顶层是指最祖先的浏览器上下文,更形象的描述请参考链接)并且不会从这次更新的渲染中受益,那么它将删除docs中所有浏览器上下文中顶级浏览器上下文为B的Document对象。顶级浏览器上下文是否受益于呈现更新取决于几个因素,例如更新频率。例如,如果浏览器正在尝试60Hz刷新率,则这些步骤每16.7毫秒才有意义。如果浏览器发现一个顶层浏览器上下文无法维持这个频率,它可能会将docs集合中所有文档对应的刷新率降低到30HZ,而不是偶尔降低频率。(规范并没有规定任何特定模型何时更新渲染),同样,如果一个顶级浏览器上下文在后台(我不太明白,我猜它的意思是显示:无等),那么浏览器可能降到4HZ,甚至更低。浏览器可能跳过更新渲染的另一个例子是确保某些任务在某些任务之后立即执行,仅交替微任务检查点。(或者没有这些交替,比如requestAnimationFrame中动画帧的回调函数交替)。例如,浏览器可能希望合并计时器回调而不在合并时呈现更新。如果存在浏览器认为不会从呈现更新中受益的嵌套浏览器上下文B,则从文档中删除那些具有浏览器上下文B的元素。与顶级浏览器上下文一样,对于嵌套的浏览器上下文,许多因素会影响它是否会从更新的呈现中受益。例如,浏览器可能希望花费更少的资源来呈现第三方内容,尤其是当前不可见或受限的内容。在此示例中,浏览器可能决定很少或根本不更新这些内容的呈现。对于文档中每个完全活动的文档对象,触发调整大小对于文档中每个完全活动的文档对象,触发滚动对于文档中每个完全活动的文档对象,触发媒体查询并提交更改对于文档中每个完全活动的文档对象运行CSS的文档对象动画和发送事件。对于文档中的每个完全活动的文档对象,运行全屏渲染步骤。对于文档中的每个完全活动的文档对象,运行动画回调函数。对于文档中的每个完全活动的文档对象,更新呈现或用户界面,以及浏览器上下文以反映当前状态。9.返回步骤1继续。继上面提到的第6步之后,微任务检查点操作执行如下:当算法需要将微任务添加到队列中时,必须将其追加到相关事件循环的微任务队列中。这个微任务的任务源称为微任务任务源。可以将微任务移动到普通任务队列,如果发生这种移动,它将在第一次运行时旋转事件循环步骤。当浏览器执行微任务检查点时,如果执行微任务检查点的标志为假,则浏览器必须执行以下步骤:1.将标志设置为真。2.如果事件循环的微任务队列为空,则跳到第8步:3.取出微任务队列头部的元素。4.设置事件循环当前运行的任务为上一步取出的任务。5.运行任务。注意:这可能会涉及调用回调函数,最终会调用一个cleanupstep,可能会在cleanupstep中执行一个microtaskcheckpoint操作,导致递归没有终止条件,这也是我们需要使用这个flag来避免这种情况的原因.6.将事件循环的当前运行任务设置为null。7.从微任务队列中移除上面运行的任务。然后返回步骤2。8.对于每个负责的事件循环,为该事件循环的环境配置对象,通知拒绝的承诺。9.将标志设置为false。时间线相关的就像用迅雷同时下载10个文件。假设我们的下载速度是1M/s,那10个资源每一个都以100kb/s的速度下载显然是不可能的,因为每个资源的资源流行度不同,所以有的是500kb/s,有的可能只有20kb/s,有的甚至下载不了。浏览器也是如此。浏览器的资源调度算法和每个时间段的网络情况决定了下载资源的顺序、消耗的能量等。以chrome的资源获取优先级算法为例,不难看出在获取到html之后,css的请求优先级最高,因为对于现在的网页来说,没有css的后果可能远大于没有其他资源。对于脚本发起的请求,比如通过接口获取数据等,高。对于普通的js,优先级是中等,普通图片和异步脚本是低等,随着时间的推移,这个算法肯定也会发生相应的变化,以提高到时候的应用体验。关于网络中的这些点,没有什么比Queuing和Stalled属性更相关的了:Queueing。浏览器在以下情况下对请求进行排队:有更高优先级的请求。已为此源打开六个TCP连接,这是限制。仅适用于HTTP/1.0和HTTP/1.1。停滞:请求可能由于排队中描述的任何原因而停滞。所以浏览器一开始会按照资源在html中出现的顺序发送获取资源的请求,但是接收到资源的顺序并不是必须是这个顺序。一个请求发出后,后面又来了一个请求,而这个请求的优先级比当前请求高,所以很可能先接收到优先级高的资源的数据。对于相同优先级的多个资源,很可能你收到一条数据,我收到一条数据。也就是说,我们经常看到页面中的图片在加载的时候,是同时从白屏中慢慢加载多张图片,而不是一张加载完再加载另一张。另外前面说了,对于普通的脚本来说,肯定是按照html的顺序执行的,也就是说,如果脚本a只有500kb,后面的脚本b也只有1kb,那么即使脚本b得到了所有bytes完成finishload后,不能立即执行。只有在脚本a获取所有字节并执行后才能执行。而如果a和b都是async脚本,就不用遵循这个原则了,谁先拿到就先执行。为什么呢,因为async设计的初衷就是把与页面无关的逻辑抽取出来,它们之间不应该有任何的连贯性和依赖性,更不要说后面的普通脚本,更不要说依赖它们来干活了。所以在下面链接提到的视频中,提问者说只要不对dom进行操作和获取,应该将这些公共代码提取出来放在head中引入async,达到性能优化的效果,这其实是不合适的。比如loadsh就满足了这个要求。显然,我们不能这样做。首先,因为lodash太大,不能保证body末尾使用lodash代码的脚本一定比lodash晚执行。第二,由于网络原因,lodash是一个只有1kb的资源,很难保证。写在这个阅读规范的过程的最后,让我学到了很多知识,已经超过了我一开始想要获取的知识。这就是学习的乐趣。当然也有很多地方花了很长时间才弄明白这个表达的意思,还有一些问题至今没有弄明白。如果有不明白或者觉得不对的地方,希望多交流,希望随着时间的推移,回过头来摸索的时候能明白。阅读更多关于the-javascript-event-loop-explainedfromtheChromesourcecode看浏览器如何构建DOM树从Chrome源码看浏览器的事件机制好)
