重学前端笔记之浏览器工作原理

266 阅读21分钟

浏览器到底是如何工作

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

HTTP 协议

浏览器首先要做的事就是根据 URL 把数据取回来,取回数据使用的是 HTTP 协议,实际上这个过程之前还有 DNS 查询


HTTP/1.1 301 Moved Permanently
Date: Fri, 25 Jan 2019 13:28:12 GMT
Content-Type: text/html
Content-Length: 182
Connection: keep-alive
Location: https://time.geekbang.org/
Strict-Transport-Security: max-age=15768000

<html>
<head><title>301 Moved Permanently</title></head>
<body bgcolor="white">
<center><h1>301 Moved Permanently</h1></center>
<hr><center>openresty</center>
</body>
</html>

在请求部分,第一行被称作 request line,它分为三个部分,HTTP Method,也就是请求的“方法”,请求的路径和请求的协议和版本。 在响应部分,第一行被称作 response line,它也分为三个部分,协议和版本、状态码和状态文本。 紧随在 request line 或者 response line 之后,是请求头 / 响应头,这些头由若干行组成,每行是用冒号分隔的名称和值。 在头之后,以一个空行(两个换行符)为分隔,是请求体 / 响应体,请求体可能包含文件或者表单数据,响应体则是 HTML 代码

HTTP 协议格式

path 是请求的路径完全由服务端来定义,没有很多的特别内容;而 version 几乎都是固定字符串;response body 是我们最熟悉的 HTML

HTTP Method(方法)

request line 里面的方法部分。这里的方法跟我们编程中的方法意义类似,表示我们此次 HTTP 请求希望执行的操作类型

  • GET
  • POST
  • HEAD
  • PUT
  • DELETE
  • CONNECT
  • OPTIONS
  • TRACE

浏览器通过地址栏访问页面都是 GET 方法。表单提交产生 POST 方法。 HEAD 则是跟 GET 类似,只返回请求头,多数由 JavaScript 发起 PUT 和 DELETE 分别表示添加资源和删除资源,但是实际上这只是语义上的一种约定,并没有强约束。 CONNECT 现在多用于 HTTPS 和 WebSocket。 OPTIONS 和 TRACE 一般用于调试,多数线上服务都不支持 CONNECT 现在多用于 HTTPS 和 WebSocket。 OPTIONS 和 TRACE 一般用于调试,多数线上服务都不支持

HTTP Status code(状态码)和 Status text(状态文本)

  • 1xx:临时回应,表示客户端请继续。
  • 2xx:请求成功。
  • 200:请求成功。
  • 3xx: 表示请求的目标有变化,希望客户端进一步处理。
    • 301&302:永久性与临时性跳转。
    • 304:跟客户端缓存没有更新。
  • 4xx:客户端请求错误。
    • 403:无权限。
    • 404:表示请求的页面不存在。
    • 418:It’s a teapot. 这是一个彩蛋,来自 ietf 的一个愚人节玩笑。(超文本咖啡壶控制协议)
  • 5xx:服务端请求错误。
    • 500:服务端错误。
    • 503:服务端暂时性错误,可以一会再试。 1xx 的状态被浏览器 HTTP 库直接处理掉了,不会让上层应用知晓。 2xx 系列的状态通常是网页请求成功的标志 3xx 系列比较复杂,301 和 302 两个状态表示当前资源已经被转移,只不过一个是永久性转移,一个是临时性转移。实际上 301 更接近于一种报错,提示客户端下次别来了 304 又是一个每个前端必知必会的状态,产生这个状态的前提是:客户端本地已经有缓存的版本,并且在 Request 中告诉了服务端,当服务端通过时间或者 tag,发现没有更新的时候,就会返回一个不含 body 的 304 状态

HTTP Head (HTTP 头)

HTTP 头可以看作一个键值对。原则上,HTTP 头也是一种数据,我们可以自由定义 HTTP 头和值

HTTP Request Body

