浏览器执行JS的过程

2023 review: 本文部分内容已经过时,例如document.write已经废弃;另外 在 html解释过程中插入JS,或手动把script放在body前 已经不推荐,或完全没有必要;

如果有中小项目 需要手动控制 JS程序的加载 过程 ,主要关注三个概念(script 标签的三个属性)即可:type(module), defer, async

1 defer:延迟到DOM加载完成后执行,这是最符合逻辑 ,也是最简单的加载方式(注意script标签 默认不开启 ):用html声明式构建DOM,再用命令式JS代码 安装 和处理用户交互 ;

2 async:异步加载 ,适用有比较大的 子模块,并且是位置无关的,因为异步加载 的顺序是不能预知的;

3 module:有工程模块化需要,可异步串行加载,默认是defer的

客户端(client-side)JS的代码可以在HTML标签上定义,<script>可以放在<head>内,也常常被放</body>之前,也常使用<script>的src外部引入,现代浏览器对<script>甚至支持异步(async)和延迟(defer)加载的功能……所有这些种种的JS加载,对入新手,缺乏项目经验的人,该如何选择确实是个难题。

新手困难的原因有二:

  • 第一,新手对浏览如何加载JS程序的,了解不足;
  • 第二,新手对JS程序具体会做些什么,能做些什么,了解不足;

举个例子,把<script>放在</body>之前,与使用延迟式脚本有什么区别?该如何选择?使用何种加载方式,取决于JS脚本的任务性质(是构建交互界面、安装事件处理、还是准备交互计算),例如,脚本有构建交互页面内容(使用document.write),可使用同步加载;如果脚本主要安装事件处理函数,那完全可以使用延迟式脚本,等DOM完全初始化后再执行。

本文略译自《犀牛书》第六版,主要讲述第一个问题,文未有有关第二个问题的一些参考。

网页上JS程序在哪里?

与服务器端JS不同,客户端(client-side)JS没有严格规定「JS程序的形态」,这有历史和应用特性上的原因。我们说客户端JS程序可以由两部分组成:

  • 第一,内嵌部分,包括HTML标签嵌入脚本(tag inline scripts)、<script>嵌入 和书签小程序(javascript: URLs)
  • 第二,外链部分则通过<script>的src属性引用的外部脚本

这些分散定义的程序代码共有一个唯一的全局的window对象,也就是说访问同一个Document对象,并且共享唯一一集全局对象(函数和变量数据),如果代码定义一个全局对象,那这个对象都能被「后续」的代码使用。后续指代码解释的前后时间顺序。

javascript The Definitive Guide

如果网页上有一块嵌入的页帧(使用<iframe>),那页帧内的网页会有自己独立的全局(文档)对象,而被认为是一个独立JS程序。一个窗口有两支不同的JS程序。不过,实践上并没有严格定义JS程序的边界,如果窗口网页和帧内的网页在一个同服务器上,那你也可认为它们是一支JS程序的不同组成部分。

JS书签小程序(bookmarklets)是独立于所有窗口文档的,所以它可被看成是一种用户功能扩展程序,可访问或修改当前窗口的全局对象或文档对象。

JS程序运行的两个阶段

client-side的JS程序(以下简称JS程序)的形态定义如此分散,那它是怎样运行的呢?它的运行过程比较复杂,JS程序运行主要分为两个阶段:页面载入初始化阶段异步事件交互阶段。

交互页载入和初始化阶段

在第一阶段,JS代码随HTML文档的载入和解释先后的执行;

异步事件交互

当文档载入完成,所有JS初始化执行完毕后,JS程序进入第二阶段——异步响应用户的交互操作。在这个事件驱动执行的阶段,浏览器会作为中介,捕捉用户的操作,异步的执行相应的事件处理函数(这些处理函数一般在第一阶段“安装”好,当然也可以另一个处理函数进行动态安装),响应用户的交互操作。常见的交互操作或事件有:鼠标键盘输入、网络访问、定时事件,或程序异常等。

JS程序的生命线(Timeline)

