浏览器工作原理与实践


浏览器工作原理与实践

记录学习曲线:

  1. 2021年11月26日15:20:10学习了15个章节(1-13,29-30),耗时两个小时,共计15个章节

  2. 2021年11月29日18:00:08,完成了第二次学习。(14-17,31),学习了一个小时,共6个章节

  3. 2021年11月30日11:21:11,完成了第三次学习。(18-28)学习了两个小时。共计10个章节

  4. 2021年11月30日14:24:47,完成了第四次学习。(32-36)学习了一个小时,共计5个章节

  5. 完成,共计6个小时


宏观视角下的浏览器

Chrome是以一个非常快速的速度在进化,越来越多的业务和应用都逐渐转至浏览器来开发,身为开发人员,我们不能坐视不管,而应该紧跟其步伐,收获这波技术红利

仅仅打开了1个页面,为什么有4个进程?因为打开1个页面至少需要1个网络进程、1个浏览器进程、1个GPU进程以及1个渲染进程,共4个;如果打开的页面有运行插件的话,还需要再加上1个插件进程。

互联网中的数据是通过数据包来传输的。如果发送的数据很大,那么该数据就会被拆分为很多小数据包来传输。比如你现在听的音频数据,是拆分成一个个小的数据包来传输的,并不是一个大的文件一次传输过来的。

本篇文章是有很多分析问题的思路在里面的。所以在学习过程中,你也要学会提问,通过最终要做什么和现在有什么,去一步步分析并提出一些问题,让疑问带领着你去学习,抓住几个本质的问题就可以学透相关知识点,让你能站在更高维度去查看整体框架。希望它能成为你的一个学习技巧吧!

“在浏览器里,从输入URL到页面展示,这中间发生了什么?  这个过程可以大致描述为如下。

  • 首先,浏览器进程接收到用户输入的URL请求,浏览器进程便将该URL转发给网络进程。

  • 然后,在网络进程中发起真正的URL请求。

  • 接着网络进程接收到了响应头数据,便解析响应头数据,并将数据转发给浏览器进程。

  • 浏览器进程接收到网络进程的响应头数据之后,发送“提交导航(CommitNavigation)”消息到渲染进程;

  • 渲染进程接收到“提交导航”的消息之后,便开始准备接收HTML数据,接收数据的方式是直接和网络进程建立数据管道;

  • 最后渲染进程会向浏览器进程“确认提交”,这是告诉浏览器进程:“已经准备好接受和解析页面数据了”。

  • 浏览器进程接收到渲染进程“提交文档”的消息之后,便开始移除之前旧的文档,然后更新浏览器进程中的页面状态。

这其中,用户发出URL请求到页面开始解析的这个过程,就叫做导航

从输入URL到页面展示,这中间发生了什么?”这个问题,你知道怎么回答了吗?

beforeunload事件允许页面在退出之前执行一些数据清理操作,还可以询问用户是否要离开当前页面,比如当前页面可能有未提交完成的表单等情况,因此用户可以通过beforeunload事件来取消导航,让浏览器不再执行任何后续工作。

渲染流程 通常,我们编写好HTML、CSS、JavaScript等文件,经过浏览器就会显示出漂亮的页面(如下图所示),但是你知道它们是如何转化成页面的吗?

按照渲染的时间顺序,流水线可分为如下几个子阶段:构建DOM树、样式计算、布局阶段、分层、绘制、分块、光栅化和合成。内容比较多,我会用两篇文章来为你详细讲解这各个子阶段。接下来,在介绍每个阶段的过程中,你应该重点关注以下三点内容:

  • 开始每个子阶段都有其输入的内容

  • 然后每个子阶段有其处理过程

  • 最终每个子阶段会生成输出内容

理解了这三部分内容,能让你更加清晰地理解每个子阶段。

在HTML页面内容被提交给渲染引擎之后,渲染引擎首先将HTML解析为浏览器可以理解的DOM;然后根据CSS样式表,计算出DOM树所有节点的样式;接着又计算每个元素的几何坐标位置,并将这些信息保存在布局树中。

因为页面中有很多复杂的效果,如一些复杂的3D变换、页面滚动,或者使用z-indexing做z轴排序等,为了更加方便地实现这些效果,渲染引擎还需要为特定的节点生成专用的图层,并生成一棵对应的图层树(LayerTree)