HTTP 请求的 body 主要用于提交表单场景。实际上,HTTP 请求的 body 是比较自由的,只要浏览器端发送的 body 服务端认可就可以了。一些常见的 body 格式是:

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

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

HTTPS

在 HTTP 协议的基础上,HTTPS 和 HTTP2 规定了更复杂的内容,但是它基本保持了 HTTP 的设计思想,即:使用上的 Request-Response 模式 首先来了解下 HTTPS。HTTPS 有两个作用,一是确定请求的目标服务端身份,二是保证传输的数据不会被网络中间节点窃听或者篡改 HTTPS 是使用加密通道来传输 HTTP 的内容。但是 HTTPS 首先与服务端建立一条 TLS 加密通道。TLS 构建于 TCP 协议之上,它实际上是对传输的内容做一次加密,所以从传输内容上看,HTTPS 跟 HTTP 没有任何区别

HTTP 2

HTTP 2.0 最大的改进有两点,一是支持服务端推送,二是支持 TCP 连接复用

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

解析HTML

HTML 的结构不算太复杂,我们日常开发需要的 90% 的“词”(指编译原理的术语 token,表示最小的有意义的单元),种类大约只有标签开始、属性、标签结束、注释、CDATA 节点几种

  • 词(token)是如何被拆分的
<p class="a">text text text</p>

考虑到起始标签也是会包含属性的,最小的意义单元其实是“<p” ,所以“ <p” 就是我们的第一个词(token) 把这段代码依次拆成词(token):

  • <p“标签开始”的开始;
  • class=“a” 属性;
  • “标签开始”的结束;

  • text text text 文本;
  • 标签结束

代码开始从 HTTP 协议收到的字符流读取字符。 在接受第一个字符之前,我们完全无法判断这是哪一个词(token),不过,随着我们接受的字符越来越多,拼出其他的内容可能性就越来越少。 比如,假设我们接受了一个字符“ < ” 我们一下子就知道这不是一个文本节点啦。 之后我们再读一个字符,比如就是 x,那么我们一下子就知道这不是注释和 CDATA 了,接下来我们就一直读,直到遇到“>”或者空格,这样就得到了一个完整的词(token)了。 实际上,我们每读入一个字符,其实都要做一次决策,而且这些决定是跟“当前状态”有关的。在这样的条件下,浏览器工程师要想实现把字符流解析成词(token),最常见的方案就是使用状态机。

状态机

状态机的初始状态,我们仅仅区分 “< ”和 “非 <”: 如果获得的是一个非 < 字符,那么可以认为进入了一个文本节点; 如果获得的是一个 < 字符,那么进入一个标签状态。 不过当我们在标签状态时,则会面临着一些可能性。 比如下一个字符是“ ! ” ,那么很可能是进入了注释节点或者 CDATA 节点。 如果下一个字符是 “/ ”,那么可以确定进入了一个结束标签。 如果下一个字符是字母,那么可以确定进入了一个开始标签。 如果我们要完整处理各种 HTML 标准中定义的东西,那么还要考虑“ ? ”“% ”等内容。

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

在 C/C++ 和 JavaScript 中,实现状态机的方式大同小异:我们把每个函数当做一个状态,参数是接受的字符,返回值是下一个状态函数


var data = function(c){
    if(c=="&") {
        return characterReferenceInData;
    }
    if(c=="<") {
        return tagOpen;
    }
    else if(c=="\0") {
        error();
        emitToken(c);
        return data;
    }
    else if(c==EOF) {
        emitToken(EOF);
        return data;
    }
    else {
        emitToken(c);
        return data;
    }
};
var tagOpenState = function tagOpenState(c){
    if(c=="/") {
        return endTagOpenState;
    }
    if(c.match(/[A-Z]/)) {
        token = new StartTagToken();
        token.name = c.toLowerCase();
        return tagNameState;
    }
    if(c.match(/[a-z]/)) {
        token = new StartTagToken();
        token.name = c;
        return tagNameState;
    }
    if(c=="?") {
        return bogusCommentState;
    }
    else {
        error();
        return dataState;
    }
};
//……

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

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


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

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

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


