要把URL变网页,一共分几步?

1,578 阅读18分钟

前言

生活中少不了和浏览器打交道,它就像一个魔法盒子,输入一串网址,就可以得到各式各样的网页,供我们满足多数日常需求,如:看看直播、买买东西、翻翻掘金、学学前端等等


那么,浏览器是如何工作的?


主题来了,要把URL变成网页,一共分几步?

普通人眼中:

  1. 打开浏览器
  2. URL 输入到浏览器的地址栏
  3. 敲下回车,等着


我们前端搬砖工程师眼中:

  1. 浏览器使用 HTTP  协议或者 HTTPS  协议,向服务端请求页面
  2. 解析请求回来的 HTML  代码,构建 DOM 树 
  3. 计算 DOM 树 上的 CSS  属性
  4. 最后根据 CSS  属性对元素逐个进行渲染,得到内存中的位图
  5. 一个可选的步骤是对位图进行合成,这会极大地增加后续绘制的速度
  6. 合成之后,再绘制到界面上


先看一张图,然后进入正题,搬砖的就是事儿多...

需要注意的是,从 HTTP  请求回来开始,这个过程并非一步做完再做下一步,而是一条流水线,产生了流式的数据,后续的 DOM 树 构建、 CSS  计算、渲染、合成、绘制,都是尽可能地流式处理前一步的产出:即不需要等到上一步骤完全结束,就开始处理上一步的输出,这样我们在浏览网页时,才会看到逐步出现的页面


HTTP协议

HTTP 结构

浏览器首先要做的事就是根据 URL  把数据取回来,取回数据使用的是 HTTP  协议,它的主要结构如下图所示

  • HTTP 是纯粹的文本协议,它是规定了使用 TCP 协议来传输文本格式的一个应用层协议, TCP  协议是一条双向的通讯通道, HTTP  在其基础上规定了 Request-Response  的模式,这个模式决定了通讯必定是由浏览器端首先发起的
  • 大部分情况下,浏览器的实现者只需要用一个 TCP  库,甚至一个现成的 HTTP  库就可以搞定浏览器的网络通讯部分

HTTP 请求过程

使用 TCP 连接工具 telnet 简单了解一下 HTTP 请求过程

输入命令连接主机
telnet juejin.im 80

成功建立TCP连接:
Trying 122.14.230.185...
Connected to bgp.xigua-lb-lf.l.bytedns.net.
Escape character is '^]'.

输入以下请求命令,并敲两次回车:
GET / HTTP/1.1
Host: juejin.im 

收到服务端响应:
HTTP/1.1 301 Moved Permanently
Server: nginx
Date: Fri, 18 Sep 2020 05:06:21 GMT
Content-Type: text/html
Content-Length: 178
Connection: keep-alive
Location: https://juejin.cn
x-tt-trace-host: 015296b7920ed693b32e320199940562beabfbd0ff41bbcb80c8d2764bbe1d487104068a81badb0e3febb69b1ec55356f4
x-tt-trace-tag: id=00;cdn-cache=miss

<html>
<head><title>301 Moved Permanently</title></head>
<body bgcolor="white">
<center><h1>301 Moved Permanently</h1></center>
<hr><center>nginx</center>
</body>
</html>
  • 请求部分
    • 第一行对应上图中 Request 分支下的 request line 
      • method 请求方法: GET 
      • path 请求路径: / 
      • version 请求协议版本: HTTP/1.1 
    • 第二行到第一个空行之间的内容对应 Request 分支下的 head 
      • 由名称及值组成: Host: juejin.im 
    • 空行下面内容对应 request 分支下的 body
      • 包含请求参数、文件数据等
      • 上面请求命令没有使用参数,所以第二行下无内容
  • 响应部分
    • 第一行对应上图中 Response 分支下的 response line
      • version 请求协议版本: HTTP/1.1
      • status code 状态码: 301 
      • status text 状态文本: Moved Penmanently 
    • 第二行到第一个空行之间的内容对应 response 分支下的 head 
      • 由名称及值组成的若干行组合
    • 空行下面内容对应 response 分支下的 body
      • 纯文本 HTML 代码