1637913614825

结合上图,一个完整的渲染流程大致可总结为如下:

  1. 渲染进程将HTML内容转换为能够读懂的DOM树结构。

  2. 渲染引擎将CSS样式表转化为浏览器可以理解的styleSheets,计算出DOM节点的样式。

  3. 创建布局树,并计算元素的布局信息。

  4. 对布局树进行分层,并生成分层树

  5. 为每个图层生成绘制列表,并将其提交到合成线程。

  6. 合成线程将图层分成图块,并在光栅化线程池中将图块转换成位图。

  7. 合成线程发送绘制图块命令DrawQuad给浏览器进程。

  8. 浏览器进程根据DrawQuad消息生成页面,并显示到显示器上。

通过这么多年的生活和工作经验来看,无论是做架构设计、产品设计,还是具体到代码的实现,甚至处理生活中的一些事情,能够把复杂问题简单化的人都是具有大智慧的。所以,在工作或生活中,你若想要简化遇到的问题,就要刻意地练习,练就抓住问题本质的能力,把那些复杂的问题简单化,从而最终真正解决问题。

浏览器中的JavaScript执行机制

JavaScript变量提升:因为在代码执行前会先被编译

所谓的变量提升,是指在JavaScript代码执行过程中,JavaScript引擎把变量的声明部分和函数的声明部分提升到代码开头的“行为”。变量被提升后,会给变量设置默认值,这个认值就是我们熟悉的undefined。

JavaScript引擎正是利用栈的这种结构来管理执行上下文的。在执行上下文创建好后,JavaScript引擎会将执行上下文压入栈中,通常把这种用来管理执行上下文的栈称为执行上下文栈,又称调用栈

HTTP网络协议

HTTP/2的解决方案可以总结为:一个域名只使用一个TCP长连接和消除队头阻塞问题

1638176349509

HTTP/2的一个核心特性是使用了多路复用技术,因此它可以通过一个TCP连接来发送多个URL请求。

多路复用技术能充分利用带宽,最大限度规避了TCP的慢启动所带来的问题,同时还实现了头部压缩、服务器推送等功能,使得页面资源的传输速度得到了大幅提升。在HTTP/1.1时代,为了提升并行下载效率,浏览器为每个域名维护了6个TCP连接;而采用HTTP/2之后,浏览器只需要为每个域名维护1个TCP持久连接,同时还解决了HTTP/1.1队头阻塞的问题。

我们首先分析了HTTP/2中所存在的一些问题,主要包括了TCP的队头阻塞、建立TCP连接的延时、TCP协议僵化等问题。

这些问题都是TCP的内部问题,因此要解决这些问题就要优化TCP或者“另起炉灶”创造新的协议。由于优化TCP协议存在着诸多挑战,所以官方选择了创建新的QUIC协议。

HTTP/3正是基于QUIC协议的,你可以把QUIC看成是集成了“TCP+HTTP/2的多路复用+TLS等功能”的一套协议。这是集众家所长的一个协议,从协议最底层对Web的文件传输做了比较彻底的优化,所以等生态相对成熟时,可以用来打造比现在的HTTP/2还更加高效的网络。

浏览器的页面循环系统

1638178035662

Chromium的官方源码

实践:浏览器页面是如何运行的

有了上面的基础知识之后,我们最后来看看浏览器的页面是如何运行的。

你可以打开开发者工具,点击“Performance”标签,选择左上角的“start porfiling and load page”来记录整个页面加载过程中的事件执行情况,如下图所示:

Performance页面

从图中可以看出,我们点击展开了Main这个项目,其记录了主线程执行过程中的所有任务。图中灰色的就是一个个任务,每个任务下面还有子任务,其中的Parse HTML任务,是把HTML解析为DOM的任务。值得注意的是,在执行Parse HTML的时候,如果遇到JavaScript脚本,那么会暂停当前的HTML解析而去执行JavaScript脚本。

至于Performance工具,在后面的章节中我们还会详细介绍,在这里你只需要建立一个直观的印象就可以了。

关于浏览器消息队列和事件循环总结