function HTMLLexicalParser(){

    //状态函数们……
    function data() {
        // ……
    }

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

构建 DOM 树

把这些简单的词变成 DOM 树,这个过程我们是使用栈来实现的


function HTMLSyntaticalParser(){
    var stack = [new HTMLDocument];
    this.receiveInput = function(token) {
        //……
    }
    this.getOutput = function(){
        return stack[0];
    }
}

receiveInput 负责接收词法部分产生的词(token),通常可以由 emitToken 来调用

在接收的同时,即开始构建 DOM 树,所以我们的主要构建 DOM 树的算法,就写在 receiveInput 当中。当接收完所有输入,栈顶就是最后的根节点,我们 DOM 树的产出,就是这个 stack 的第一项

为了构建 DOM 树,我们需要一个 Node 类,接下来我们所有的节点都会是这个 Node 类的实例

在完全符合标准的浏览器中,不一样的 HTML 节点对应了不同的 Node 的子类,我们为了简化,就不完整实现这个继承体系了。我们仅仅把 Node 分为 Element 和 Text(如果是基于类的 OOP 的话,我们还需要抽象工厂来创建对象)


function Element(){
    this.childNodes = [];
}
function Text(value){
    this.value = value || "";
}

使用的栈正是用于匹配开始和结束标签的 对于 Text 节点,我们则需要把相邻的 Text 节点合并起来,我们的做法是当词(token)入栈时,检查栈顶是否是 Text 节点,如果是的话就合并 Text 节点


<html maaa=a >
    <head>
        <title>cool</title>
    </head>
    <body>
        <img src="a" />
    </body>
</html>
  • 栈顶元素就是当前节点;
  • 遇到属性,就添加到当前节点;
  • 遇到文本节点,如果当前节点是文本节点,则跟文本节点合并,否则入栈成为当前节点的子节点;
  • 遇到注释节点,作为当前节点的子节点;
  • 遇到 tag start 就入栈一个节点,当前节点就是这个节点的父节点;
  • 遇到 tag end 就出栈一个节点(还可以检查是否匹配)

当我们的源代码完全遵循 XHTML(这是一种比较严谨的 HTML 语法)时,这非常简单问题,然而 HTML 具有很强的容错能力,奥妙在于当 tag end 跟栈顶的 start tag 不匹配的时候如何处理。 于是,这又有一个极其复杂的规则,幸好 W3C 又一次很贴心地把全部规则都整理地很好

CSS 规则应用

DOM构建过程同时进行css属性计算

在这个过程中,我们依次拿到上一步构造好的元素,去检查它匹配到了哪些规则,再根据规则的优先级,做覆盖和调整

  • 空格: 后代,选中它的子节点和所有子节点的后代节点。

  • : 子代,选中它的子节点。

  • +:直接后继选择器,选中它的下一个相邻节点。

  • ~:后继,选中它之后所有的相邻节点。

  • ||:列,选中表格中的一列。

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

未来也不可能会出现“父元素选择器”这种东西,因为父元素选择器要求根据当前节点的子节点,来判断当前节点是否被选中,而父节点会先于子节点构建

作为一门语言,CSS 需要先经过词法分析和语法分析,变成计算机能够理解的结构

一个 compound-selector 是检查一个元素的规则,而一个复合型选择器,则是由数个 compound-selector 通过前面讲的符号连接起来的

  • 后代选择器 “空格”

a#b .cls {
    width: 100px;
}

CSS 选择器按照 compound-selector 来拆成数段,每当满足一段条件的时候,就前进一段

在上面的例子中,当我们找到了匹配 a#b 的元素时,我们才会开始检查它所有的子代是否匹配 .cls

后代选择器的作用范围是父节点的所有子节点,因此规则是在匹配到本标签的结束标签时回退。

  • 后继选择器“ ~ ”

