从输入 URL 到页面展示,中间发生了什么?

360 阅读11分钟

整体流程如上图所示,大概可描述为:

  1. 用户从浏览器里输入请求信息
  2. 网络进程发起 URL 请求
  3. 服务器响应 URL 请求之后,浏览器进程开始准备渲染进程
  4. 渲染进程准备完毕后,向渲染进程提交页面数据(提交文档阶段)
  5. 渲染进程接受完文档信息后,开始解析页面和加载子资源,完成页面渲染

从输入 URL 到页面展示详细过程

用户输入

用户在地址栏输入关键字后,地址栏会根据关键字来判断是搜索内容还是请求 URL

  • 若判断为搜索内容,地址栏使用默认搜索引擎,合成新的带搜索关键字的 URL(q 后为搜索关键字) https://www.google.com/search?q=google&xxxxxx
  • 若判断内容符合 URL 规则,则地址栏会根据输入内容,合称为完整的 URL www.baidu.com => https://www.baidu.com/

输入关键字后回车,标签上的图标变成 loading 状态,此时页面没有立即替换为目标页面;需要等待提交文档阶段结束,页面内容才会被替换。(见下图)

开始加载 URL 时浏览器状态

URL 请求过程

页面请求资源过程,浏览器进程通过 IPC(进程间通信) 把 URL 请求发送至网络进程,网络进程收到后发起真正的 URL 请求。具体过程如下:

  1. 查找本地有无缓存资源。有,返回给浏览器进程。
  2. 没有,直接发起网络请求。
  3. DNS 解析来获取请求域名的服务器 IP 地址。
  4. 若请求协议时 HTTPS,还需要建立 TLS(安全传输层协议) 连接。
  5. 用 IP 地址和服务器建立 TCP 连接。(同一域名同时最多只能建立 6 个连接,超过的排队等待)(通过传输层、网络层等加上相应的头)
  6. 建立连接后,构建请求头、行等信息,将相关 cookie 等数据附加到请求头中,然后发送给服务器
  7. 服务器接受到请求信息后,根据请求信息生成响应数据,发给网络进程。(响应数据又顺着应用层——传输层——网络层——网络层——传输层——应用层的顺序返回到网络进程)
  8. 网络进程接收了响应行、头后便开始解析响应内容。
  9. 若返回的状态时是 301 或 302,则浏览器需要重定向到其他 URL,一切从头开始。
  10. 浏览器根据响应头中的Content-Type字段判断响应体的数据类型;若是application/octet-stream字节流类型,浏览器会按下载类型来处理,该请求会被提交给浏览器的下载管理器,流程结束。若是 HTML,则进入下个流程:准备渲染进程

准备渲染进程 & 提交文档

不同主域名的页面使用不同的渲染进程,主域名相同的使用同一个渲染进程;

  1. 渲染进程准备完毕后,浏览器进程发出提交文档的消息。
  2. 渲染进程收到后,会和网络进程建立传输数据的管道。
  3. 传输完毕后,渲染进程会返回“确认提交”的消息给浏览器进程。
  4. 浏览器进程收到确认的消息后,会更新浏览器界面状态(安全状态、URL、前进后退的历史状态)并更新 web 页面。(此时 web 页面是空白页面)
  5. 导航流程结束,进入渲染阶段。
    浏览器导航完成状态

渲染阶段

按照渲染的时间顺序,流水线可分为: 构建 DOM 树 -> 样式计算 -> 布局 -> 分层 -> 绘制 -> 分块 -> 光栅化 -> 合成等阶段。

每个阶段的流程如下:接收输入的内容 -> 处理 -> 输出内容

构建 DOM 树

接收 html 文件 -> html 解析器解析 -> 输出树状解构的 DOM

样式计算

大体流程:接受 css 文本 -> 计算出 dom 节点的具体样式 -> 输出计算完成后的 DOM 样式

具体步骤如下:

  • 接收 css 文本,将其转为浏览器可理解的结构:stylesheets css 三种来源:内联、外部引入、style

  • 转换 stylesheets 中属性值,使其标准化

body { font-size: 2em } 
p {color:blue;} 
div { font-weight: bold}