以上将JS的运行概略的分为两阶段——初始化阶段和异步事件响应阶段,具体细节可分为以下8步:

第一,解释HTML源码,并构建对应的文档树

浏览器创建一个Document对象,并启动HTML parser 对网页HTML源码进行解析或分析,将分析到的标签和文本节点分别添加到Document对象上。这个时候「文档的ready状态」document.readyState属性为“loading”;

第二,同步式JS脚本程序的载入

在分析HTML源码过程中,当HTML parser读到「非异步载入的JS脚本」(<script>没有 async 或 defer 属性)时,parser会在Document上添加相应的script对象,并立即处理这个脚本(可以是<script>嵌入或是src外部,外部的要先网络加载)。注意这是一个同步操作,加载并执行JS脚本会暂停parser的分析活动,也就是说<script>之后的HTML源码在要同步脚本完成执行之后,才会被继续分析。看这个图:

JS    ] 由于同步脚本会暂停parser,故在同步脚本中可以使用document.write() ,添加额外的HTML内容。

同步的JS脚本一般定义一些全局功能函数(为后续使用),和安装事件处理函数来响应用户交互,但是,它们也可能也可以修改DOM,只是修改的内容只有包括它自己在内已经分析好的DOM。

第三,异步式JS脚本程序的载入

当parser读到一个异步脚本(<script> 设定了 async 属性),它启动脚本的下载,并且继续分析后续HTML文档。当脚本下载完成会被立即执行,但是在下载过程中parser不会暂停,如上图。 由于异步脚本的执行时间是不确定的,所以不能在脚本中使用document.write(),为HTML添加额外内容,因为异步执行时,文档状态不确定。

第四,文档可交互

当HTML文档被分析完成后,「文档的ready状态」document.readyState属性为“interactive”,文档处于可交互状态。

第五,延迟式JS脚本的执行

至此,延迟式脚本(<script> 设定了 defer 属性)开始按定义先后执行,这个时候延迟脚本可访问完整的文档树,所以脚本里不能使用document.write();

第六,DOM内容加载完成

浏览器在Document对象触发「DOM内容加载完成」事件——DOMContentLoaded。这标志着JS程序从初始阶段转入异步事件驱动阶段。值得注意的是,异步式脚本此时「可能」还在下载中,没有执行;(?异步还没有执行,要是脚本中还有交互面初始化任务呢?!)

第七,Window对象load事件

此时,虽然文档已经完全分析完毕,但浏览可能还在等一些网络资源的下载,例如图片。当所有的资源都准备好了,并且异步脚本也执行完毕,「文档的ready状态」document.readyState属性为“complete”,浏览器在Window对象上触发load事件;

第八,交互页面建立

到此,所有交互页面初始化(包括额外内容的创建)完成,交互事件的处理函数安装完成,可以响应用户交互操作了。

JS程序的生命线的实现情况

以上JS程序的生命线过程是理论性的,实际中,不同的浏览器的执行细节都有不同。不过,load事件是所有浏览器都支持的。load事件是通用的用来判断HTML加载完成并可操作DOM的方法。

「DOM内容加载完成」事件——DOMContentLoaded在load事件之前,这个事件被大部分浏览器支持,除了IE。Document对象的「文档的ready状态」document.readyState属性被绝大多数浏览器支持(在本文写作的2011年),但它的具体属性值,则是每个浏览器都不同。

「延迟脚本」被大多数浏览器支持,「异步脚本」目前(2011年)还是很有限的支持,必须使用类似 loadasync() 的函数来模拟异步脚本加载和调用;

以上理论性的生命线并没有具体指出页面在哪个时间点上用户可看到页面,也没有指出浏览器具体的响应用户交互的时间点,这些都是具体的技术实现细节。对一个非常耗时的页面或很慢的网络,理论上,浏览器可以局部渲染页面,让用户可以开始交互,用户的交互有可能先于JS程序进入第二阶段的。

参考

裸男
Nakeman.cn 2023 Build by Gatsby and Tailwind, Deploy on Netlify.