重学前端----浏览器是如何工作的----寒冬winter
浏览器是如何工作的
第一小节 HTTP HTTPS
把一个URL变成一个屏幕上显示的网页
- 浏览器首先使用HTTP协议或者HTTPS协议,向服务端请求页面
- 把请求回来的HTML代码经过解析,构建成DOM树
- 计算DOM树上的CSS属性
- 最后根据CSS属性对元素逐个进行渲染,得到内存中的位图
- 一个可选的步骤是对位图进行合成,这样会极大地增加后续绘制的速度
- 合成之后,再绘制到界面上
来个图片吧
这是一条流水线
HTTP协议
浏览器首先做的事情就是根据URL把数据拿回来,用的是HTTP协议或者HTTPS协议
HTTP协议是基于TCP的,TCP是一条双向通讯的通道,而HTTP在其基础上,规定出Request-Response的模式。这个模式决定了通讯必定是由浏览器端发起的。
HTTP协议格式
HTTP Method
- GET
- POST
- HEAD
- PUT
- DELETE
- CONNECT
- OPTIONS
- TRACE
浏览器通过地址栏访问页面都是GET方法,提交表单POST方法
HEAD则是跟GET类似,只是还返回一个请求头,大多数情况下HEAD由JavaScript发起
PUT 和 DELETE 分别表示添加资源和删除资源,但是实际上这只是语义上的一种约定,并没有强约束
CONNECT 现在多用于HTTPS还有WebSocket
Options 和 TRACE一般用于调试,多数线上服务都不支持
HTTP Status Code 状态码 和 Status Text 状态文本
接下来我们看看reponse line的状态码还有状态文本
1xx: 临时回应 表示客户端请继续
2xx: 请求成功
200: 请求成功
3xx: 表示请求的目标有变化,希望客户端进一步处理
301&302: 永久性与临时性跳转
304: 跟客户端缓存没有更新
4xx: 客户端请求错误
403: 无权限
404: 表示请求的页面不存在
418: ??????其实这个是自定义的 只是已经有了规范
5xx: 服务端请求错误
500: 服务端错误
503: 服务端暂时性错误,可以过一会儿再试。
对于我们前端来说,1xx系列的状态码是非常陌生的,原因是1xx的状态被浏览器http库直接处理掉了,不会让上层应用知晓。
2xx系列的状态最熟悉的就是200,这通常是网页请求成功的标志,也是大家最喜欢的状态码。
3xx系列比较复杂,301资源永久转移,302资源临时转移。实际上301更接近于一种报错,提示用户端下次别来了。
304又是一个前端必会必知的状态,产生这个状态的前提是:客户端本地已有缓存的版本,并且在Request中告诉了服务端,当服务端通过时间或者tag,发现没有更新了的时候,就会返回一个不含body的304状态。
HTTP Head(HTTP头)
HTTP头可以看作是一个键值对。原则上,HTTP头也是一种数据。我们可以自由的定义HTTP头和值,不过在HTTP规范中,规定了一些特殊的HTTP头,我们现在就来了解一下他们。
在HTTP标准中,有完整的请求/响应头规定。
接下来看一下Response Header
这里winter仅仅列出了比较常见的HTTP头,这些头应该是前端工程师做到不需要查阅,看到就可以知道意思的HTTP头。完整的列表还是要参考winter给出的rfc2616标准。
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和HTTP2规定了更为复杂的内容,一是确定请求的目标服务端身份,而是保证传输的数据不会被中间节点窃听或者篡改。
HTTPS的标准也是RFC规定的,你可以查看它的详情链接:
https://tools.ietf.org/html/rfc2818
HTTPS是使用加密通道来传输HTTP的内容。但是HTTPS首先与服务端建立一条TLS加密通道。TLS构建于TCP协议之上,它实际上是对传输的内容做一次加密,所以从传输的内容上看,HTTPS和HTTP没有任何的区别。
HTTP2
HTTP2是HTTP1.1的升级版本,详情链接
https://tools.ietf.org/html/rfc7540
HTTP2.0有两个最大的改进点:一个是支持服务端推送,二个是支持TCP连接的复用。
服务端推送能够在客户端发送第一个请求到服务端的时候,提前把一部分内容推送给客户端,放入到缓存当中,这样可以避免客户端请求顺序带来的并行度不高,从而导致的性能问题。
TCP连接复用,则使用同一个TCP连接来传输多个HTTP请求,避免了TCP连接建立时候的三次握手开销,和初建TCP连接时传输窗口小的问题。
Note: 其实很多优化涉及更下层的协议。IP 层的分包情况,和物理层的建连 时间是需要被考虑的。
第二小节 如何解析请求回来的HTML代码 DOM树如何构建
解析代码
我们在前面讲到了HTTP的构成,但是有一部分winter没有详细讲解,那就是Response的body部分,这正是因为HTTP的Response的body,今天就来讲。
HTML的结构不复杂,我们的日常开发中需要的90%的“词”(指的是编译原理的术语token,表示最小有意义的单元),种类大约只有标签开始、属性、标签结束、注释、CDATA节点集中。
实际上有点麻烦的是,由于HTML和SGML的千丝万缕的联系,我们需要做不少容错处理。“<?” 和 “<%”什么的也是必须要支持好的,报错了也不能吭声。
-
词(token)是如何拆分的
首先我们来看看一个非常标准的标签,会被如何拆分:
<p class="a”>text text text </p>
如果我们从最小有意义单元的定义来拆分,第一个词(token)是什么呢?显然,作为一个词(token),整个p标签肯定是过大了(它甚至可以嵌套)。
那么,只用p标签的开头是不是合适吗?我们考虑到起始标签也是会包含属性的,最小的意义单元其实是“<p”,所以“<p”就是我们的第一个词(token)
我们继续拆分,可以把这段代码依次拆成词(token):
<p“标签开始”的开始;
class=“a” 属性;
> “标签开始”的结束;
text text text 文本;
标签结束。这是一段最简单的例子,类似的还有什么呢?现在我们可以来来看看这些词(token)长成什么样子
根据这样的分析,现在我们讲讲浏览器是如何用代码实现,我们设想,代码开始从 HTTP协议收到的字符流读取字符。
在接受第一个字符之前,我们完全无法判断这是哪一个词(token),不过,随着我们接受的字符越来越多,拼出其他的内容可能性就越来越少。
比如,假设我们接受了一个字符“ < ” 我们一下子就知道这不是一个文本节点啦。之后我们再读一个字符,比如就是 x,那么我们一下子就知道这不是注释和 CDATA 了,接下来我们就一直读,直到遇到“>”或者空格,这样就得到了一个完整的词(token)了。
实际上,我们每读入一个字符,其实都要做一次决策,而且这些决定是跟“当前状态”有
关的。在这样的条件下,浏览器工程师要想实现把字符流解析成词(token),最常见的
方案就是使用状态机。
-
状态机
绝大多数语言的词法部分都是用状态机实现的。那么我们把部分词(token)的解析画成一个状态机看看。
当然了,我们这里的分析比较粗略,真正完整的 HTML 词法状态机,比我们描述的要复杂 的多。更详细的内容,你可以参考HTML 官方文档,HTML 官方文档规定了 80 个状态 (顺便一说,HTML 是我见过唯一一个标准中规定了状态机实现的语言,对大部分语言来 说,状态机是一种实现而非定义)。
这里我们为了理解原理,用这个简单的状态机就足够说明问题了。
状态机的初始状态,我们仅仅区分 “< ”和 “非 <”:
如果获得的是一个非 < 字符,那么可以认为进入了一个文本节点;
如果获得的是一个 < 字符,那么进入一个标签状态。
不过当我们在标签状态时,则会面临着一些可能性。
比如下一个字符是“ ! ” ,那么很可能是进入了注释节点或者 CDATA 节点。
如果下一个字符是 “/ ”,那么可以确定进入了一个结束标签。
比如下一个字符是字母,那么可以确定进入了一个开始标签。
如果我们要完整处理各种HTML标准中定义的东西,那么还要考虑“?” “%”等内容
我们可以看到,用状态及做词法分析,其实正是把每个词的“特征字符”逐个拆开成独立的状态,然后再把所有的特征字符链合并起来,形成一个连通图结构。
由于状态机的设计属于编译原理的基本知识,我们在这里仅仅做一个简单的介绍。
由于状态机设计属于编译原理的基本知识,这里我们仅作一个简要的介绍。
接下来就是代码实现的事情了,在 C/C++ 和 JavaScript 中,实现状态机的方式大同小 异:我们把每个函数当做一个状态,参数是接受的字符,返回值是下一个状态函数。(这里我希望再次强调下,状态机真的是一种没有办法封装的东西,所以我们永远不要试图封 装状态机。)
为了方便理解和试验,我们这里用 JavaScript 来讲解,图上的 data 状态大概就像下面这 样的:
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);
}
}
至此,我们就把字符流拆成了词(token)了。
构建DOM树
接下来我们要把这些简单的词语变成DOM树,整个过程中我们是使用栈来实现的,在任何的语言里面几乎都有栈。我们还是用JavaScript来实现的。
function HTMLSyntaticalParser(){
var stack = [new HTMLDocument];
this.receiveINput = function(token) {
// ......
}
this.getOutput = function() {
return stack[0];
}
}
我们这样来设计 HTML 的语法分析器,receiveInput 负责接收词法部分产生的词
(token),通常可以由 emmitToken 来调用。
在接收的同时,即开始构建 DOM 树,所以我们的主要构建 DOM 树的算法,就写在 receiveInput 当中。当接收完所有输入,栈顶就是最后的根节点,我们 DOM 树的产出, 就是这个 stack 的第一项。
为了构建 DOM 树,我们需要一个 Node 类,接下来我们所有的节点都会是这个 Node 类的实例。
在完全符合标准的浏览器中,不一样的 HTML 节点对应了不同的 Node 的子类,我们为 了简化,就不完整实现这个继承体系了。我们仅仅把 Node 分为 Element 和 Text(如果 是基于类的 OOP 的话,我们还需要抽象工厂来创建对象),
function Element() {
this.childNodes = [];
}
function Text(value) {
this.value = value || "";
}
前面我们的词(token)中,以下两个是需要成对匹配的:
tag start
tag end
根据一些编译原理中常见的技巧,我们使用的栈正是用于匹配开始和结束标签的方案。
对于 Text 节点,我们则需要把相邻的 Text 节点合并起来,我们的做法是当词(token) 入栈时,检查栈顶是否是 Text 节点,如果是的话就合并 Text 节点同样我们来看看直观的解析过程:
<html maaa=a >
<head>
<title>cool</title>
</head>
<body>
<img src="a"/>
</body>
</html>
通过这个栈,我们可以构建 DOM 树:
栈顶元素就是当前节点;
遇到属性,就添加到当前节点;
遇到文本节点,如果当前节点是文本节点,则跟文本节点合并,否则入栈成为当前节点的字节点;
遇到注释节点,作为当前节点的字节点;
遇到tag start就入栈一个节点,当前节点就是这个节点的父节点
遇到tag end就出栈一个节点(还可以检查是否匹配)
当我们的源代码完全遵循 xhtml(这是一种比较严谨的 HTML 语法)时,这非常简单问 题,然而 HTML 具有很强的容错能力,奥妙在于当 tag end 跟栈顶的 start tag 不匹配的 时候如何处理。
于是,这又有一个极其复杂的规则,幸好 W3C 又一次很贴心地把全部规则都整理地很 好,我们只要翻译成对应的代码就好了,以下这个网站呈现了全部规则。你可以点击查看。
http://www.w3.org/html/wg/drafts/html/master/syntax.html#tree-construction
https://github.com/aimergenge/toy-html-parser
第三小节 CSS规则应用到节点上,并添加DOM节点
整体过程
首先我们感性地理解一下这个过程。
首先CSS选择器这个名称,可能会给你带来一定的误解,觉得好像CSS规则是DOM树构建好了以后,再进行选择并给它添加样式的。实际上,这个过程并不是这个样子的。
我们回忆一下我们在浏览器第一节课讲的内容,浏览器会尽量流式处理整个过程。我们上一节课构建DOM的过程是:从父到子,从先到后,一个一个节点构造,并且挂载到DOM树上,那么这个过程中,我们是否能同步把CSS属性计算出来呢?
答案是肯定的。
在这个过程中,我们依次拿到上一部构造好的元素,去检查它匹配到了哪些规则,再根据规则的优先级,做覆盖和调整。所以,从这个角度来看,所谓的匹配器,应该被理解成“匹配器”才更合适。
winter在CSS语法部分,已经总结了选择器的各种符号,这里又列了一下:
- 空格: 后代,选中它的字节点和所有字节点的后代节点。
- +:直接后继选择器,选中它的下一个相邻节点。
- ~:后继,选中它之后所有的相邻节点。
- ||:列,选中表哥中的一列。
选择器,继续学习后面winter CSS,这里主要介绍浏览器是如何实现这些规则的。
不知道你有没有发现,这里的选择器有一个特点,那就是选择器的出现顺序,必定跟构建DOM树的顺序一致,这个一个CSS设计的原则,即保证选择器在DOM树构建到当前节点时,已经可以准确判断是否匹配,不需要后续的节点信息。
也就是说,未来也不可能出现“父元素”选择器这种东西,因为父元素选择器要求根据当前节点的字节点,来判断当前的节点是否被选中,然而父节点会先于子节点来构建。
理解了CSS构建的大概过程,我们来看看下面的操作。
首先,我们必须把CSS规则做一下处理。作为一门语言,CSS需要先经过词法分析和语法分析,变成计算机能够理解的结构。
这部分具体的做法属于编译原理的内容,这就不赘述了。我们这里假设CSS已经被解析成了一颗可用的抽象语法树。
我们在之前的CSS课程里面已经介绍过了compound-selector的概念,一个compound-selector是检查一个元素的规则,而一个复合型选择器,则是由数个compound-selector通过前面讲的符号连接起来的。
后代选择器“空格”
我们先来分析一下后代选择器,我们一起来看一个例子:
a#b .cls {
width: 100px;
}
可以把一个CSS选择器按照compound-selector来拆成数段,每当满足一段条件的时候,就前进一段。
比如,在上面的例子中,当我们找到了匹配a#b的元素时,我们才会开始检查它所有的子代是否匹配.cls。
除了前进一段的情况,我们还需要处理后退的情况。比如这样一段代码
<a id=b>
<span>1<span>
<span class=cls>2<span>
</a>
<span class=cls>3<span>
当遇到时,必须使得规则a#b .cls回退一步,这样第三个span才不会被选中。后代选择器的作用范围是父节点的所有字节点,因此规则是匹配到本标签的结束标签时回退。
后继选择器 “~”
接下来我们看下后继选择器,跟后代选择器不同的是,后继选择器只作用于一层,我们来看一个例子:
.cls~* {
border: solid 1px green;
}
<div>
<span>1</span>
<span class=cls>2</span>
<span>3
<span>4</span>
</span>
<span>5</span>
</div>
这里.cls选中了span2 然后 span3 是它的后继,但是span3的子节点span4并不应该被选中,然而span5也是它的后继,因此应该被选中。
按照DOM树的构造顺序,4在3和5中间,我们就没有办法像前面讲的后代选择器一样,通过激活或者关闭规则来实现匹配。
但是这里有个非常方便的思路,就是给选择器的激活,带上一个条件,父元素。
注意,这里后继选择器,当前半段的.cls匹配成功的时候,后继*所匹配的所有元素的父元素都已经确定了(后继节点和当前节点父元素相同是充分必要条件)。在我们的例子中,那个div就是后继节点的父元素。
子代选择器 “>”
继续看,子代选择器是如何实现的。
实际上,有了前面讲的父元素这个约束思路,我们很容易实现子代选择器。区别仅仅是拿当前节点作为父元素,还是拿当前节点的父元素作为父元素。
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树构建过程中,span2就被选中了。
直接后继选择器 “+”
直接后继选择器的实现是上述规则中最为简单的了,因为他只会对一个元素生效,所以不需要像前面几种一样反复激活和关闭规则。
一个最简单的思路是,我们可以把它当作是检查自身元素的选择器来处理。即我们把#id + .cls都当作检查一个元素的选择器
另外一种思路是:给后继选择器加上一个flag,使它匹配一次后失效。你可以尝试一下。
列选择器 “||”
列选择器比较特别,它是专门针对表格的选择器,跟表格的模型建立相关,我们这里不详
细讲了。
其它
我们不要忘记,CSS 选择器还支持逗号分隔,表示“或”的关系。这里最简单的实现是把
逗号视为两条规则的一种简易写法。
比如
a#b, .cls {
}
我们当作两条规则来处理:
a#b {
}
.cls {
}
还有一种情况,这就是选择器可能有重合,这样,我们可以用树形结构来进行一些合并,来提高效率:
#a .cls {
}
#a .span {
}
#a > span {
}
实际上可以把选择器构造成一棵树。
a
<空格>.cls
<空格>span
>span
需要注意的是,这里的树,必须带上连字符。
第四小节 排版
浏览器进行到了这一步,我们已经给DOM元素添加了用于展现的CSS属性,接下来,浏览器的工作就是确定每一个元素的位置。我们的基本原则仍然不变,就是尽可能流式地处理上一步骤的输出。
在构建DOM树和计算CSS属性这两个步骤,我们的产出都是一个一个的元素,但是在排版这个步骤中,有些情况下,我们就没法子做到这样了。
尤其是表格相关的排版、flex排版和grid排版,它们有一个显著的特点,那就是子元素之间具有关联性。
基本概念
首先我们来介绍一些基本概念,使你可以感性地认识一下我们平常所说的各种术语。
“排版”这个概念最初来自活字印刷,是指我们把一个一个的铅字根据文章顺序,放入板框当中的步骤,排版的意思是确定每一个字的位置。
在现代浏览器中,仍然借用了这个概念,但是排版的内容更加复杂,包括文字、图片、图形、表格等等。我们把浏览器确定它们位置的过程,叫做排版。
浏览器最基本的排版方案是正常流排版,它包含了顺次排布和折行等规则,这是一个跟我们提到的印刷排版类似的排版方案,也跟我们平时书写文字的方式一致,所以我们把它叫做正常流。
浏览器的最基本的排版方案是正常流排版,它包含了顺次排布和折行等规则,这是一个跟我们提到的印刷排版类似的排版方案,也跟我们平时书写文字的方式一致,所以我们把它叫做正常流。
浏览器的文字排版遵循公认的文字排版规范,文字排版是一个复杂的系统,它规定了行模型和文字在行模型中的排布。行模型规定了行顶、行底、文字区域、基线等对齐方式。(你还记得小时候写英语的英语本吗?英语本子上的四条线就是简单的行模型)
此外,浏览器支持不同语言,因为不同语言的书写顺序不一致,所以浏览器的文本排版还支持双向文字系统。
浏览器又可以支持元素和文字的混排,元素被定义为占据长方形的区域,还允许边框、边距和留白,这个就是所谓的盒模型。
在正常流的基础上,浏览器还支持两类元素:绝对定位元素和浮动元素。
- 绝对定位元素把自身从正常流中抽出,直接由top和left等属性确定自身的位置,不参加排版计算,也不影响其他元素。绝对定位元素由position属性控制。
- 浮动元素则是使得自己在正常流的位置向左或者向右移动到边界,并且占据一块排版空间。浮动元素由float属性控制。
除了正常流,浏览器还支持其他的排版方式,比如现在非常常用的flex排版,这些排版方式由外部元素的display属性来控制(注意,display同时还控制元素在正常流中属于inline等级还是block等级)。
正常流文字排版
winter老师会在CSS部分详细介绍正常流排版的行为,我们这里主要介绍浏览器当中的正常流。正常流是唯一一个文字和盒混排的排版方式,我们先从文字来讲起。
要想理解正常流,我们首先要回忆一下自己如何在纸上写文章。
首先,纸是要有固定宽度的,虽然纸由固定的宽度,但是我们可以通过下一页的方式来接续,所以我们不存在写不下的场景。
我们书写文字的时候,是从左到右依次书写,每一个字跟上一个字都不重叠,文字之间有一定间距,每当写满一行的时候,我们就换行去写。
书写中文的时候,文字的上、下、中轴线都对齐,书写英文时,不同字母的高度不同,但是有一条基线对齐。
实际上浏览器环境也很类似,但是因为浏览器支持改变排版方向,不一定是从左到右从上到下,所以我们把文字依次书写的延伸方向称为主轴或者主方向,换行延伸的方向,跟主轴垂直交叉,称为交叉轴或者交叉方向。
我们一般会从某个字体文件中获取某个特定文字的相关信息,我们获取到的信息大概类似于下面。
纵向版本:
这两张图片来自著名开源字体解析库 freetype,实际上,各个库对字体的理解大同小异,我们注意一下,advance代表每一个文字排布后在主轴上的前进距离,它跟文字的宽/高不想等,是自提中最重要的属性。
除了字体提供的字形本身包含的信息,文字的排版还收到一些CSS属性的影响,例如line-height、letter-spacing、word-spacing等。
在正常流的文字排版中,多数元素被当作长方形盒子来排版,而只有display为inline的元素,是被拆成文本来排版的(还有一种是run-in元素,它有时候作为盒,有时候作为文字,不太常用,这里就不细讲了)。
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属性。浏览器支持的每一种排版方式,都是按照对应的标准来实现的。
总结
这一部分,我们以正常流为主,介绍了浏览器的排版基本概念及一些算法。这里,我主要 介绍了正常流中的文字排版、正常流中的盒、绝对定位元素、浮动元素排版这几种情况。 最后,我还简单介绍了一下 flex 排版。这属于进阶版的排版方式了,你可以了解一下。
「你平时喜欢使用方式排版呢,欢迎留言告诉我。」
--------平时喜欢flex,简单啦,就是antd element 用的都是grid 偶尔再来两个float
————写代码之前认真思考整体的布局真的太有必要了
第五小节 渲染
首先我们来谈谈渲染这个词,这个词也是一个外来词,它是英文词render的翻译,render这个词在英文里面,有“导致”、“变成”的意思,也有“粉刷墙壁”的意思。
在计算机图形学领域,英文render这个词是一个简写,它是特指模型变成位图的过程。我们把render翻译成“渲染”,是个非常有意思的翻译,中文里“渲染”这个词是一种绘画技法,是指沾清水把墨涂开的意思。
所以,render翻译成“渲染”,我认为是非常高明的,对render这个过程,用国画的渲染手法来概括,是颇有神似得。
我们现在的一些框架,也会把“从数据变成HTML代码的过程”称为render,其实我觉得这是非常有误导性的,我个人非常不喜欢这种命名方式,当然了,所谓“文无第一”,在自然语言的范围里,我们很难彻底否定这种用法的合理性。
不过在本篇文章中,我们可以约定一下,本文中出现在“渲染”一词,统一指的是它的图形学的意义,就是把模型变成位图的过程。
这里的位图就是在内存里建立一张二维表格,把一张图片的每个像素对应的颜色保存进去(位图信息也是DOM树里占据浏览器内存最多的信息,我们在做内存占用优化时,主要就考虑这个部分)。
这个渲染过程是十分复杂的,但是总体来说,可以分为两大类:图形和文字。
盒的背景、边框、SVG元素、阴影等特性,都是需要绘制的图形类。就像我们实现HTTP协议必须要基于TCP库意义,这一部分,我们需要一个底层库来支持。
一般的操作系统都会提供一个底层库,比如在android中,有个大名鼎鼎的Skia,而windows平台则有GDI,一般的浏览器会做一个兼容层来处理掉平台差异。
这些盒子的特性如何绘制,每一个都有对应的标准规定,而每一个的实现都可以作为一个独立的课题来研究,当年圆角+虚线边框,可是难倒了各个浏览器的工程师。考虑到这些知识互相都比较独立,对于前端工程师来说也不是特别重要的细节,我们在这里就不详细探究了。
盒中的文字,也需要用底层库来支持,叫做字体库。字体库提供读取字体文件的基本能力,它能根据字符的码点抽取出字形。
字形分为像素字形和矢量字形两种。通常的字体,会在6px,8px等小尺寸提供像素字形,比较大的尺寸则提供矢量字形。矢量字形本身就需要经过渲染才能继续渲染到元素的位图上去。目前最常用的字体库是Freetype,这是一个C++编写的开源的字体库。
在最普遍的情况下,渲染过程生成的位图尺寸跟它在上一步排版时候所占据的尺寸相同。
但是理想和现实是有差距的,很多属性会影响渲染位图的大小,比如阴影,它可能非常巨大,或者渲染到非常遥远的位置,所以为了优化,浏览器实际的实现中会把阴影作为一个独立的盒子来处理。
注意,我们这里讲的渲染过程,是不会把子元素绘制到渲染的位图上的,这样,当父子元
素的相对位置发生变化时,可以保证渲染的结果能够最大程度被缓存,减少重新渲染。
合成
合成是英文术语compositing的翻译,这个过程实际上是一个性能考量,它并非实现浏览器的必要一环。
我们上一小节中讲到,渲染过程不会把子元素渲染到位图上面,合成的过程,就是为一些元素创建一个“合成后的位图”(我们把它称为合成层),把一部分子元素渲染到合成的位图上面。
看到这句话,我想你一定会问问题,到底是为哪些元素创建合成后的位图,把哪些子元素 渲染到合成的位图上面呢?
这就是我们要讲的合成的策略。我们前面讲了,合成是一个性能考量,那么合成的目标就 是提高性能,根据这个目标,我们建立的原则就是最大限度减少绘制次数原则。
我们举一个极端的例子。如果我们把所有元素都进行合成,比如我们为根元素 html 创建 一个合成后的位图,把所有子元素都进行合成,那么会发生什么呢?
那就是,一旦我们用 JavaScript 或者别的什么方式,改变了任何一个 CSS 属性,这份合 成后的位图就失效了,我们需要重新绘制所有的元素。
那么如果我们所有的元素都不合成,会怎样呢?结果就是,相当于每次我们都必须要重新 绘制所有的元素,这也不是对性能友好的选择。
那么好的合成策略是什么呢,好的合成策略是“猜测”可能变化的元素,把它排除到合成 之外。
我们来举个例子:
<div id="a">
<div id="b">
...
</div>
<div id="c" style="transform: translate(0,0)">
</div>
</div>
假设我们的合成策略能够把 a、b 两个 div 合成,而不把 c 合成,那么,当我执行以下代 码时:
document.getElementById("c").style.transform = "translate(100px, 0)";
我们绘制的时候,就可以只需要绘制 a 和 b 合成好的位图和 c,从而减少了绘制次数。这 里需要注意的是,在实际场景中,我们的 b 可能有很多复杂的子元素,所以当合成命中 时,性能提升收益非常之高。
目前,主流浏览器一般根据 position、transform 等属性来决定合成策略,来“猜测”这 些元素未来可能发生变化。
但是,这样的猜测准确性有限,所以新的 CSS 标准中,规定了 will-change 属性,可以由 业务代码来提示浏览器的合成策略,灵活运用这样的特性,可以大大提升合成策略的效 果。
绘制
绘制是把“位图最终绘制到屏幕上,变成肉眼可见的图像”的过程,不过,一般来说,浏 览器并不需要用代码来处理这个过程,浏览器只需要把最终要显示的位图交给操作系统即可。
一般最终显式的位图位于显存中,也有一些情况下,浏览器只需要把内存中的一张位图提 交给操作系统或者显式驱动就可以了,这取决于浏览器运行的环境。不过无论如何,我们 把任何位图合成到这个“最终位图”的操作称为绘制。
这个过程听上去非常简单,这是因为在前面两个小节中,我们已经得到了每个元素的位 图,并且对它们部分进行了合成,那么绘制过程,实际上就是按照 z-index 把它们依次绘 制到屏幕上。
然而如果在实际中这样做, 会带来极其糟糕的性能。
有一个一度非常流行于前端群体的说法,讲做 CSS 性能优化,应该尽量避免"重排"和"重 绘",前者讲的是我们上一课的排版行为,后者模糊地指向了我们本课程三小节讲的三个步 骤,而实际上,这个说法大体不能算错,却不够准确。
因为,实际上,“绘制”发生的频率比我们想象中要高得多。我们考虑一个情况:鼠标划 过浏览器显示区域。这个过程中,鼠标的每次移动,都造成了重新绘制,如果我们不重新 绘制,就会产生大量的鼠标残影。
这个时候,限制绘制的面积就很重要了。如果鼠标某次位置恰巧遮盖了某个较小的元素, 我们完全可以重新绘制这个元素来完成我们的目标,当然,简单想想就知道,这种事情不 可能总是发生的。
计算机图形学中,我们使用的方案就是“脏矩形”算法,也就是把屏幕均匀地分成若干矩 形区域。
当鼠标移动、元素移动或者其它导致需要重绘的场景发生时,我们只重新绘制它所影响到 的几个矩形区域就够了。比矩形区域更小的影响最多只会涉及 4 个矩形,大型元素则覆盖 多个矩形。
设置合适的矩形区域大小,可以很好地控制绘制时的消耗。设置过大的矩形会造成绘制面 积增大,而设置过小的矩形则会造成计算复杂。
我们重新绘制脏矩形区域时,把所有与矩形区域有交集的合成层(位图)的交集部分绘制 即可。
总结
在这一节课程中,我们讲解了浏览器中的位图操作部分,这包括了渲染、合成和绘制三个 部分。渲染过程把元素变成位图,合成把一部分位图变成合成层,最终的绘制过程把合成 层显示到屏幕上。
当绘制完成时,就完成了浏览器的最终任务,把一个 URL 最后变成了一个可以看的网页图 像。当然了,我们对每一个部分的讲解,都省略了大量的细节,比如我们今天讲到的绘 制,就有意地无视了滚动区域。
尽管如此,对浏览器工作原理的感性认识,仍然可以帮助我们理解很多前端技术的设计和 应用技巧,浏览器的工作原理和性能部分非常强相关,我们在实践部分的性能优化部分, 会再次跟你做一些探讨。
实际上,如果你认真阅读浏览器系列的课程,是可以用 JavaScript 实现一个玩具浏览器 的,我非常希望学习课程的同学中能有人这样做,一旦你做到了,收益会非常大。这就是 我今天留给你的课外作业,你可以尝试一下。
「结尾说一下,极客时间-重学前端, buy buy buy, just take my money 😂」