.cls~* {
    border:solid 1px green;
}
<div>
<span>1<span>
<span class=cls>2<span>
<span>
    3
    <span>4</span>
<span>
<span>5</span>
</div>

按照 DOM 树的构造顺序,4 在 3 和 5 中间,我们就没有办法像前面讲的后代选择器一样通过激活或者关闭规则来实现匹配 给选择器的激活,带上一个条件:父元素.当前半段的 .cls 匹配成功时,后续 * 所匹配的所有元素的父元素都已经确定了(后继节点和当前节点父元素相同是充分必要条件

  • 子代选择器“ >” 有了前面讲的父元素这个约束思路,我们很容易实现子代选择器

div>.cls {
    border:solid 1px green;
}
<div>
<span>1<span>
<span class=cls>2<span>
<span>
    3
    <span>4</span>
<span>
<span>5</span>
</div>

当 DOM 树构造到 div 时,匹配了 CSS 规则的第一段,因为是子代选择器,我们激活后面的 .cls 选择条件,并且指定父元素必须是当前 div。于是后续的构建 DOM 树构建过程中,span 2 就被选中了

  • 直接后继选择器“ +” 只对唯一一个元素生效 把它当作检查元素自身的选择器来处理。即我们把 #id+.cls 都当做检查某一个元素的选择器 另外的一种思路是:给后继选择器加上一个 flag,使它匹配一次后失效

  • 列选择器“ || ” 列选择器比较特别,它是专门针对表格的选择器,跟表格的模型建立相关

  • 其它

CSS 选择器还支持逗号分隔,表示“或”的关系。这里最简单的实现是把逗号视为两条规则的一种简易写法

选择器可能有重合,这样,我们可以使用树形结构来进行一些合并,来提高效率:


#a .cls {

}

#a span {

}
#a>span {

}

可以把选择器构造成一棵树

  • #a

    • < 空格 >.cls
    • < 空格 >span
    • span

确定元素位置

浏览器最基本的排版方案是正常流排版,它包含了顺次排布和折行等规则,这是一个跟我们提到的印刷排版类似的排版方案,也跟我们平时书写文字的方式一致,所以我们把它叫做正常流

浏览器又可以支持元素和文字的混排,元素被定义为占据长方形的区域,还允许边框、边距和留白,这个就是所谓的盒模型

在正常流的基础上,浏览器还支持两类元素:绝对定位元素和浮动元素。

  • 绝对定位元素把自身从正常流抽出,直接由 top 和 left 等属性确定自身的位置,不参加排版计算,也不影响其它元素。绝对定位元素由 position 属性控制
  • 浮动元素则是使得自己在正常流的位置向左或者向右移动到边界,并且占据一块排版空间。浮动元素由 float 属性控制

正常流文字排版

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

除了字体提供的字形本身包含的信息,文字排版还受到一些 CSS 属性影响,如 line-height、letter-spacing、word-spacing 等。 在正常流的文字排版中,多数元素被当作长方形盒来排版,而只有 display 为 inline 的元素,是被拆成文本来排版的

display 值为 inline 的元素中的文字排版时会被直接排入文字流中,inline 元素主轴方向的 margin 属性和 border 属性(例如主轴为横向时的 margin-left 和 margin-right)也会被计算进排版前进距离当中

当没有强制指定文字书写方向时,在左到右文字中插入右到左向文字,会形成一个双向文字盒

即使没有元素包裹,混合书写方向的文字也可以形成一个盒结构,我们在排版时,遇到这样的双向文字盒,会先排完盒内再排盒外

正常流中的盒

在正常流中,display 不为 inline 的元素或者伪元素,会以盒的形式跟文字一起排版。多数 display 属性都可以分成两部分:内部的排版和是否 inline,带有 inline- 前缀的盒,被称作行内级盒。

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

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

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

绝对定位元素

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

浮动元素排版

