现代浏览器背后发生了什么

我们几乎每天都在使用浏览器。

可能是从PC端,可能是在移动端。

你每天看的微信公众号,包括这篇文章,背后都离不开浏览器的默默工作。

之前有篇文章,浏览器是如何工作的,主要描述了浏览器如何将网络请求的返回内容,渲染到屏幕上。

这一次我们将尝试从更加宏观的视角,解释浏览器在稳定性、性能以及安全性等多个方面,付出的种种努力。

主要分成四个部分:

  1. 多进程架构
  2. 导航
  3. 渲染的过程
  4. 如何处理输入

1. 多进程架构

从硬件上来看,电脑也好,手机也罢,核心的器件有两个,CPU(Central Processing Unit)和GPU(Graphics Processing Unit)。

其中大部分的处理过程都在CPU,但针对某些特殊场景,例如图形绘制,密集计算等,可以交给GPU来加速。

从软件上来看,程序运行也有两个概念:进程(process)和线程(thread)。当你打开一个程序的时候,进程会被创建出来,进程里会有一个主线程,同时还可以创建更多其他的工作线程,但这并不是必须的。

进程可以理解为资源的容器,其中不仅有线程,而且有不同程序间隔离的存储空间。同一进程内的多个线程,资源是共享的,但对于不同的进程而言,资源存在隔离,如果需要交互,就需要IPC(Inter Process Communication)的机制。

这也就引出不同的浏览器架构:多线程架构和多进程架构

这两种架构方式各有优劣,但对于现代浏览器而言,基本选择了后一种架构方式。我们这里就以经典的Chromium多进程架构为例,尝试理解其背后的原因。

对于Chromium而言,Brower进程是其主进程,主进程会派生出其他不同类型进程:

  • GPU进程,负责加速计算
  • Utility进程
  • Plugin进程,插件运行在独立的进程里
  • Render进程,负责解析和渲染网页

不同类型的进程各司其职,进程间通过IPC进行通信。

多进程架构尤其缺点,比如资源占用较大,另外就是进程间通信成本较高。

但其好处也很明显。

进程与进程之间是独立的,其中一个进程崩溃不会影响到其他进程,这就提升了整个程序的健壮性(robust)。

例如,不同的Tab页是由不同的Render进程负责,其中一个Tab页停止响应,其他的Tab页依然可以正常工作。

但是为了避免创建过多的Render进程,Chromium还提供了Site Isolation的特性,同一个站点打开的Tab,可能存放于同一个Render进程。

另一个好处是多进程架构更加安全。除了Browser进程有较大权限外,像Render进程是运行在一个Sandbox环境中,连文件访问的权限都没有,这样就避免了恶意脚本带来的潜在安全隐患。

2. 导航

导航(Navigation),差不多是浏览器最核心的部分,牵扯到多个进程之间的交互,共同完成,从用户在地址栏键入URL开始,到在屏幕上呈现出网页的全部过程。

当用户输入URL的时候,此时Browser进程中的UI线程负责处理用户输入。

当导航开始时候,网络线程会执行合适的协议(Protocol),比如查询DNS,建立TLS连接等。如果遇到服务端重定向,网络线程会重新发起一次URL请求。

当接收到返回的数据后,网络线程回去查看Header,如果是HTML文件,数据会被转发到Render进程,如果是zip或者其他文件,数据会被转发给下载管理器。

也会有一些安全检查,如果域(Domain)和返回数据和已知恶意站点匹配,网络线程也会发出警告。

一旦通过检查,网络线程就会告诉UI线程数据准备好了,UI线程此时就会寻找一个合适的Render进程负责数据展示。

因为网络请求的过程一般比较耗时,为了加速这个过程,UI线程会尝试在请求网络地同时并行查找或启动Render进程。

当数据和Render进程都准备好后,一个IPC会从Browser进程发给Render进程。一旦这次Commit被确认,整个导航的过程完成。进入到加载阶段。

当Render进程接收到数据并渲染完成后,会返回一个IPC给Brower进程,告诉Browser进程渲染完成。

上面的还只是最简单的一种导航情况。

如果用户想要导航到另一个URL,在新的导航开始前,会先检查是否有beforeunload事件,如果需要的话会被执行。这种设计非常贴心,因为重新导航到另一个URL,当前网页的内容会被清理,如果有form这类组件,数据丢失无疑会影响用户体验。

这时候浏览器提供的这个机制,就给了开发者干预下一次导航的机会。

如果被允许,导航的过程与上述的流程基本一致。只不过旧的Render进程会执行unload事件,执行清理的操作。

传统的浏览器,一定要online才能得到网页内容,这也就意味着,网站没有办法离线运行。

但是现代浏览器提供了一种叫做Service Worker的特性,可以将缓存的内容作为网络请求的内容返回,这样网站就可以暂时地离线运行了。

3. 渲染的过程

关于渲染的过程,在之前的文章浏览器是如何工作的里有过一些涉及,为了保证体系的完整性,这里会尝试从另一个角度,从顶层观察整个过程。