HTTP Method

简单介绍一下请求方法的种类以及他们的使用场景:

  • GET
    • 通过浏览器地址栏访问页面
  • POST
    • 通过表单提交数据
  • HEAD
    • GET 类似,只返回响应头,不常用
  • PUT
    • 语义上为添加资源,无强制约束,不常用
  • DELETE
    • 语义上为删除资源,无强制约束,不常用
  • CONNECT
    • 用于 HTTPS 和 WebSocket
  • OPTIONS
    • 多用于调试,不常用,多数线上服务器都不支持
  • TRACE
    • 多用于调试,不常用,多数线上服务器都不支持

HTTP Status

我们比较常用的几种状态码,以及对应的状态文本大概有这么几种:

  • 1xx:临时回应,表示客户端请继续
  • 2xx:请求成功
    • 200:请求成功
  • 3xx: 表示请求的目标有变化,希望客户端进一步处理
    • 301:永久性跳转
    • 302:临时性跳转
    • 304:跟客户端缓存没有更新
  • 4xx:客户端请求错误
    • 400:参数错误
    • 403:无权限
    • 404:表示请求的页面不存在
    • 418:It’s a teapot. 这是一个彩蛋,来自 ietf 的一个愚人节玩笑
  • 5xx:服务端请求错误
    • 500:服务端错误
    • 502:服务端收到无效响应
    • 503:服务端暂时性错误,可以一会再试
    • 504:服务端未能及时收到响应

HTTP Head

  • Request Header

  • Response Header


HTTP Body

HTTP  请求的 body  主要用于提交表单场景,也就是 POST 请求,一些常见的格式:

  • application/json
  • application/x-www-form-urlencoded
  • multipart/form-data
  • text/xml

使用 HTML 的 form 标签提交请求,默认会产生 application/x-www-form-urlencoded  的数据格式; 有文件上传时,使用 multipart/form-data 


HTTPS

HTTPS  有两个作用:

  1. 确定请求的目标服务端身份,
  2. 保证传输的数据不会被网络中间节点窃听或者篡改

HTTPS  与服务端建立一条 TLS 加密通道( TLS 构建于 TCP 协议之上),对传输的内容做一次加密,从传输内容上与 HTTP 没有区别


HTTP2

HTTP 2  是 HTTP 1.1  的升级版本,2.0 最大的改进有两点:

  1. 支持服务端推送
    • 服务端推送能够在客户端发送第一个请求到服务端时,提前把一部分内容推送给客户端,放入缓存当中,这可以避免客户端请求顺序带来的并行度不高,从而导致的性能问题
  2. 支持 TCP 连接复用
    • 使用同一个 TCP 连接来传输多个 HTTP 请求,避免了连接建立时的三次握手开销,和初建连接时传输窗口小的问题

解析代码


词(token)

先来分析一段代码

<p class="a">text text text</p>

可以把这段代码依次拆成词(token):

  • <p“标签开始”的开始
  • class=“a” 属性
  • >  “标签开始”的结束
  • text text text 文本
  • 标签结束

状态机

绝大多数语言的词法部分都是用状态机实现的,那么我们来把部分词(token)的解析画成一个状态机看看

这里的粗略分析主要为了理解原理,真正完整的 HTML 词法状态机 ,比我们描述的要复杂的多;更详细的内容可以参考 HTML 官方文档,文档中规定了 80 个状态( HTML  是唯一一个标准中规定了状态机实现的语言,对大部分语言来说,状态机是一种实现而非定义)

此状态机的初始状态,仅区分 < 和 非 < :

  • 如果获得一个非 < ,则进入了一个文本节点
  • 如果获得一个 < ,则进入一个标签状态