好了,今天就讲到这里,下面我来总结下今天所讲的内容。

  • 如果有一些确定好的任务,可以使用一个单线程来按照顺序处理这些任务,这是第一版线程模型。

  • 要在线程执行过程中接收并处理新的任务,就需要引入循环语句和事件系统,这是第二版线程模型。

  • 如果要接收其他线程发送过来的任务,就需要引入消息队列,这是第三版线程模型。

  • 如果其他进程想要发送任务给页面主线程,那么先通过IPC把任务发送给渲染进程的IO线程,IO线程再把任务发送给页面主线程。

  • 消息队列机制并不是太灵活,为了适应效率和实时性,引入了微任务。

基于消息队列的设计是目前使用最广的消息架构,无论是安卓还是Chrome都采用了类似的任务机制,所以理解了本篇文章的内容后,你再理解其他项目的任务机制也会比较轻松。

浏览器怎么实现setTimeout

要了解定时器的工作原理,就得先来回顾下之前讲的事件循环系统,我们知道渲染进程中所有运行在主线程上的任务都需要先添加到消息队列,然后事件循环系统再按照顺序执行消息队列中的任务。下面我们来看看那些典型的事件:

  • 当接收到HTML文档数据,渲染引擎就会将“解析DOM”事件添加到消息队列中,

  • 当用户改变了Web页面的窗口大小,渲染引擎就会将“重新布局”的事件添加到消息队列中。

  • 当触发了JavaScript引擎垃圾回收机制,渲染引擎会将“垃圾回收”任务添加到消息队列中。

  • 同样,如果要执行一段异步JavaScript代码,也是需要将执行任务添加到消息队列中。

以上列举的只是一小部分事件,这些事件被添加到消息队列之后,事件循环系统就会按照消息队列中的顺序来执行事件。

所以说要执行一段异步任务,需要先将任务添加到消息队列中。不过通过定时器设置回调函数有点特别,它们需要在指定的时间间隔内被调用,但消息队列中的任务是按照顺序执行的,所以为了保证回调函数能在指定时间内执行,你不能将定时器的回调函数直接添加到消息队列中。

在Chrome中除了正常使用的消息队列之外,还有另外一个消息队列,这个队列中维护了需要延迟执行的任务列表,包括了定时器和Chromium内部一些需要延迟执行的任务。所以当通过JavaScript创建一个定时器时,渲染进程会将该定时器的回调任务添加到延迟队列中。

Chromium中关于队列部分的源码。

使用setTimeout的一些注意事项

  1. 如果当前任务执行时间过久,会影响定时器任务的执行

  2. 如果setTimeout存在嵌套调用,那么系统会设置最短时间间隔为4毫秒

  3. 未激活的页面,setTimeout执行最小间隔是1000毫秒

  4. 延时执行时间有最大值

  5. 使用setTimeout设置的回调函数中的this不符合直觉

XMLHttpRequest运作机制

1638179247093

发起请求。

一切准备就绪之后,就可以调用xhr.send来发起网络请求了。你可以对照上面那张请求流程图,可以看到:渲染进程会将请求发送给网络进程,然后网络进程负责资源的下载,等网络进程接收到数据之后,就会利用IPC来通知渲染进程;渲染进程接收到消息之后,会将xhr的回调函数封装成任务并添加到消息队列中,等主线程循环系统执行到该任务的时候,就会根据相关的状态来调用对应的回调函数。

  • 如果网络请求出错了,就会执行xhr.onerror;

  • 如果超时了,就会执行xhr.ontimeout;

  • 如果是正常的数据接收,就会执行onreadystatechange来反馈相应的状态。

这就是一个完整的XMLHttpRequest请求流程,如果你感兴趣,可以参考下Chromium对XMLHttpRequest的实现,点击这里查看代码。

浏览器安全问题是前端工程师避不开的一道坎

XMLHttpRequest使用过程中的“坑”

  1. 跨域问题

  2. HTTPS混合内容的问题

微任务

微任务就是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。

微任务产生的时机和执行微任务队列的时机:

第一种方式是使用MutationObserver监控某个DOM节点,然后再通过JavaScript来修改这个节点,或者为这个节点添加、删除部分子节点,当DOM节点发生变化时,就会产生DOM变化记录的微任务。

第二种方式是使用Promise,当调用Promise.resolve()或者Promise.reject()的时候,也会产生微任务。

