前言
现在正值秋招,金九银十已经到了银十的末尾,不知道各位25届的朋友是否已经有了心仪的offer,反正我是0offer,只能说非常惨淡。所以还是好好的在沉淀自己,从平常的点滴中慢慢积累,准备厚积薄发,这里用我最喜欢的艺人的一句话就是:那些看似不起波澜的日复一日,终会在未来的某一天让你看到坚持的意义!
相信大家面试肯定都会被问到这个问题,从输入url到页面渲染这个过程中会发生什么。很多人可能直接背的八股,就只有粗略的大概流程:
DNS解析 -> 建立tcp连接 -> 发送请求 -> 响应 -> html解析 -> css解析 -> 合成render树 -> 计算页面布局 -> 绘制 -> js执行
大概五分钟左右就讲完了,但其实里面的逻辑比这个要复杂很多,真的要讲的比较细其实是能讲15分钟左右的,而且还可以体现知识的深度。
前置知识
在了解浏览器的渲染流程之前,要先知道浏览器的基本架构。以Chrome为例:最上层是浏览器进程,负责协调承担各项工作的其他进程,比如实用程序进程、渲染器进程、GPU进程、插件进程等。
- 浏览器进程:控制浏览器这个应用的chrome(主框架)部分,包括地址栏、书签、前进/后退按钮等,同时也会处理浏览器不可见的高权限任务,如发送网络请求、访问文件。
- 渲染器进程:负责在标签页中显示网站及处理事件。
- 插件进程:控制网站用到的所有插件。
- GPU进程:在独立的进程中处理GPU任务。之所以放到独立的进程,是因为GPU要处理来自多个应用的请求,但要在同一个界面(显示器界面)上绘制图形。
当然,还有其他进程,比如扩展进程、实用程序进程。要知道你的Chrome当前打开了多少个进程,点击右上角的按钮,选择“更多工具”,再选择“任务管理器”。
导航
从请求网页到浏览器准备渲染网页的过程,叫做导航。按照上面提到流程,也就是开始解析html之前的这个过程就叫做导航。导航涉及浏览器进程与线程间为显示网页而通信。一切从用户在浏览器中输入一个URL开始。输入URL之后,浏览器会通过互联网获取数据并显示网页。
- 第一步:处理输入。UI线程会判断用户输入的是查询字符串还是URL。因为Chrome的地址栏同时也是搜索框。
- 第二步:开始导航。如果输入的是URL,UI线程会通知网络线程发起网络调用,获取网站内容。此时标签页左端显示旋转图标,网络线程进行查询、建立TLS连接(对于HTTPS)。网络线程可能收到服务器的重定向头部,如HTTP 301。此时网络线程会跟UI线程沟通,告诉它服务器要求重定向。然后,再发起对另一个URL的请求。
- 第三步:读取响应。服务器返回的响应体到来之后,网络线程会检查接收到的前几个字节。响应的Content-Type头部应该包含数据类型,如果没有这个字段,则需要MIME类型嗅探。如果响应是HTML文件,那下一步就是把数据交给渲染器进程。但如果是一个zip文件或其他文件,那就意味着是一个下载请求,需要把数据传给下载管理器。此时也是“安全浏览”检查的环节。如果域名和响应数据匹配已知的恶意网站,网络线程会显示警告页。此外,CORB检查也会执行,以确保敏感的跨站点数据不会发送给渲染器进程。
- 第四步:联系渲染器进程。所有查检完毕,网络线程确认浏览器可以导航到用户请求的网站,于是会通知UI线程数据已经准备好了。UI线程会联系渲染器进程渲染网页。由于网络请求可能要花几百毫秒才能拿到响应,这里还会应用一个优化策略。第二步UI线程要求网络线程发送请求后,已经知道可能要导航到哪个网站去了。因此在发送网络请求的同时,UI线程会提前联系或并行启动一个渲染器进程。这样在网络线程收到数据后,就已经有渲染器进程原地待命了。如果发生了重定向,这个待命进程可能用不上,而是换作其他进程去处理。
- 第五步:提交导航。数据和渲染器进程都有了,就可以通过IPC从浏览器进程向渲染器进程提交导航。渲染器进程也会同时接收到不间断的HTML数据流。当浏览器进程收到渲染器进程的确认消息后,导航完成,文档加载阶段开始。此时,地址栏会更新,安全指示图标和网站设置UI也会反映新页面的信息。当前标签页面的会话历史会更新,后退/前进按钮起作用。为便于标签页/会话在关闭标签页或窗口后恢复,会话历史会写入磁盘。
- 最后一步:初始加载完成。提交导航之后,渲染器进程将负责加载资源和渲染页面(具体细节后面介绍)。而在“完成”渲染后(在所有iframe中的onload事件触发且执行完成后),渲染器进程会通过IPC给浏览器进程发送一个消息。此时,UI线程停止标签页上的旋转图标。
渲染
当导航结束之后,网络线程会去通知渲染器进程,告诉它可以开始渲染页面了,这个时候浏览器进程就要开始准备工作了。染是渲染器进程内部的工作,涉及Web性能的诸多方面,标签页中的一切都由渲染器进程负责处理。渲染的大致流程前面已经提过,就不再赘述。
html解析
因为浏览器无法直接理解和使用html,所以需要将html转换为浏览器能够理解的结构——DOM树。 在渲染引擎内部,有一个叫 HTML 解析器(HTMLParser)的模块,它的职责就是负责将 HTML 字节流转换为 DOM 结构。
-
字节流转换为字符流: HTML文档以字节流的形式传输,浏览器首先将字节流转换为字符流。这通常涉及到字符编码的处理,例如UTF-8。
-
词法分析(Lexical Analysis): 浏览器对字符流进行词法分析,将文档划分为一系列的标记(tokens)。标记是HTML文档中的最小单元,包括标签、属性、文本等。
-
节点转换: 生成tokens后会去递归遍历这些token,浏览器会识别HTML标签,将它们转换为DOM节点(node),并确定它们之间的父子关系。这个过程也包括处理文本节点、属性等。
-
语法分析(Syntax Analysis): 浏览器进行语法分析,根据HTML的语法规则将node组织成树状结构,即文档对象模型(DOM)树。
css解析
与HTML文本一样,渲染引擎也没法直接理解CSS文本,因此渲染引擎会将其转换为其能理解的结构——styleSheets。
css的解析规则和html解析几乎一样,只是最后生成的是css规则树。
针对styleSheets,结合CSS的继承、优先级层叠等规则,渲染引擎最终生成如下CSS规则树:
合成render树
现在DOM树和CSS规则树都有了,接下来就是将两者结合起来,对页面进行整体的布局。
DOM树只是描述了源码中HTML的结构,但其中许多元素并不需要展示在画面中,比如dispaly:none,也有一些不存在DOM树中但需要显示在页面上的元素(比如伪元素),因此在显示之前需要遍历DOM树中的所有节点,忽略掉不可见元素,添加不存在DOM树中但需要显示的的内容,最终生成一棵只包含可见元素的Render树。
计算页面布局
到这一步,渲染器进程知道了文档的结构,也知道了每个节点的样式。但基于这些信息仍然不足以渲染页面。比如,你通过电话跟朋友说:“画一个红色的大圆形,还有一个蓝色的小方形”,你的朋友仍然不知道该画成什么样。
布局,就是要找到元素间的几何位置关系。这个过程非常复杂,确定页面的布局要考虑很多很多因素,并不简单。比如,字体大小、文本换行都会影响段落的形状,进而影响后续段落的布局。CSS可让元素浮动到一边、隐藏溢出边界的内容、改变文本显示方向。可想而知,布局阶段的任务是非常艰巨的。
这个视频提到了chorme布局的一些流程,感兴趣的可以看一看 www.youtube.com/watch?v=Y5X…
绘制
这个才是今天的重头戏,这里详细的描述了浏览器的渲染原理。有了DOM、样式和布局,仍然不足以渲染页面。还要解决先画什么后画什么,即绘制顺序的问题。渲染是一个流水线作业(pipeline):前一道工序的输出就是下一道工序的输入。这意味着如果布局树有变化,则相应的绘制记录也要重新生成。 如果元素有动画,浏览器就需要每帧运行一次渲染流水线。目前显示器的刷新率为每秒60次(60fps),也就是说每秒更新60帧,动画会显得很流畅。如果中间缺了帧,那页面看起来就会“闪眼睛”。
分层
为了更好的解决这些问题,浏览器的渲染引擎采用了分层机制。
每个DOM元素会有自己的布局信息Layout Object, 根据其布局信息的层级等关系,某些Layout Object会拥有共同的渲染层Paint Layer,某些Paint Layer又会拥有共同的合成层Composite Layer(Graphic Layers)。
如上图,DOM 树中得每个 Node 节点都有一个对应的 LayoutObject;拥有相同的坐标空间的 LayoutObjects,属于同一个渲染层(PaintLayer)。渲染层产生的最普遍条件是“层叠上下文”。
这个是mdn对层叠上下文的解释:
developer.mozilla.org/zh-CN/docs/…
合成
知道各个元素的绘制顺序之后就可以开始绘制页面了,页面的绘制不是交给主线程去操作的,而是交给合成器线程。在页面的绘制过程中还会有一个栅格化的操作,把元素的样式信息转换为屏幕上的像素的这个过程就叫做栅格化。
如果直接一次性把这个页面全部绘制出来会产生很大的性能开销,所以合成器线程会先对页面进行分块,把当前出现在可视窗口的这些图块交给栅格化线程进行栅格化,转换成位图,然后合成器线程会将这些栅格化后的小图片进行合成,合成一个关键帧叫做合成器帧,然后把合成器帧交给GPU,由GPU进行处理最终显示在屏幕上。
交互
最后,我们看一看合成器如何处理用户交互。说到用户交互,有人可能只会想到在文本框里打字或点击鼠标。实际上,从浏览器的角度看,交互意味着来自用户的任何输入:鼠标滚轮转动、触摸屏幕、鼠标悬停等这些都是交互。
当用户交互比如触摸事件发生时,浏览器进程首先接收到该手势。但是,浏览器进程仅仅知道手势发生在哪里,因为标签页中的内容是渲染器进程处理。因此浏览器进程会把事件类型(如touchstart)及其坐标发送给渲染器进程。渲染器进程会处理这个事件,即根据事件目标来运行注册的监听程序。
具体来说,输入事件是由渲染器进程中的合成器线程处理的。如前所述,如果页面上没有注册事件监听程序,那合成器线程可以完全独立于主线程生成新的合成器帧。但是如果页面上注册了事件监听程序呢?此时合成器线程怎么知道是否有事件要处理?
这就涉及一个概念,叫“非快速滚动区”(non-fast scrollable region)。我们知道,运行JavaScript是主线程的活儿。在页面合成后,合成器线程会给附加了事件处理程序的页面区域打上“Non-Fast Scrollable Region”的记号。有了这个记号,合成器线程就可以在该区域发生事件时通过ITC通信,把事件发送给主线程。如果事件发生在这个区域外,那合成器线程会继续合成新帧而不会等待主线程。
这里有个值得注意的点,很多人喜欢使用事件委托来注册处理程序。这是利用事件冒泡原理,把事件注册到最外层元素上,然后再根据事件目标决定是否执行任务。一个事件处理程序就可以面向多个元素,这种高效的写法因此很流行。然而,从浏览器的角度来看,这样会导致整个页面被标记为“非快速滚动区”。这也就意味着,即便事件发生在那些不需要处理的元素上,合成器线程也要每次都跟主线程沟通,并等待它的回应。于是,合成器线程平滑滚动的优点就被抵消了。所以能不用这种写法就尽量不用。
总结
如果之前对浏览器的渲染原理不是很了解的小伙伴,看完这篇文章之后肯定会醍醐灌顶,仿佛七经八脉已经被打通了一脉。之前跨部门转正三面挂了,所以我的宇宙厂实习也到此结束了,失败了也不一定是坏事,最起码找到了自己的不足,后续还是要多去学习沉淀沉淀。不过当时给我提了一个意见就是觉得我的文章大部分都是偏应用,对知识理解的深度不够,所以后续我的文章会以原理为主,慢慢的也会去把之前的文章重新做个修改。
秋招即将结束,如果没拿到offer的小伙伴也别着急,有时候后退也是一种前进,人不是总要往高处走,人要往四处走,就像我一样,我也即将要往四处走走了。