标签中存在多种可能性

  • 下一个字符是 !  ,可能是进入了 注释节点 或者 CDATA 
  • 下一个字符是 / ,可以确定进入了一个结束标签
  • 下一个字符是字母,可以确定进入了一个开始标签
  • 要完整处理各种 HTML 标准中定义的东西,那么还要考虑 ? % 等内容

用状态机做词法分析,其实正是把每个词的特征字符逐个拆开成独立状态,然后再把所有词的特征字符链合并起来,形成一个联通图结构

接下来就是代码实现的事情了,我们把每个函数当做一个状态,参数是接受的字符,返回值是下一个状态函数

function data(c) {
  switch (c) {
    case '&':
      return characterReferenceInData;

    case '<':
      return tagOpen;

    case '\0':
      error();
      emitToken(c);
      return data;

    case EOF:
      emitToken(EOF);
      return data;

    default:
      emitToken(c);
      return data;
  }
}

function tagOpen(c) {
  if (c === '/') {
    return endTagOpen;
  }
  if (/[a-zA-Z]/.test(c)) {
    token = new StartTagToken();
    token.name = c.toLowerCase();
    return tagName;
  } else {
    error(c);
    return data;
  }
}

function tagName(c) {
  //……
}

function characterReferenceInData(c) {
  //……
}

function endTagOpen(c) {
  //……
}

//……

这段代码给出了状态机的两个状态示例:

  • data  为初始状态,
  • tagOpenState  是接受了一个 <  字符,来判断标签类型的状态

这里的状态机,每一个状态是一个函数,通过 if else 来区分下一个字符做状态迁移,这里所谓的状态迁移,就是当前状态函数返回下一个状态函数

状态迁移:

let state = data;
let char
while(char = getInput())
    state = state(char);

这段代码的关键一句是 state = state(char) ,不论我们用何种方式来读取字符串流,我们都可以通过 state  来处理输入的字符流,这里用循环是一个示例,真实场景中,可能是来自 TCP 的输出流

状态函数通过代码中的 emitToken  函数来输出解析好的 token(词) ,我们只需要覆盖 emitToken ,即可指定对解析结果的处理方式:

function HTMLLexicalParser(){
		let state = data;
  
    //状态函数们……
    function data() {
        // ……
    }

    function tagOpen() {
        // ……
    }
    // ……
    
    this.receiveInput = function(char) {
        state = state(char);
    }
}

构建DOM

把词变成 DOM  树

function HTMLSyntaticalParser(){
    var stack = [new HTMLDocument];
    this.receiveInput = function(token) {
        //……
    };
    this.getOutput = () => stack[0];
}
  • receiveInput  负责接收词法部分产生的 词(token) ,由 emitToken 来调用
  • 接收同时,开始构建 DOM 树 ,在 receiveInput 中进行处理
  • 接收完所有输入,stack[0] 就是最后的根节点
  • 简单实现 Node  分为 Element  和 Text 
class Node {}

class Element extends Node {
  constructor (token) {
    super(token)
    for (const key in token) {
      this[key] = token[key]
    }
    this.childNodes = []
  }
  [Symbol.toStringTag] () {
    return `Element<${this.name}>`
  }
}

class Text extends Node {
  constructor (value) {
    super(value)
    this.value = value || ''
  }
}

前面我们的 词(token) 中,以下两个是需要成对匹配的:

  • tag start 
  • tag end 
<html maaa=a >
    <head>
        <title>cool</title>
    </head>
    <body>
        <img src="a" />
    </body>
</html>

构建 DOM 树 规则:

  • 当前节点是栈顶元素
  • 遇到属性,添加到当前节点
  • 遇到文本节点,如果当前节点是文本节点则合并,否则入栈成为当前节点的子节点
  • 遇到注释节点,作为当前节点的子节点
  • 遇到 tag start  就入栈一个节点,当前节点就是这个节点的父节点
  • 遇到 tag end  就出栈一个节点(还可以检查是否匹配)

结果展示

完整代码传送门

输出 词(token) 部分:

