01前端/浏览器 - 工作原理

浏览器的主要组件

  1. 用户界面 - 包括地址栏、后退 / 前进按钮、书签目录等,也就是你所看到的除了用来显示你所请求页面的主窗口之外的其他部分。
  2. 浏览器引擎 - 用来查询及操作渲染引擎的接口。
  3. 渲染引擎 - 用来显示请求的内容,例如,如果请求内容为 html,它负责解析 html 及 css,并将解析后的结果显示出来。
  4. 网络 - 用来完成网络调用,例如 http 请求,它具有平台无关的接口,可以在不同平台上工作。
  5. UI 后端 - 用来绘制类似组合选择框及对话框等基本组件,具有不特定于某个平台的通用接口,底层使用操作系统的用户接口。
  6. JS 解释器 - 用来解释执行 JS 代码。
  7. 数据存储 - 属于持久层,浏览器需要在硬盘中保存类似 Cookie 的各种数据,HTML5 定义了 web database 技术,这是一种轻量级完整的客户端存储技术

image

渲染引擎

本文所讨论的浏览器——Firefox、Chrome 和 Safari 是基于两种渲染引擎构建的,Firefox 使用 Geoko——Mozilla 自主研发的渲染引擎,Safari 和 Chrome 都使用 webkit。
Webkit 是一款开源渲染引擎,它本来是为 Linux 平台研发的,后来由 Apple 移植到 Mac 及 Windows 上,相关内容请参考http://webkit.org
下面是渲染引擎在取得内容之后的基本流程:
解析 html 以构建 dom 树 -> 构建 render 树 -> 布局 render 树 -> 绘制 render 树

image

渲染引擎开始解析 html,并将标签转化为内容树中的 dom 节点。接着,它解析外部 CSS 文件及 style 标签中的样式信息。这些样式信息以及 html 中的可见性指令将被用来构建另一棵树——render 树。
Render 树由一些包含有颜色和大小等属性的矩形组成,它们将被按照正确的顺序显示到屏幕上。
Render 树构建好了之后,将会执行布局过程,它将确定每个节点在屏幕上的确切坐标。再下一步就是绘制,即遍历 render 树,并使用 UI 后端层绘制每个节点。
值得注意的是,这个过程是逐步完成的,为了更好的用户体验,渲染引擎将会尽可能早的将内容呈现到屏幕上,并不会等到所有的 html 都解析完成之后再去构建和布局 render 树。它是解析完一部分内容就显示一部分内容,同时,可能还在通过网络下载其余内容。

解析 html 以构建 dom 树

HTML

解析:HTML 解析器的工作是将 html 标识解析为解析树。
文法:W3C 组织制定规范定义了 HTML 的词汇表和语法。
上下文无关文法:HTML 不能简单的用解析所需的上下文无关文法(Not a context free grammar)来定义。(HTML 接近 XML)

DOM

DOM 是输出的树,也就是解析树,是由 DOM 元素及属性节点组成的。DOM 是文档对象模型的缩写,它是 html 文档的对象表示,作为 html 元素的外部接口供 js 等调用。

树的根是 “document” 对象。
DOM 和标签基本是一一对应的关系,例如,如下的标签:

<html>
<body>
  <p>
    Hello DOM
  </p>
  <div><img src=”example.png” /></div>
</body>
</html>

将会被转换为下面的 DOM 树:
image

HTML 解析算法

初始状态为 “Data State”,当遇到 “<” 字符,状态变为 “Tag open state”,读取一个 a-z 的字符将产生一个开始标签符号,状态相应变为 “Tag name state”,一直保持这个状态直到读取到 “>”,每个字符都附加到这个符号名上,例子中创建的是一个 html 符号。
当读取到 “>”,当前的符号就完成了,此时,状态回到 “Data state”,“<body>” 重复这一处理过程。到这里,html 和 body 标签都识别出来了。现在,回到 “Data state”,读取 “Hello world” 中的字符 “H” 将创建并识别出一个字符符号,这里会为 “Hello world” 中的每个字符生成一个字符符号。
这样直到遇到 “</body>” 中的 “<”。现在,又回到了 “Tag open state”,读取下一个字符 “/” 将创建一个闭合标签符号,并且状态转移到 “Tag name state”,还是保持这一状态,直到遇到 “>”。然后,产生一个新的标签符号并回到 “Data state”。后面的 “</html>” 将和 “</body>” 一样处理。

