JS进阶 - 浏览器工作原理
一、浏览器的结构
浏览器的主要组件为:
- 用户界面 - 包括地址栏、前进/后退按钮、书签菜单等。除了浏览器主窗口(显示页面),其他部分都属于用户界面。
- 浏览器引擎 - 在用户界面和渲染引擎之间传送指令。
- 渲染引擎 - 显示(渲染)请求的内容。如果请求的内容是 HTML,它就负责解析 HTML 和 CSS 内容,并将解析后的内容显示在屏幕上。
- 网络 - 用于网络调用,比如 HTTP 请求。其接口与平台无关,并为所有平台提供底层实现。
- 用户界面后端 - 用于绘制基本的窗口小部件,比如组合框和窗口。公开了与平台无关的通用接口,在底层使用操作系统的用户界面方法。
- JavaScript 解释器。用于解析和执行 JavaScript 代码。
- 数据存储。这是持久层。浏览器需要在硬盘上保存各种数据,例如 Cookie。新的 HTML 规范 (HTML5) 定义了“网络数据库”,这是一个完整(但是轻便)的浏览器内数据库。
二、渲染引擎
渲染引擎负责渲染——即渲染HTML/XML文档或者图片(通过插件可以渲染PDF等等)。渲染引擎有
- Chrome/Safari - Webkit
- Firefox - Gecko
- Edge - EdgeHTML(不在本文讨论范围)
(一)渲染主流程
浏览器从网络层获取请求的文档内容,然后开始渲染流程:
- 解析并开始构建 content tree(element --> DOM nodes),同时解析样式数据(外部CSS和style元素);
- 两者结合构建 render tree(渲染树包含带有视觉属性(如颜色和尺寸)的矩形们)
- 在渲染树创建后进入 Layout 阶段,给渲染树的每个节点设置在屏幕上的位置信息
- Paint 阶段,通过 UI backend 绘制 render tree 到屏幕。
注意,渲染过程是渐进式的。浏览器会尽早展示文档内容,即不会在所有HTML文档解析完成后才会去构建render tree,而是部分内容被解析和展示,并继续解析和展示剩下的。
对chrome而言,渲染的具体流程是
对firefox而言,
(二)处理脚本和样式表的顺序
-
script 是同步的
web模型一直是同步的,即网页作者希望引擎遇到
<script>
标签时可以立即解析并执行——停止解析HTML,执行脚本(如果是外部脚本,先下载)。可以用defer
属性指定脚本是异步的——不会停止文档解析,在文档解析完成后执行。 -
Speculative parsing(预解析)
当执行脚本时,其它线程会解析剩下的文档,找出里面的外部资源(script/style/img)来提前加载(可以并行加载)。这种解析只是去查找需要加载的外部资源,不会修改content tree。
所以我们可以看到多个外部资源并行下载。
-
样式
样式表有不同的模型。理论上,样式表不会更改 DOM tree,似乎没有必要等待样式表并停止文档解析。但有个问题,如果在文档解析阶段,脚本访问样式信息怎么办?Firefox会在脚本加载和解析阶段禁止所有的脚本;对于 WebKit 而言,仅当脚本尝试访问的样式属性可能受尚未加载的样式表影响时,它才会禁止该脚本。
这就是为什么推荐样式放在<head>
里而脚本放在<body>
底部。
(三)Render tree construction
构建 DOM tree的同时,浏览器还会构建另一个树:渲染树(render tree)。这是由可视化元素按照其显示顺序而组成的树,也是文档的可视化表示。它的作用是保证按照正确的顺序来绘制内容。
渲染树的每个节点(renderer)代表一个矩形区域——对应DOM元素的CSS Box。
renderer 和 DOM元素对应,但非一一对应。比如display:none
的元素没有对应的renderer;比如select
对应3个renderer(display area/drop down list box /button)。另外,根据css spec,一个inline元素只能包含一个block元素或者多个inline元素,如果不符规则,就会创建anonymous block renderer。
有些 renderers 与对应的 DOM 节点,在各自树中的位置不同。比如浮动定位和绝对定位的元素,它们在normal flow之外,放置在树的其它地方,并映射到真正的renderer,而放在原位的是placeholder renderer。
渐进式处理
WebKit 使用一个标记来表示是否所有的顶级样式表(包括 @imports)均已加载完毕。如果在attaching(DOM+CSSOM --> Render tree)过程中样式尚未完全加载,则使用占位符,并在文档中进行标注,等样式表加载完毕后再重新计算。
(四)Layout
renderer在创建完成并添加到render tree时,并不包含 位置和大小 信息。计算这些值的过程称为布局或重排(Layout/Reflow)。
HTML 采用基于流的布局模型,这意味着大多数情况下只要一次遍历就能计算出几何信息。处于流中靠后位置元素通常不会影响靠前位置元素的几何特征,因此布局可以按从左至右、从上至下的顺序遍历文档。
Dirty 位系统
为避免对所有细小更改都进行整体布局,浏览器采用了一种“dirty 位”系统。如果renderer有更改,或者其自身及其children被标注为“dirty”——则需要进行布局。
有两种标记:“dirty”和“children are dirty”。“children are dirty”表示renderer自身没有变化,但它的children需要布局。
全局布局和增量布局
全局布局是指触发了整个render tree的布局,触发原因可能包括:
- 影响所有renderers的全局样式更改,例如字体大小更改。
- 屏幕大小调整。
布局可以采用增量方式,也就是只对 dirty 的 renderer 进行布局(这样可能存在需要进行额外布局的弊端)。
当renderer为 dirty 时,触发增量布局(异步)。例如,当来自网络的额外内容添加到 DOM 树之后,新的renderer附加到了render tree中。
异步布局和同步布局
-
增量布局是异步执行的。
请求样式信息(如“offsetHeight”)的脚本可触发同步增量布局。
-
全局布局往往是同步执行的。
-
有时,当初始布局完成之后,如果一些属性(如滚动位置)发生变化,布局就会作为回调而触发。
优化
- 如果layout由 resize 或者 renderer 的位置变化触发,那么尺寸就无需再计算,直接从缓存获取;
- 有些情况如果只是子树变化(比如text更新),那么layout无需从root开始。
布局处理
布局过程通常如下:
-
父renderer确定自己的宽度。
-
父renderer依次处理子renderer,并且:
- 放置子renderer(设置 x,y 坐标)。
- 如果有必要,调用子renderer的布局(如果子renderer是 dirty 的,或者这是全局布局,或出于其他某些原因),这会计算子renderer的高度。
-
父renderer根据子renderer的累加高度以及边距和补白的高度来设置自身高度,此值也可供父renderer的父renderer使用。
-
将其 dirty 位设置为 false。
宽度计算
renderer宽度是根据容器块(container block)的宽度、renderer样式中的“width”属性以及边距和边框计算得出的。
换行
如果renderer在布局过程中需要换行,会立即停止布局,并告知其父renderer需要换行。父renderer会创建额外的renderer,并对其调用布局。
(五)Painting
在绘制阶段,会遍历render tree,并调用renderer的“paint”方法,将renderer的内容显示在屏幕上。绘制工作是使用用户界面基础组件(UI infrastructure component)完成的。
全局绘制和增量绘制
和布局一样,绘制也分为全局(绘制整个render tree)和增量两种。在增量绘制中,部分renderer发生了更改,但是不会影响整个树。更改后的renderer将其在屏幕上对应的矩形区域设为无效,这导致 OS 将其视为一块“dirty 区域”,并生成“paint”事件。OS 会很巧妙地将多个区域合并成一个。
绘制顺序
CSS2 defines the order of the painting process. This is actually the order in which the elements are stacked in the stacking contexts. This order affects painting since the stacks are painted from back to front.
block renderer的堆栈顺序是:
- 背景颜色
- 背景图片
- 边框
- children
- 轮廓(outline)
动态变化
在发生变化时,浏览器会尽可能做出最小的响应。比如元素的颜色改变后,只会对该元素进行重绘。元素的位置改变后,只会对该元素及其子元素(可能还有同级元素)进行布局和重绘。添加 DOM 节点后,会对该节点进行布局和重绘。
一些重大变化(例如增大“html”元素的字体)会导致缓存无效,使得整个render tree都会进行重新布局和绘制。
结合整个render tree构建和lauout,paint阶段,可以去思考怎么减少relayout/repaint。
渲染引擎的线程(The rendering engine's threads)
渲染引擎是单线程的。几乎所有操作(除了网络操作)都是在单线程中进行的。在 Firefox 和 Safari 中,该线程就是浏览器的主线程。而在 Chrome 浏览器中,该线程是tab进程的主线程。
网络操作可由多个线程并行执行。并行连接数是有限的(通常为 2~6 个)。
Event loop
The browser main thread is an event loop. It's an infinite loop that keeps the process alive. It waits for events (like layout and paint events) and processes them.
这里可配合 #21 阅读,结合上面一小段,可展开讨论下。
在浏览器的具体实现里,浏览器内核(渲染进程)是多线程的。其中最重要的线程有:
-
GUI线程,即本章所讲的渲染引擎线程,负责解析HTML/CSS,构建DOM tree和 render tree,布局和绘制等。
页面第一次展示,或者需要重绘(repaint)或由于某种操作引发回流(reflow)时,该线程运行。
-
JS线程,即JS引擎线程,负责解析JavaScript脚本,运行代码。JS引擎一直等待着任务队列中任务的到来,然后执行。
一个Tab页(渲染进程)中无论什么时候都只有一个JS线程在运行——JS是单线程的。
-
其它线程。
GUI线程和JS线程是互斥的(因为JavaScript可操纵DOM)。这就是为什么JS长时间运行会导致浏览器失去响应。
加微信:boan910227,备注:大前端;进前端进阶群;