StartTagToken { name: 'html', maaa: 'a' }
String(\n)
String(<whitespace>)
String(<whitespace>)
String(<whitespace>)
String(<whitespace>)
StartTagToken { name: 'head' }
String(\n)
String(<whitespace>)
String(<whitespace>)
String(<whitespace>)
String(<whitespace>)
String(<whitespace>)
String(<whitespace>)
String(<whitespace>)
String(<whitespace>)
StartTagToken { name: 'title' }
String(c)
String(o)
String(o)
String(l)
EndTagToken { name: 'title' }
String(\n)
String(<whitespace>)
String(<whitespace>)
String(<whitespace>)
String(<whitespace>)
EndTagToken { name: 'head' }
String(\n)
String(<whitespace>)
String(<whitespace>)
String(<whitespace>)
String(<whitespace>)
StartTagToken { name: 'body' }
String(\n)
String(<whitespace>)
String(<whitespace>)
String(<whitespace>)
String(<whitespace>)
String(<whitespace>)
String(<whitespace>)
String(<whitespace>)
String(<whitespace>)
StartTagToken { name: 'img', src: 'a' }
EndTagToken { name: 'img' }
String(\n)
String(<whitespace>)
String(<whitespace>)
String(<whitespace>)
String(<whitespace>)
EndTagToken { name: 'body' }
String(\n)
EndTagToken { name: 'html' }

输出 DOM 树 结构:

{
  "isDocument": true,
  "childNodes": [
    {
      "name": "html",
      "maaa": "a",
      "childNodes": [
        {
          "value": "\n    "
        },
        {
          "name": "head",
          "childNodes": [
            {
              "value": "\n        "
            },
            {
              "name": "title",
              "childNodes": [
                {
                  "value": "cool"
                }
              ]
            },
            {
              "value": "\n    "
            }
          ]
        },
        {
          "value": "\n    "
        },
        {
          "name": "body",
          "childNodes": [
            {
              "value": "\n        "
            },
            {
              "name": "img",
              "src": "a",
              "childNodes": []
            },
            {
              "value": "\n    "
            }
          ]
        },
        {
          "value": "\n"
        }
      ]
    }
  ]
}

 

CSS计算

构建 DOM  的过程是:从父到子,从先到后,一个一个节点构造,并且挂载到 DOM  树上的,在这个过程中依次拿到上一步构造好的元素,去检查它匹配到了哪些规则,再根据规则的优先级,做覆盖和调整, CSS 选择器可以看成是匹配器:

  • 空格 : 后代,选中它的子节点和所有子节点的后代节点
  • > : 子代,选中它的子节点
  • + :直接后继选择器,选中它的下一个相邻节点
  • ~ :后继,选中它之后所有的相邻节点
  • || :列,选中表格中的一列

选择器的出现顺序,必定跟构建 DOM  树的顺序一致,这是一个 CSS  设计的原则,即保证选择器在 DOM  树构建到当前节点时,已经可以准确判断是否匹配,不需要后续节点信息


排版

正常流文字排版

正常流是唯一一个文字和盒混排的排版方式,要想理解正常流,我们首先要回忆一下自己如何在纸上写文章:

  • 首先,纸有固定宽度和固定高度,但是可以通过下一页纸的方式来接续,因此不存在写不下的场景
  • 书写文字时,从左到右依次书写,每一个字跟上一个字都不重叠,文字之间有一定间距,当写满一行时,换到下一行
  • 书写中文时,文字的上、下、中轴线都对齐
  • 书写英文时,不同字母的高度不同,但是有一条基线对齐

实际上浏览器环境也很类似,但是因为浏览器支持改变排版方向,不一定是从左到右从上到下,所以我们把文字依次书写的延伸方向称为主轴或者主方向,换行延伸的方向,跟主轴垂直交叉,称为交叉轴或者交叉方向

我们一般会从某个字体文件中获取某个特定文字的相关信息,我们获取到的信息大概类似下面:

横向排版.png

纵向版本:

纵向排版.png