<html>
  <body>
    Hello world
  </body>
</html>

image

DOM 树构建算法

首先是 “initial mode”,接收到号 html 符后将转换为 “before html” 模式,在这个模式中对这个符号进行再处理。此时,创建了一个 HTMLHtmlElement 元素,并将其附加到根 Document 对象上。
状态此时变为 “before head”,接收到 body 符号时,即使这里没有 head 符号,也将自动创建一个 HTMLHeadElement 元素并附加到树上。
现在,转到 “in head” 模式,然后是 “after head”。到这里,body 符号会被再次处理,将创建一个 HTMLBodyElement 并插入到树中,同时,转移到 “in body” 模式。
然后,接收到字符串 “Hello world” 的字符符号,第一个字符将导致创建并插入一个 text 节点,其他字符将附加到该节点。
接收到 body 结束符号时,转移到 “after body” 模式,接着接收到 html 结束符号,这个符号意味着转移到了 “after after body” 模式,当接收到文件结束符时,整个解析过程结束。

image

解析结束

文档状态将被设置为完成,同时触发一个 load 事件。

浏览器容错

你从来不会在一个 html 页面上看到 “无效语法” 这样的错误,浏览器修复了无效内容并继续工作。
以下面这段 html 为例:

<html>
  <mytag>
  </mytag>
  <div>
  <p>
  </div>
  Really lousy HTML
  </p>
</html>

这段 html 违反了很多规则(mytag 不是合法的标签,p 及 div 错误的嵌套等等),但是浏览器仍然可以没有任何怨言的继续显示,它在解析的过程中修复了 html 作者的错误。
浏览器都具有错误处理的能力,但是,另人惊讶的是,这并不是 html 最新规范的内容,就像书签及前进后退按钮一样,它只是浏览器长期发展的结果。一些比较知名的非法 html 结构,在许多站点中出现过,浏览器都试着以一种和其他浏览器一致的方式去修复。
Html5 规范定义了这方面的需求,webkit 在 html 解析类开始部分的注释中做了很好的总结。
解析器将符号化的输入解析为文档并创建文档,但不幸的是,我们必须处理很多没有很好格式化的 html 文档,至少要小心下面几种错误情况。

  1. 在未闭合的标签中添加明确禁止的元素。这种情况下,应该先将前一标签闭合
  2. 不能直接添加元素。有些人在写文档的时候会忘了中间一些标签(或者中间标签是可选的),比如 HTML HEAD BODY TR TD LI 等
  3. 想在一个行内元素中添加块状元素。关闭所有的行内元素,直到下一个更高的块状元素
  4. 如果这些都不行,就闭合当前标签直到可以添加该元素。

CSS 解析

CSS 是一种上下文无关文法

image

JS 解析

web 的模式是同步的,开发者希望解析到一个 script 标签时立即解析执行脚本,并阻塞文档的解析直到脚本执行完。如果脚本是外引的,则网络必须先请求到这个资源——这个过程也是同步的,会阻塞文档的解析直到资源被请求到。这个模式保持了很多年,并且在 html4 及 html5 中都特别指定了。开发者可以将脚本标识为 defer,以使其不阻塞文档解析,并在文档解析结束后执行。Html5 增加了标记脚本为异步的选项,以使脚本的解析执行使用另一个线程。

构建 render 树