通常情况下,在当前宏任务中的JavaScript快执行完成时,也就在JavaScript引擎准备退出全局执行上下文并清空调用栈的时候,JavaScript引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行队列中的微任务。WHATWG把执行微任务的时间点称为检查点。当然除了在退出全局执行上下文式这个检查点之外,还有其他的检查点,不过不是太重要,这里就不做介绍了。

如果在执行微任务的过程中,产生了新的微任务,同样会将该微任务添加到微任务队列中,V8引擎一直循环执行微任务队列中的任务,直到队列为空才算执行结束。也就是说在执行微任务过程中产生的新的微任务并不会推迟到下个宏任务中执行,而是在当前的宏任务中继续执行。

  • 微任务和宏任务是绑定的,每个宏任务在执行时,会创建自己的微任务队列。

  • 微任务的执行时长会影响到当前宏任务的时长。比如一个宏任务在执行过程中,产生了100个微任务,执行每个微任务的时间是10毫秒,那么执行这100个微任务的时间就是1000毫秒,也可以说这100个微任务让宏任务的执行时间延长了1000毫秒。所以你在写代码的时候一定要注意控制微任务的执行时长。

  • 在一个宏任务中,分别创建一个用于回调的宏任务和微任务,无论什么情况下,微任务都早于宏任务执行。

监听DOM变化方法演变

虽然监听DOM的需求是如此重要,不过早期页面并没有提供对监听的支持,所以那时要观察DOM是否变化,唯一能做的就是轮询检测,比如使用setTimeout或者setInterval来定时检测DOM是否有改变。这种方式简单粗暴,但是会遇到两个问题:如果时间间隔设置过长,DOM 变化响应不够及时;反过来如果时间间隔设置过短,又会浪费很多无用的工作量去检查DOM,会让页面变得低效。

直到2000年的时候引入了Mutation Event,Mutation Event采用了观察者的设计模式,当DOM 有变动时就会立刻触发相应的事件,这种方式属于同步回调。

采用Mutation Event解决了实时性的问题,因为DOM一旦发生变化,就会立即调用JavaScript接口。但也正是这种实时性造成了严重的性能问题,因为每次DOM变动,渲染引擎都会去调用JavaScript,这样会产生较大的性能开销。比如利用JavaScript动态创建或动态修改50个节点内容,就会触发50次回调,而且每个回调函数都需要一定的执行时间,这里我们假设每次回调的执行时间是4毫秒,那么50次回调的执行时间就是200毫秒,若此时浏览器正在执行一个动画效果,由于Mutation Event触发回调事件,就会导致动画的卡顿。

也正是因为使用Mutation Event会导致页面性能问题,所以Mutation Event被反对使用,并逐步从Web标准事件中删除了。

为了解决了Mutation Event由于同步调用JavaScript而造成的性能问题,从DOM4开始,推荐使用 MutationObserver 来代替 Mutation Event。MutationObserver API 可以用来监视 DOM 的变化,包括属性的变化、节点的增减、内容的变化等。

那么相比较 Mutation Event,MutationObserver 到底做了哪些改进呢?

首先,MutationObserver将响应函数改成异步调用,可以不用在每次DOM变化都触发异步调用,而是等多次DOM变化后,一次触发异步调用,并且还会使用一个数据结构来记录这期间所有的DOM变化。这样即使频繁地操纵DOM,也不会对性能造成太大的影响。

我们通过异步调用和减少触发次数来缓解了性能问题,那么如何保持消息通知的及时性呢?如果采用setTimeout创建宏任务来触发回调的话,那么实时性就会大打折扣,因为上面我们分析过,在两个任务之间,可能会被渲染进程插入其他的事件,从而影响到响应的实时性。

这时候,微任务就可以上场了,在每次DOM节点发生变化的时候,渲染引擎将变化记录封装成微任务,并将微任务添加进当前的微任务队列中。这样当执行到检查点的时候,V8引擎就会按照顺序执行微任务了。

综上所述, MutationObserver采用了“异步+微任务”的策略。

  • 通过异步操作解决了同步操作的性能问题

  • 通过微任务解决了实时性的问题

Promise:使用Promise,告别回调函数

DOM/BOM API中新加入的API大多数都是建立在Promise上的,而且新的前端框架也使用了大量的Promise。 Promise已经成为现代前端的“水”和“电”,很是关键,所以深入学习Promise势在必行。