float 元素非常特别,浏览器对 float 的处理是先排入正常流,再移动到排版宽度的最左 / 最右

移动之后,float 元素占据了一块排版的空间,因此,在数行之内,主轴方向的排版距离发生了变化,直到交叉轴方向的尺寸超过了浮动元素的交叉轴尺寸范围,主轴排版尺寸才会恢复。float 元素排布完成后,float 元素所在的行需要重新确定位置

CSS 的每一种排版都有一个很复杂的规定,实际实现形式也各不相同。比如如 Flex 排版,支持了 flex 属性,flex 属性将每一行排版后的剩余空间平均分配给主轴方向的 width/height 属性

渲染

模型变成位图:在内存里建立一张二维表格,把一张图片的每个像素对应的颜色保存进去

浏览器中渲染这个过程,就是把每一个元素对应的盒变成位图。这里的元素包括 HTML 元素和伪元素,一个元素可能对应多个盒(比如 inline 元素,可能会分成多行)。每一个盒对应着一张位图。

这个渲染过程是非常复杂的,但是总体来说,可以分成两个大类:图形和文字

盒的背景、边框、SVG 元素、阴影等特性,都是需要绘制的图形类。这就像我们实现 HTTP 协议必须要基于 TCP 库一样,这一部分,我们需要一个底层库来支持

一般的操作系统会提供一个底层库,比如在 Android 中,有大名鼎鼎的 Skia,而 Windows 平台则有 GDI,一般的浏览器会做一个兼容层来处理掉平台差异

盒中的文字,也需要用底层库来支持,叫做字体库。字体库提供读取字体文件的基本能力,它能根据字符的码点抽取出字形。

字形分为像素字形和矢量字形两种。通常的字体,会在 6px 8px 等小尺寸提供像素字形,比较大的尺寸则提供矢量字形。矢量字形本身就需要经过渲染才能继续渲染到元素的位图上去

很多属性会影响渲染位图的大小,比如阴影,它可能非常巨大,或者渲染到非常遥远的位置,所以为了优化,浏

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

合成

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

好的合成策略是“猜测”可能变化的元素,把它排除到合成之外。

主流浏览器一般根据 position、transform 等属性来决定合成策略,来“猜测”这些元素未来可能发生变化。

绘制

绘制是把“位图最终绘制到屏幕上,变成肉眼可见的图像”的过程,不过,一般来说,浏览器并不需要用代码来处理这个过程,浏览器只需要把最终要显示的位图交给操作系统即可

把任何位图合成到这个“最终位图”的操作称为绘制,实际上就是按照 z-index 把它们依次绘制到屏幕上。

限制绘制的面积:“脏矩形”算法,也就是把屏幕均匀地分成若干矩形区域。

当鼠标移动、元素移动或者其它导致需要重绘的场景发生时,我们只重新绘制它所影响到的几个矩形区域就够了。比矩形区域更小的影响最多只会涉及 4 个矩形,大型元素则覆盖多个矩形。

设置合适的矩形区域大小,可以很好地控制绘制时的消耗。设置过大的矩形会造成绘制面积增大,而设置过小的矩形则会造成计算复杂

重新绘制脏矩形区域时,把所有与矩形区域有交集的合成层(位图)的交集部分绘制即可。

渲染过程把元素变成位图,合成把一部分位图变成合成层,最终的绘制过程把合成层显示到屏幕上。

review

浏览器的第一步工作,也就是“浏览器首先使用 HTTP 协议或 HTTPS 协议,向服务端请求页面”的这一过程

然后是解析代码和构建 DOM 树

CSS 计算是把 CSS 规则应用到 DOM 树上,为 DOM 结构添加显示相关属性的过程

并且根据样式信息,计算了每个元素的位置和大小,确定每一个元素的位置,进行排版

最后渲染过程把元素变成位图,合成把一部分位图变成合成层,最终的绘制过程把合成层显示到屏幕上。

ps:文章为重学前端专栏的学习笔记,侵删。