Firefox 中,表述为一个监听 Dom 更新的监听器,将 frame 的创建委派给 Frame Constructor,这个构建器计算样式(参看样式计算)并创建一个 frame。
Webkit 中,计算样式并生成渲染对象的过程称为 attachment,每个 Dom 节点有一个 attach 方法,attachment 的过程是同步的,调用新节点的 attach 方法将节点插入到 Dom 树中。
处理 html 和 body 标签将构建渲染树的根,这个根渲染对象对应被 css 规范称为 containing block 的元素——包含了其他所有块元素的顶级块元素。它的大小就是 viewport——浏览器窗口的显示区域,Firefox 称它为 viewPortFrame,webkit 称为 RenderView,这个就是文档所指向的渲染对象,树中其他的部分都将作为一个插入的 Dom 节点被创建。

布局 render 树

当渲染对象被创建并添加到树中,它们并没有位置和大小,计算这些值的过程称为 layout 或 reflow。
Html 使用基于流的布局模型,意味着大部分时间,可以以单一的途径进行几何计算。流中靠后的元素并不会影响前面元素的几何特性,所以布局可以在文档中从右向左、自上而下的进行。也存在一些例外,比如 html tables。
坐标系统相对于根 frame,使用 top 和 left 坐标。
布局是一个递归的过程,由根渲染对象开始,它对应 html 文档元素,布局继续递归的通过一些或所有的 frame 层级,为每个需要几何信息的渲染对象进行计算。
根渲染对象的位置是 0,0,它的大小是 viewport-浏览器窗口的可见部分。
所有的渲染对象都有一个 layout 或 reflow 方法,每个渲染对象调用需要布局的 children 的 layout 方法。

Dirty bit 系统

为了不因为每个小变化都全部重新布局,浏览器使用一个 dirty bit 系统,一个渲染对象发生了变化或是被添加了,就标记它及它的 children 为 dirty——需要 layout。
存在两个标识——dirty 及 children are dirty,children are dirty 说明即使这个渲染对象可能没问题,但它至少有一个 child 需要 layout。

全局和增量 layout

当 layout 在整棵渲染树触发时,称为全局 layout,这可能在下面这些情况下发生:

  1. 一个全局的样式改变影响所有的渲染对象,比如字号的改变。
  2. 窗口 resize。
    layout 也可以是增量的,这样只有标志为 dirty 的渲染对象会重新布局(也将导致一些额外的布局)。
    增量 layout 会在渲染对象 dirty 时异步触发,例如,当网络接收到新的内容并添加到 Dom 树后,新的渲染对象会添加到渲染树中。

异步和同步 layout

增量 layout 的过程是异步的,Firefox 为增量 layout 生成了 reflow 队列,以及一个调度执行这些批处理命令。WebKit 也有一个计时器用来执行增量 layout-遍历树,为 dirty 状态的渲染对象重新布局。
另外,当脚本请求样式信息时,例如 “offsetHeight”,会同步的触发增量布局。
全局的 layout 一般都是同步触发。
有些时候,layout 会被作为一个初始 layout 之后的回调,比如滑动条的滑动。

优化

当一个 layout 因为 resize 或是渲染位置改变(并不是大小改变)而触发时,渲染对象的大小将会从缓存中读取,而不会重新计算。
一般情况下,如果只有子树发生改变,则 layout 并不从根开始。这种情况发生在,变化发生在元素自身并且不影响它周围元素,例如,将文本插入文本域(否则,每次击键都将触发从根开始的重排)。

layout 过程

layout 一般有下面这几个部分:

  1. parent 渲染对象决定它的宽度
  2. parent 渲染对象读取 chilidren,并:
    a. 放置 child 渲染对象(设置它的 x 和 y)
    b. 在需要时(它们当前为 dirty 或是处于全局 layout 或者其他原因)调用 child 渲染对象的 layout,这将计算 child 的高度
  3. parent 渲染对象使用 child 渲染对象的累积高度,以及 margin 和 padding 的高度来设置自己的高度-这将被 parent 渲染对象的 parent 使用
  4. 将 dirty 标识设置为 false
    Firefox 使用一个 “state” 对象(nsHTMLReflowState)做为参数去布局(firefox 称为 reflow),state 包含 parent 的宽度及其他内容。
    Firefox 布局的输出是一个 “metrics” 对象(nsHTMLReflowMetrics)。它包括渲染对象计算出的高度。