advance  代表每一个文字排布后在主轴上的前进距离,它跟文字的宽 / 高不相等,是字体中最重要的属性

除了字体提供的字形本身包含的信息,文字排版还受到一些 CSS  属性影响,如 line-height 、 letter-spacing 、 word-spacing 

在正常流的文字排版中,多数元素被当作长方形盒来排版

而只有 display  为 inline  的元素,是被拆成文本来排版的,此类元素中的文字排版时会被直接排入文字流中, inline  元素主轴方向的 margin  属性和 border  属性也会被计算进排版前进距离当中(例如主轴为横向时的 margin-leftmargin-right

注意,当没有强制指定文字书写方向时,在左到右文字中插入右到左向文字,会形成一个双向文字盒,反之亦然;这样,即使没有元素包裹,混合书写方向的文字也可以形成一个盒结构,我们在排版时,遇到这样的双向文字盒,会先排完盒内再排盒外


正常流中的盒

在正常流中, display  不为 inline  的元素或者伪元素,会以盒的形式跟文字一起排版。

多数 display  属性都可以分成两部分:

  • 内部的排版
  • 是否 inline 
    • 带有 inline-  前缀的盒,被称作行内级盒

根据盒模型,一个盒具有 margin 、 border 、 padding 、 width/height  等属性,它在主轴方向占据的空间是由对应方向的这几个属性之和决定的,而 vertical-align  属性决定了盒在交叉轴方向的位置,同时也会影响实际行高

所以,浏览器对行的排版,一般是先行内布局,再确定行的位置,根据行的位置计算出行内盒和文字的排版位置

块级盒比较简单,它总是单独占据一整行,计算出交叉轴方向的高度即可


绝对定位元素

position  属性为 absolute  的元素,我们需要根据它的包含块来确定位置,这是完全跟正常流无关的一种独立排版模式,逐层找到其父级的 position  非 static  元素即可


浮动元素排版

浏览器对 float  的处理是先排入正常流,再移动到排版宽度的最左 / 最右(这里实际上是主轴的最前和最后)

移动之后, float 元素占据了一块排版的空间,因此,在数行之内,主轴方向的排版距离发生了变化,直到交叉轴方向的尺寸超过了浮动元素的交叉轴尺寸范围,主轴排版尺寸才会恢复

float  元素排布完成后,此元素所在的行需要重新确定位置


其他排版

Flex  排版,支持了 flex  属性,此属性将每一行排版后的剩余空间平均分配给主轴方向的 width/height  属性


渲染

浏览器中渲染这个过程,就是把每一个元素对应的盒变成位图

  • 元素包括 HTML  元素和伪元素,一个元素可能对应多个(比如 inline 元素,可能会分成多行)
  • 每一个对应着一张位图
  • 位图就是在内存里建立一张二维表格,把一张图片的每个像素对应的颜色保存进去

位图信息也是 DOM  树中占据浏览器内存最多的信息,我们在做内存占用优化时,主要就是考虑这一部分


渲染过程可以分成两个大类:

  • 图形
    • 盒的 背景 、 边框 、 SVG元素 、 阴影 等特性,都是需要绘制的图形类,需要一个底层库来支持
    • 一般的操作系统会提供一个底层库,比如在 Android  的 Skia , Windows  的 GDI ,一般的浏览器会做一个兼容层来处理掉平台差异
  • 文字
    • 盒中的文字,也需要用底层库来支持,叫做字体库
    • 字体库提供读取字体文件的基本能力,它能根据字符的码点抽取出字形,字形分为像素字形和矢量字形两种
    • 通常的字体,会在 6px  8px  等小尺寸提供像素字形,比较大的尺寸则提供矢量字形
    • 矢量字形本身就需要经过渲染才能继续渲染到元素的位图上去
    • 目前最常用的字体库是 Freetype ,这是一个 C++  编写的开源的字体库

渲染过程中,不会把子元素绘制到渲染的位图上,因此当父子元素的相对位置发生变化时,可以保证渲染的结果能够最大程度被缓存,减少重新渲染


合成

渲染过程不会把子元素渲染到位图上面,而合成的过程,就是为一些元素创建一个“合成后的位图”(合成层),把一部分子元素渲染到合成的位图上面

如果把所有元素都进行合成,比如为根元素 HTML 创建一个合成后的位图,把所有子元素都进行合成,一旦改变了任何一个 CSS  属性,这份合成后的位图就失效了,需要重新绘制所有的元素

如果所有的元素都不合成,相当于每次都必须要重新绘制所有的元素

合成是一个性能考量,那么合成的目标就是提高性能,根据这个目标,我们建立的原则就是最大限度减少绘制次数原则

<div id="a">
    <div id="b">...</div>
    <div id="c" style="transform:translate(0,0)"></div>
</div>

假设合成策略能够把 a 、 b  两个 div  合成,而不把 c  合成,当执行以下代码时,就可以只绘制 a  和 b  合成好的位图和 c ,从而减少了绘制次数

document.getElementById("c").style.transform = "translate(100px, 0)";

在实际场景中,我们的 b 可能有很多复杂的子元素,所以当合成命中时,性能提升收益非常之高

目前,主流浏览器一般根据 position 、 transform  等属性来决定合成策略,来“猜测”这些元素未来可能发生变化,这样的猜测准确性有限,所以新的 CSS 标准中,规定了 will-change  属性,可以由业务代码来提示浏览器的合成策略,灵活运用这样的特性,大大提升合成策略的效果


绘制

绘制是把 **位图最终绘制到屏幕上,变成肉眼可见的图像 **的过程

一般来说,浏览器并不需要用代码来处理这个过程,浏览器只需要把最终要显示的位图交给操作系统即可

一般最终位图位于显存中,也有一些情况下,浏览器只需要把内存中的一张位图提交给操作系统或者驱动就可以了,这取决于浏览器运行的环境

我们把任何位图合成到这个“最终位图”的操作称为绘制

这个过程听上去非常简单,因为在渲染部分已经得到了每个元素的位图,并且对它们部分进行了合成,那么绘制过程,实际上就是按照 z-index 把它们依次绘制到屏幕上,然而如果在实际中这样做,会带来极其糟糕的性能
**
实际上,“绘制”发生的频率比我们想象中要高得多。

我们考虑一个情况:鼠标划过浏览器显示区域,这个过程中,鼠标的每次移动,都造成了重新绘制,如果我们不重新绘制,就会产生大量的鼠标残影,这个时候,限制绘制的面积就很重要了。如果鼠标某次位置恰巧遮盖了某个较小的元素,我们完全可以重新绘制这个元素来完成我们的目标,当然,简单想想就知道,这种事情不可能总是发生的

计算机图形学中,我们使用的方案就是“脏矩形”算法,也就是把屏幕均匀地分成若干矩形区域

  • 当鼠标移动、元素移动或者其它导致需要重绘的场景发生时,我们只重新绘制它所影响到的几个矩形区域就够了
  • 比矩形区域更小的影响最多只会涉及 4 个矩形,大型元素则覆盖多个矩形
  • 设置合适的矩形区域大小,可以很好地控制绘制时的消耗。设置过大的矩形会造成绘制面积增大,而设置过小的矩形则会造成计算复杂
  • 我们重新绘制脏矩形区域时,把所有与矩形区域有交集的合成层(位图)的交集部分绘制即可

总结

回看前言中我们前端搬砖工程师眼中的浏览器工作原理,可以将整个过程划分为几大块知识内容,加以补充学习

  • HTTP  协议扩展学习 DNS  TCP  HTTP2 HTTPS 
  • 代码解析及构建 DOM 扩展学习 **编译原理 **
  • 参照上一步内容,可以更好的理解流式处理CSS 的计算方式
  • 浏览器排版规则
  • 渲染、合成、绘制等 位图操作知识 扩展到 计算机图形学 相关知识

参考资料