// 标准化后
body { font-size: 32px } 
p {color: rgb(0,0,255);} 
div { font-weight: 700}
  • 计算出 DOM 树中每个节点的具体样式 计算遵守 css 继承、层叠两个规则,如下图:
    计算后的 DOM 样式

所有子节点都继承父节点的样式。计算完后输出每个 DOM 节点的样式(保存在 ComputedStyle 解构内,可在 elements -> computed 标签下查看)

布局

经过上面过程,有了 DOM 树和树中元素的样式,但每个元素的位置还不确定,不足以显示页面。

Chrome 在布局阶段的任务:创建布局树与布局计算。

  • 创建布局树 遍历 DOM 中的可见节点,将其添加到布局树中。不可见的节点会被忽略,比如:head 标签下的内容,display 为 none

    布局树构造过程

  • 布局计算

计算布局树节点的坐标位置,计算完执行布局时会把运算的结果会重新写入布局树中,所以布局树既是输入内容也是输出内容;此处设计不合理,chrome 正在重构,下一代布局系统 LayoutNG 试图分离输入与输出。

分层

布局完之后不会立即绘制,渲染引擎会为特定的节点生成专用的图层,并生成一棵对应的图层树(LayerTree)。在开发者工具下的 Layers 标签可以看见页面的分层情况。

页面分层
最终的显示的页面就是由这些图层叠加在一起。

  • 布局树和图层树的关系

    布局树和图层树的关系
    由上图可得,不是每个节点都有图层,若没有,就属于它父节点的图层。

  • 什么情况下,渲染引擎会为特定节点创建新图层?

  1. 拥有层叠上下文的属性会被提升为单独的一层。 层叠上下文
    层叠上下文让 HTML 具有三维概念,按照自身属性优先级垂直分布在 z 轴上。
    层叠上下文
  2. 需要剪裁(clip)的地方也会被创建为图层。 文字内容比较多,超出显示区域,这时就产生裁剪。渲染引擎会为文字部分单独创建一个层,若出现滚动条,滚动条也会被提升为单独的层。
    被裁剪的内容、滚动条单独一层

图层绘制

画图例子: 给你一张纸,画一个背景蓝色,中间为红色的圆,再在圆上画一个绿色三角形,如何画?

  1. 绘制蓝色背景
  2. 在中间绘制红色圆
  3. 在圆上绘制绿色三角形

图层绘制与之类似,将一个图层拆分成很多小的绘图指令,把指令按顺序组成列表去绘制。(每个元素背景、前景、边框都需要单独的绘图指令) 开发者工具 Layers 标签,选中 document 层,可重现绘制过程,如下。

图层绘制列表

栅格化(raster)操作

GPU 栅格化

绘制列表只用来记录绘制顺序与指令,真正的绘制操作由渲染引擎中的合成线程来完成。如上图,绘制列表准备好后,主线程会把它提交给合成线程。

有的图层很大,页面要使用滚动条滚好久才能到底部;通过视口,用户只能看到一小部分,这种情况下如果绘制所有图层内容,会产生很大开销且没必要。

合成线程如何处理?
合成线程会将图层划分成图块(tile),大小通常为(256256,512512)。

图层划分图块示意图
合成线程会按照视口附近的图块来优先生成位图,实际生成位图的操作由栅格化(将图块转为位图)执行。图块是栅格化执行的最小单位。渲染进程维护了一个栅格化的线程池,所有图块栅格化都在线程池内执行。

栅格化过程会使用 GPU 来加速生成,生成的位图被保留在 GPU 内存中。(GPU 操作在 GPU 进程中,栅格化在渲染进程中,这个过程涉及跨进程操作。)

合成与显示

当所有图块都被栅格化,合成进程会生成一个绘制图块的命令DrawQuad,然后将该命令提交给浏览器进程。

浏览器进程里有个 viz 组件,接收到 DrawQuad 命令后,根据其内容,将页面绘制到内存,最后再将内存显示到屏幕上。

完整渲染图