宽度计算

渲染对象的宽度使用容器的宽度、渲染对象样式中的宽度及 margin、border 进行计算。例如,下面这个 div 的宽度:
<div style="width: 30%"/>
webkit 中宽度的计算过程是(RenderBox 类的 calcWidth 方法):

  • 容器的宽度是容器的可用宽度和 0 中的最大值,这里的可用宽度为:clientWidth() - paddingLeft() - paddingRight()和 clientHeight 代表一个对象内部的不包括 border 和滑动条的大小
  • 元素的宽度指样式属性 width 的值,它可以通过计算容器的百分比得到一个绝对值
  • 加上水平方向上的 border 和 padding

到这里是最佳宽度的计算过程,现在计算宽度的最大值和最小值,如果最佳宽度大于最大宽度则使用最大宽度,如果小于最小宽度则使用最小宽度。
最后缓存这个值,当需要 layout 但宽度未改变时使用。

Line breaking

当一个渲染对象在布局过程中需要折行时,则暂停并告诉它的 parent 它需要折行,parent 将创建额外的渲染对象并调用它们的 layout。

绘制 render 树

绘制阶段,遍历渲染树并调用渲染对象的 paint 方法将它们的内容显示在屏幕上,绘制使用 UI 基础组件,这在 UI 的章节有更多的介绍。

全局和增量

和布局一样,绘制也可以是全局的——绘制完整的树——或增量的。在增量的绘制过程中,一些渲染对象以不影响整棵树的方式改变,改变的渲染对象使其在屏幕上的矩形区域失效,这将导致操作系统将其看作 dirty 区域,并产生一个 paint 事件,操作系统很巧妙的处理这个过程,并将多个区域合并为一个。Chrome 中,这个过程更复杂些,因为渲染对象在不同的进程中,而不是在主进程中。Chrome 在一定程度上模拟操作系统的行为,表现为监听事件并派发消息给渲染根,在树中查找到相关的渲染对象,重绘这个对象(往往还包括它的 children)。

绘制顺序

css2 定义了绘制过程的顺序 –http://www.w3.org/TR/CSS21/zindex.html。这个就是元素压入堆栈的顺序,这个顺序影响着绘制,堆栈从后向前进行绘制。
一个块渲染对象的堆栈顺序是:

  1. 背景色
  2. 背景图
  3. border
  4. children
  5. outline

Firefox 显示列表

Firefox 读取渲染树并为绘制的矩形创建一个显示列表,该列表以正确的绘制顺序包含这个矩形相关的渲染对象。
用这样的方法,可以使重绘时只需查找一次树,而不需要多次查找——绘制所有的背景、所有的图片、所有的 border 等等。
Firefox 优化了这个过程,它不添加会被隐藏的元素,比如元素完全在其他不透明元素下面。

WebKit 矩形存储

重绘前,WebKit 将旧的矩形保存为位图,然后只绘制新旧矩形的差集。

动态变化

浏览器总是试着以最小的动作响应一个变化,所以一个元素颜色的变化将只导致该元素的重绘,元素位置的变化将大致元素的布局和重绘,添加一个 Dom 节点,也会大致这个元素的布局和重绘。一些主要的变化,比如增加 html 元素的字号,将会导致缓存失效,从而引起整数的布局和重绘。

渲染引擎的线程

渲染引擎是单线程的,除了网络操作以外,几乎所有的事情都在单一的线程中处理,在 Firefox 和 Safari 中,这是浏览器的主线程,Chrome 中这是 tab 的主线程。
网络操作由几个并行线程执行,并行连接的个数是受限的(通常是 2-6 个)。

事件循环

浏览器主线程是一个事件循环,它被设计为无限循环以保持执行过程的可用,等待事件(例如 layout 和 paint 事件)并执行它们。下面是 Firefox 的主要事件循环代码。

while (!mExiting)
  NS_ProcessNextEvent(thread);

参考:
前端必读:浏览器内部工作原理
How Browsers Work: Behind the Scenes of Modern Web Browsers