如果你想要学习一门新技术,最好的方式是先了解这门技术是如何诞生的,以及它所解决的问题是什么。了解了这些后,你才能抓住这门技术的本质。所以本文我们就来重点聊聊JavaScript引入Promise的动机,以及解决问题的几个核心关键点。

1638236822546

上图展示的是一个标准的异步编程模型,页面主线程发起了一个耗时的任务,并将任务交给另外一个进程去处理,这时页面主线程会继续执行消息队列中的任务。等该进程处理完这个任务后,会将该任务添加到渲染进程的消息队列中,并排队等待循环系统的处理。排队结束之后,循环系统会取出消息队列中的任务进行处理,并触发相关的回调操作。

这就是页面编程的一大特点:异步回调

封装异步代码,让处理流程变得线性
Promise:消灭嵌套调用和多次错误处理
  • Promise实现了回调函数的延时绑定

  • 需要将回调函数onResolve的返回值穿透到最外层

  • 错误“冒泡”技术

async/await:使用同步的方式去写异步代码

使用promise.then也是相当复杂,虽然整个请求流程已经线性化了,但是代码里面包含了大量的then函数,使得代码依然不是太容易阅读。基于这个原因,ES7 引入了async/await,这是JavaScript异步编程的一个重大改进,提供了在不阻塞主线程的情况下使用同步代码实现异步访问资源的能力,并且使得代码逻辑更加清晰。你可以参考下面这段代码:


async function foo(){

try{

let response1 = await fetch('https://www.geekbang.org')

console.log('response1')

console.log(response1)

let response2 = await fetch('https://www.geekbang.org/test')

console.log('response2')

console.log(response2)

}catch(err) {

console.error(err)

}

}

foo()

通过上面代码,你会发现整个异步处理的逻辑都是使用同步代码的方式来实现的,而且还支持try catch来捕获异常,这就是完全在写同步代码,所以是非常符合人的线性思维的。但是很多人都习惯了异步回调的编程思维,对于这种采用同步代码实现异步逻辑的方式,还需要一个转换的过程,因为这中间隐藏了一些容易让人迷惑的细节。

首先介绍生成器(Generator)是如何工作的,接着讲解Generator的底层实现机制——协程(Coroutine);又因为async/await使用了Generator和Promise两种技术,所以紧接着我们就通过Generator和Promise来分析async/await到底是如何以同步的方式来编写异步代码的。

生成器 VS 协程

生成器函数是一个带星号函数,而且是可以暂停执行和恢复执行的。

要搞懂函数为何能暂停和恢复,那你首先要了解协程的概念。协程是一种比线程更加轻量级的存在。 你可以把协程看成是跑在线程上的任务,一个线程上可以存在多个协程,但是在线程上同时只能执行一个协程,比如当前执行的是A协程,要启动B协程,那么A协程就需要将主线程的控制权交给B协程,这就体现在A协程暂停执行,B协程恢复执行;同样,也可以从B协程中启动A协程。通常,如果从A协程启动B协程,我们就把A协程称为B协程的父协程。

正如一个进程可以拥有多个线程一样,一个线程也可以拥有多个协程。 最重要的是,协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态执行) 。.

协程执行流程图

async/await

虽然生成器已经能很好地满足我们的需求了,但是程序员的追求是无止境的,这不又在ES7中引入了async/await,这种方式能够彻底告别执行器和生成器,实现更加直观简洁的代码。其实async/await技术背后的秘密就是Promise和生成器应用,往低层说就是微任务和协程应用。要搞清楚async和await的工作原理,我们就得对async和await分开分析。

Promise的编程模型依然充斥着大量的then方法,虽然解决了回调地狱的问题,但是在语义方面依然存在缺陷,代码中充斥着大量的then函数,这就是async/await出现的原因。

使用async/await可以实现用同步代码的风格来编写异步代码,这是因为async/await的基础技术使用了生成器和Promise,生成器是协程的实现,利用生成器能实现生成器函数的暂停和恢复。

另外,V8引擎还为async/await做了大量的语法层面包装,所以了解隐藏在背后的代码有助于加深你对async/await的理解。

async/await无疑是异步编程领域非常大的一个革新,也是未来的一个主流的编程风格。其实,除了JavaScript,Python、Dart、C#等语言也都引入了async/await,使用它不仅能让代码更加整洁美观,而且还能确保该函数始终都能返回Promise。