Render进程是个非常重要的进程,发生在网页里的几乎所有事情它都要参与:主线程会处理大部分和用户相关的代码;当你使用Web Worker和Service Worker的时候,部分JavaScript被工作线程执行;合成器线程负责高效的渲染页面。

Render进程将HTML的文本内容,转换为在屏幕显示的网页,总共用了四步:

  1. 解析(Parsing)
  2. 布局(Layout)
  3. 绘制(Paint)
  4. 合成(Composition)
3.1 解析

解析的第一步是构建DOM(Document Object Model)树。

DOM是浏览器内部,对于网页内容的数据组织形式。前端开发者借助JavaScript也可对其进行操作。

DOM的解析方式有相应的HTML标准,这里不再赘述,而且多数情况下也不需要有很深入的了解。

浏览器再解析HTML的时候,有时会遇到很多外部资源,例如图片、CSS和JavaScript脚本等。这些资源同样需要从网络或者缓存中获取。

主线程可以顺序请求,但为了加速这个过程,也会有些并行处理的机制,例如preload scanner

即便如此,<script>标签还是会阻塞整个解析过程,因为脚本里的内容可能会修改整个文档。但现代浏览器提供了asyncdefer关键字,允许开发者提供一些提示,以便更好地加载资源。

仅仅有DOM树是不够的,因为它缺乏样式信息。CSS在此时参与进来,提供每个DOM节点一定的样式信息,例如长、宽等。

3.2 绘制

有了上述信息,Render进程就进入到绘制阶段:为每个元素确定集合形状。

此时主线程会遍历整个DOM,同时计算属性,并创建Layout树,不仅包含x,y等坐标信息,也包含边界框尺寸等。

3.3 绘制

但是有了这些信息还不够,因为元素之间是有重叠的,例如z-index属性。

主线程会遍历Layout树,并创建绘制记录。这就像一条流水线,从头到尾跑一遍记录,就会得到绘制的结果。

在这条流水线上,每一步都会使用前一步的结果,因此只要中间某些环节稍微有些更新,这种更新就会蔓延到后面,导致更新的成本很高。

这种情况对于动画这种场景尤其明显。如果使用JavaScript生成动画,其在执行的时候,更新会被阻塞,进而导致网页卡顿,此时可借助requestAnimationFrame()避免此种情况,可见Optimize JavaScript execution

3.4 合成

将上面的所有信息转换为屏幕上的像素,这个过程叫做栅格化。

合成(Composition)是栅格化的一种手段。将页面的各个部分分成图层,分别栅格化,并在合成器线程中合成为页面。

DevTools可以帮助查看网页是如何分割为不同的层的,可见Layers panel

由此可见,对用户来说看似简单的通过点击URL,就能得到酷炫的网页,看似简单,背后的工作繁琐且复杂。

幸运地是,浏览器把这些过程封装得很好,而且还在不断地迭代和优化,尽可能利用有限资源做更多的事。

4. 如何处理输入

对于浏览器而言,最重要的时候就是响应用户的请求,返回用户需要的结果。

这个过程要尽可能高效,如何用户输入半天得不到响应,用户体验无疑极差。

因此第四个部分我们来看看,浏览器是如何流畅地完成这些事情的。

从浏览器视角看,所有的用户行为都可以理解为输入事件,鼠标滑动,触摸等。这些事件首先会被Browser进程捕获,然后传递给Render进程,接着Render进程寻找到响应事件的目标后,执行注册好的事件监听器。

在Web开发中,最常见的事件处理模式是事件委托。例如:

document.body.addEventListener('touchstart', event => {
    if (event.target === area) {
        event.preventDefault();
    }
});

通常来说,事件监听的目标应该足够小,因为事件冒泡机制的存在,较大的监听目标会导致频繁的事件触发,降低性能。但是通过传入passive: true可以在一定程度上提高性能,详见:Improving scrolling performance with passive listeners.

document.body.addEventListener('touchstart', event => {
    if (event.target === area) {
        event.preventDefault()
    }
 }, {passive: true});

从用户角度而言,有些事件是连续的,比如touchmove。但这对于浏览器将会带来较大的处理负担,因此为了降低主线程的处理负担,Chromium会合并连续的事件。对于大多数Web应用而言,合并事件可以提供更好的用户体验。

但也有例外。当你正在构建诸如绘图程序之类的应用,合并事件则可能导致绘制精度的丢失。

此时可以使用getCoalescedEvents获取这些合并事件的信息。

window.addEventListener('pointermove', event => {
    const events = event.getCoalescedEvents();
    for (let event of events) {
        const x = event.pageX;
        const y = event.pageY;
        // draw a line using x and y coordinates.
    }
});

除此之外,为了提高Web性能,你也可以关注Lighthouse并学习how to measure your site's performance

5. 总结

通常对于前端开发者而言,关注代码的组织和功能实现,是最重要的。

但这并非意味着你不需要了解底层浏览器的运作逻辑。因为现代浏览器,一直在持续地将新的功能,优化机制引入,以提升用户体验。

所以,当你按照浏览器喜欢的方式编写代码的时候,你也将得到最棒的效果。

(完)

参考

在 GitHub 上编辑本页面 更新时间: 10/13/2023, 12:55:21 AM