完整渲染流程总结

  1. 渲染进程将 HTML 内容转换为能够读懂的DOM 树结构。
  2. 渲染引擎将 CSS 样式表转化为浏览器可以理解的styleSheets,计算出 DOM 节点的样式。
  3. 创建布局树,并计算元素的布局信息。
  4. 对布局树进行分层,并生成分层树。
  5. 为每个图层生成绘制列表,提交给合成线程。
  6. 合成线程将图层分成图块,并在栅格化线程池中将图块转化为位图。
  7. 合成线程发送绘制图块命令 DrawQuad 给浏览器进程。
  8. 浏览器进程根据 DrawQuad 消息生成页面,并显示到显示器上。

重排、重绘、合成

  • 重排
    更新元素几何属性,开销最大
    更新元素几何属性
  • 重绘
    更新元素颜色
    更新元素背景
  • 合成
    直接合成,避开重排重绘。
    transform 动画

Some question

  • 同一站点共用一个渲染进程,那假设有2个标签页是同一站点,我在A标签页面写个死循环,导致页面卡死,B页面是否也是卡死了呢?

    多个页面公用一个渲染进程,也就意味着多个页面公用同一个主线程,所有页面的任务都是在同一个主线程上执行,这些任务包括渲染流程,JavaScript执行,用户交互的事件的响应等等,但是如果一个标签页里面执行一个死循环,那么意味着该JavaScript代码会一直霸占主线程,这样就导致了其它的页面无法使用该主线程,从而让所有页面都失去响应!

  • 若下载 CSS 文件阻塞了,会阻塞 DOM 树的合成吗?会阻塞页面的显示吗?
    不会阻塞 DOM 树合成,html 转为 DOM 树的过程中,发现文件请求会交给网络进程去请求文件,渲染进程继续解析 html。 会阻塞页面显示,计算样式时需要 css 文件资源,若资源阻塞,会等待至网络超时,network 报出响应错误,渲染进程继续层叠样式计算。

  • JS 和 CSS 都可能阻塞 DOM 解析

  1. 当从服务器接收HTML页面的第一批数据时,DOM解析器就开始工作了,在解析过程中,如果遇到了JS脚本,如下所示:

    <html>
        <body>
            掘金
            <script>
            document.write("--foo")
            </script>
        </body>
    </html>
    

    那么DOM解析器会先执行JavaScript脚本,执行完成之后,再继续往下解析。

  2. 那么第二种情况复杂点了,我们内联的脚本替换成js外部文件,如下所示:

    <html>
        <body>
            掘金
            <script type="text/javascript" src="foo.js"></script>
        </body>
    </html>
    

    这种情况下,当解析到JavaScript的时候,会先暂停DOM解析,并下载foo.js文件,下载完成之后执行该段JS文件,然后再继续往下解析DOM。这就是JavaScript文件为什么会阻塞DOM渲染。

  3. 我们再看第三种情况,还是看下面代码:

    <html>
        <head>
            <style type="text/css" src = "theme.css" />
        </head>
        <body>
            <p>掘金</p>
            <script>
                let e = document.getElementsByTagName('p')[0]
                e.style.color = 'blue'
            </script>
        </body>
    </html>
    

    当我在JavaScript中访问了某个元素的样式,那么这时候就需要等待这个样式被下载完成才能继续往下执行,所以在这种情况下,CSS也会阻塞DOM的解析。

  • 渲染进程的帧是什么?
    可以拿放电影电影来解释,通常,电影的帧速是24,也就是说每秒切换24幅画面,其中的每幅画面就是一帧。

    理解什么是帧后,我们在回过头看看我们的页面。由于目前大多数设备的屏幕刷新率为 60 次/秒。因此,如果页面中有一个动画、或一个渐变效果、或者用户正在滚动页面,那么浏览器渲染动画的频率至少要和刷新频率保持一致,也就是每秒需要更新60次,这样我们就能计算出来生成每帧的预算只有(1/60)毫秒,也就是16毫秒多一点(1 秒/ 60 = 16.66 毫秒)。如果超过16毫秒,帧率将下降,并且会出现画面抖动现象,此现象通常被称为卡顿,会对用户体验产生负面影响。

    所以,如果想要保证画面的流畅,就需要尽量降低每帧的渲染时间,所以局部更新流水线显得非常重要了,能大大减少处理每帧所消耗的时间。

参考资料

浏览器工作原理与实践