上面V8课程以及Promise,async和await等课程,懵懵懂懂的听不懂。

浏览器中的页面

Chrome开发者工具

Chrome开发者工具(简称DevTools)是一组网页制作和调试的工具,内嵌于Google Chrome 浏览器中

1638238331043

网络面板

网络面板由控制器、过滤器、抓图信息、时间线、详细列表和下载信息概要这6个区域构成

1638238370659

  1. 控制器

其中,控制器有4个比较重要的功能

  1. 过滤器

网络面板中的过滤器,主要就是起过滤功能。因为有时候一个页面有太多内容在详细列表区域中展示了,而你可能只想查看JavaScript文件或者CSS文件,这时候就可以通过过滤器模块来筛选你想要的文件类型。

  1. 抓图信息

抓图信息区域,可以用来分析用户等待页面加载时间内所看到的内容,分析用户实际的体验情况。比如,如果页面加载1秒多之后屏幕截图还是白屏状态,这时候就需要分析是网络还是代码的问题了。(勾选面板上的“Capture screenshots”即可启用屏幕截图。)

  1. 时间线

时间线,主要用来展示HTTP、HTTPS、WebSocket加载的状态和时间的一个关系,用于直观感受页面的加载过程。如果是多条竖线堆叠在一起,那说明这些资源被同时被加载。至于具体到每个文件的加载信息,还需要用到下面要讲的详细列表。

  1. 详细列表

这个区域是最重要的,它详细记录了每个资源从发起请求到完成请求这中间所有过程的状态,以及最终请求完成的数据信息。通过该列表,你就能很容易地去诊断一些网络问题。

详细列表是我们本篇文章介绍的重点,不过内容比较多,所以放到最后去专门介绍了。

  1. 下载信息概要

下载信息概要中,你要重点关注下DOMContentLoaded和Load两个事件,以及这两个事件的完成时间。

DOMContentLoaded,这个事件发生后,说明页面已经构建好DOM了,这意味着构建DOM所需要的HTML文件、JavaScript文件、CSS文件都已经下载完成了。

Load,说明浏览器已经加载了所有的资源(图像、样式表等)。

通过下载信息概要面板,你可以查看触发这两个事件所花费的时间。

单个资源的时间线

1638238740772

1638238785923

首先,页面中的资源是有优先级的,比如CSS、HTML、JavaScript等都是页面中的核心文件,所以优先级最高;而图片、视频、音频这类资源就不是核心资源,优先级就比较低。通常当后者遇到前者时,就需要“让路”,进入待排队状态。

优化时间线上耗时项

了解了时间线面板上的各项含义之后,我们就可以根据这个请求的时间线来实现相关的优化操作了。

1. 排队(Queuing)时间过久

排队时间过久,大概率是由浏览器为每个域名最多维护6个连接导致的。那么基于这个原因,你就可以让1个站点下面的资源放在多个域名下面,比如放到3个域名下面,这样就可以同时支持18个连接了,这种方案称为域名分片技术。除了域名分片技术外,我个人还建议你把站点升级到HTTP2,因为HTTP2已经没有每个域名最多维护6个TCP连接的限制了。

2. 第一字节时间(TTFB)时间过久

这可能的原因有如下:

  • 服务器生成页面数据的时间过久。对于动态网页来说,服务器收到用户打开一个页面的请求时,首先要从数据库中读取该页面需要的数据,然后把这些数据传入到模板中,模板渲染后,再返回给用户。服务器在处理这个数据的过程中,可能某个环节会出问题。

  • 网络的原因。比如使用了低带宽的服务器,或者本来用的是电信的服务器,可联通的网络用户要来访问你的服务器,这样也会拖慢网速。

  • 发送请求头时带上了多余的用户信息。比如一些不必要的Cookie信息,服务器接收到这些Cookie信息之后可能需要对每一项都做处理,这样就加大了服务器的处理时长。

对于这三种问题,你要有针对性地出一些解决方案。面对第一种服务器的问题,你可以想办法去提高服务器的处理速度,比如通过增加各种缓存的技术;针对第二种网络问题,你可以使用CDN来缓存一些静态文件;至于第三种,你在发送请求时就去尽可能地减少一些不必要的Cookie数据信息。

3. Content Download时间过久

如果单个请求的Content Download花费了大量时间,有可能是字节数太多的原因导致的。这时候你就需要减少文件大小,比如压缩、去掉源码中不必要的注释等方法。

Dom树

DOM树的解析流程介绍两块内容:第一个是在解析过程中遇到JavaScript脚本,DOM解析器是如何处理的?第二个是DOM解析器是如何处理跨站点资源的?

什么是DOM

从网络传给渲染引擎的HTML文件字节流是无法直接被渲染引擎理解的,所以要将其转化为渲染引擎能够理解的内部结构,这个结构就是DOM。DOM提供了对HTML文档结构化的表述。在渲染引擎中,DOM有三个层面的作用。

  • 从页面的视角来看,DOM是生成页面的基础数据结构。

  • 从JavaScript脚本视角来看,DOM提供给JavaScript脚本操作的接口,通过这套接口,JavaScript可以对DOM结构进行访问,从而改变文档的结构、样式和内容。

  • 从安全视角来看,DOM是一道安全防护线,一些不安全的内容在DOM解析阶段就被拒之门外了。

简言之,DOM是表述HTML的内部数据结构,它会将Web页面和JavaScript脚本连接起来,并过滤一些不安全的内容。

DOM树如何生成

在渲染引擎内部,有一个叫HTML解析器(HTMLParser)的模块,它的职责就是负责将HTML字节流转换为DOM结构。所以这里我们需要先要搞清楚HTML解析器是怎么工作的。

在开始介绍HTML解析器之前,我要先解释一个大家在留言区问到过好多次的问题:HTML解析器是等整个HTML文档加载完成之后开始解析的,还是随着HTML文档边加载边解析的?

在这里我统一解答下,HTML解析器并不是等整个文档加载完成之后再解析的,而是网络进程加载了多少数据,HTML解析器便解析多少数据。

那详细的流程是怎样的呢?网络进程接收到响应头之后,会根据响应头中的content-type字段来判断文件的类型,比如content-type的值是“text/html”,那么浏览器就会判断这是一个HTML类型的文件,然后为该请求选择或者创建一个渲染进程。渲染进程准备好之后,网络进程和渲染进程之间会建立一个共享数据的管道,网络进程接收到数据后就往这个管道里面放,而渲染进程则从管道的另外一端不断地读取数据,并同时将读取的数据“喂”给HTML解析器。你可以把这个管道想象成一个“水管”,网络进程接收到的字节流像水一样倒进这个“水管”,而“水管”的另外一端是渲染进程的HTML解析器,它会动态接收字节流,并将其解析为DOM。

1638239196312

1638239220101

由图可以看出,Tag Token又分StartTag 和 EndTag,比如就是StartTag ,就是EndTag,分别对于图中的蓝色和红色块,文本Token对应的绿色块。

至于后续的第二个和第三个阶段是同步进行的,需要将Token解析为DOM节点,并将DOM节点添加到DOM树中。

HTML解析器维护了一个Token栈结构,该Token栈主要用来计算节点之间的父子关系,在第一个阶段中生成的Token会被按照顺序压到这个栈中。具体的处理规则如下所示:

  • 如果压入到栈中的是StartTag Token,HTML解析器会为该Token创建一个DOM节点,然后将该节点加入到DOM树中,它的父节点就是栈中相邻的那个元素生成的节点。

  • 如果分词器解析出来是文本Token,那么会生成一个文本节点,然后将该节点加入到DOM树中,文本Token是不需要压入到栈中,它的父节点就是当前栈顶Token所对应的DOM节点。

  • 如果分词器解析出来的是EndTag标签,比如是EndTag div,HTML解析器会查看Token栈顶的元素是否是StarTag div,如果是,就将StartTag div从栈中弹出,表示该div元素解析完成。

通过分词器产生的新Token就这样不停地压栈和出栈,整个解析过程就这样一直持续下去,直到分词器将所有字节流分词完成。

通过上面的介绍,相信你已经清楚DOM是怎么生成的了。不过在实际生产环境中,HTML源文件中既包含CSS和JavaScript,又包含图片、音频、视频等文件,所以处理过程远比上面这个示范Demo复杂。不过理解了这个简单的Demo生成过程,我们就可以往下分析更加复杂的场景了。

JavaScript是如何影响DOM生成的