一文看懂Chrome浏览器工做原理

前言

本文是笔者对Mario Kosaka写的inside look at modern web browser系列文章的翻译。这里的翻译不是指直译,而是结合我的的理解将做者想表达的意思表达出来,并且会尽可能补充一些相关的内容来帮助你们更好地理解。javascript

这篇文章是我对以前发表的窥探浏览器内部原理系列文章的一个整合,你们若是以为内容太多能够按篇查看之前的文章:css

CPU,GPU,内存和多进程架构

在本篇文章中,我将会从Chrome浏览器的高层次架构(high-level architecture)开始提及,一直深刻讲到页面渲染流水线(rendering pipeline)的具体细节。若是你想知道浏览器是怎么把你编写的代码转变成一个可用的网站,或者你不知道为何一些特定的代码写法能够提升网站的性能的,那你就来对地方了,这篇文章就是为你准备的。html

首先咱们先了解一些关键的计算机术语以及Chrome浏览器的多进程架构html5

计算机的核心 - CPU和GPU

要想理解浏览器的运行环境,咱们先要搞明白一些计算机组件以及它们的做用。java

CPU

首先咱们要说的是计算机的大脑 - CPU(Central Processing Unit)。CPU是计算机里面的一块芯片,上面有一个或者多个核心(core)。咱们能够把CPU的一个核心(core)比喻成一个办公室工人,他功能强大,上知天文下知地理,琴棋书画无所不能,它能够串行地一件接着一件处理交给它的任务。好久以前的时候大多数CPU只有一个核心,不过在如今的硬件设备上CPU一般会有多个核心,由于多核心CPU能够大大提升手机和电脑的运算能力。 git

四个CPU核心愉快地在各自工位上一个接着一个地处理交给它们的任务github

GPU

图形处理器 - 或者说GPU(Graphics Processing Unit)是计算机的另一个重要组成部分。和功能强大的CPU核心不同的是,单个GPU核心只能处理一些简单的任务,不过它胜在数量多,单片GPU上会有不少不少的核心能够同时工做,也就是说它的并行计算能力是很是强的。图形处理器(GPU)顾名思义一开始就是专门用来处理图形的,因此在说到图形使用GPU(using)或者GPU支持(backed)时,人们就会联想到图形快速渲染或者流畅的用户体验相关的概念。最近几年来,随着GPU加速概念的流行,在GPU上单独进行的计算也变得愈来愈多了。 web

每一个GPU核心手里只有一个扳手,这就说明它的能力是很是有限的,但是它们人多啊!chrome

当你在手机或者电脑上打开某个应用程序的时候,背后实际上是CPU和GPU支撑着这个应用程序的运行。一般来讲,你的应用要经过操做系统提供的一些机制才能跑在CPU和GPU上面。 canvas

计算机的三层架构,最下层是硬件机器,操做系统夹在中间,最上层是运行的应用

在进程和线程上执行程序

在深刻到浏览器的架构以前咱们还得了解一下进程(process)和线程(thread)的相关概念。进程能够当作正在被执行的应用程序(executing program)。而线程是跑在进程里面的,一个进程里面可能有一个或者多个线程,这些线程能够执行任何一部分应用程序的代码。

进程就像一个大鱼缸,而线程就是浴缸里面畅游的鱼儿

当你启动一个应用程序的时候,操做系统会为这个程序建立一个进程同时还为这个进程分配一片私有的内存空间,这片空间会被用来存储全部程序相关的数据和状态。当你关闭这个程序的时候,这个程序对应的进程也会随之消失,进程对应的内存空间也会被操做系统释放掉。

进程使用系统分配的内存空间去存储应用的数据

有时候为了知足功能的须要,建立的进程会叫系统建立另一些进程去处理其它任务,不过新建的进程会拥有全新的独立的内存空间而不是和原来的进程共用内存空间。若是这些进程须要通讯,它们要经过IPC机制(Inter Process Communication)来进行。不少应用程序都会采起这种多进程的方式来工做,由于进程和进程之间是互相独立的它们互不影响,换句话来讲,若是其中一个工做进程(worker process)挂掉了其余进程不会受到影响,并且挂掉的进程还能够重启。

不一样的进程经过IPC来通讯

浏览器架构

那么浏览器是怎么使用进程和线程来工做的呢?其实大概能够分为两种架构,一种是单进程架构,也就是只启动一个进程,这个进程里面有多个线程工做。第二种是多进程架构,浏览器会启动多个进程,每一个进程里面有多个线程,不一样进程经过IPC进行通讯。

单进程和多进程浏览器的架构图

上面的图表架构其实包含了浏览器架构的具体实现了,在现实中其实并无一个你们都遵循的浏览器实现标准,因此不一样浏览器的实现方式可能会彻底不同。

为了更好地在本系列文章中展开论述,咱们主要讨论最新的Chrome浏览器架构,它采用的是多进程架构,如下是架构图:

Chrome的多进程架构图,多个渲染进程的卡片(render process)是用来代表Chrome会为每个tab建立一个渲染进程。

Chrome浏览器会有一个浏览器进程(browser process),这个进程会和其余进程一块儿协做来实现浏览器的功能。对于渲染进程(renderer process),Chrome会尽量为每个tab甚至是页面里面的每个iframe都分配一个单独的进程。

各个进程如何分工合做呢?

如下是各个进程具体负责的工做内容:

进程 负责的工做
Browser 负责浏览器的“Chrome”部分, 包括导航栏,书签, 前进和后退按钮。同时这个进程还会控制那些咱们看不见的部分,包括网络请求的发送以及文件的读写。
Renderer 负责tab内和网页展现相关的全部工做。
Plugin 控制网页使用的全部插件,例如flash插件。
GPU 负责独立于其它进程的GPU任务。它之因此被独立为一个进程是由于它要处理来自于不一样tab的渲染请求并把它在同一个界面上画出来。

不一样的进程负责浏览器不一样部分的界面内容

除了上面列出来的进程,Chrome还有不少其余进程在工做,例如扩展进程(Extension Process)和工具进程(utility process)。若是你想看一下你的Chrome浏览器如今有多少个进程在跑能够点击浏览器右上角的更多按钮,选择更多工具和任务管理器:

在弹出的窗口里面你会看到正在工做的进程列表,以及每一个进程使用的CPU和内存情况。

Chrome多进程架构的好处

那么为何Chrome会采起多进程架构工做呢?

其中一个好处是多进程可使浏览器具备很好的容错性。对于大多数简单的情景来讲,Chrome会为每一个tab单独分配一个属于它们的渲染进程(render process)。举个例子,假如你有三个tab,你就会有三个独立的渲染进程。当其中一个tab的崩溃时,你能够随时关闭这个tab而且其余tab不受到影响。但是若是全部的tab都跑在同一个进程的话,它们就会有连带关系,一个挂所有挂。

不一样的tab会有不一样的渲染进程来负责

Chrome采用多进程架构的另一个好处就是能够提供安全性和沙盒性(sanboxing)。由于操做系统能够提供方法让你限制每一个进程拥有的能力,因此浏览器可让某些进程不具有某些特定的功能。例如,因为tab渲染进程可能会处理来自用户的随机输入,因此Chrome限制了它们对系统文件随机读写的能力。

不过多进程架构也有它很差的地方,那就是进程的内存消耗。因为每一个进程都有各自独立的内存空间,因此它们不能像存在于同一个进程的线程那样共用内存空间,这就形成了一些基础的架构(例如V8 JavaScript引擎)会在不一样进程的内存空间同时存在的问题,这些重复的内容会消耗更多的内存。因此为了节省内存,Chrome会限制被启动的进程数目,当进程数达到必定的界限后,Chrome会将访问同一个网站的tab都放在一个进程里面跑

节省更多的内存 - Chrome的服务化

一样的优化方法也能够被使用在浏览器进程(browser process)上面。Chrome浏览器的架构正在发生一些改变,目的是将和浏览器自己(Chrome)相关的部分拆分为一个个不一样的服务,服务化以后,这些功能既能够放在不一样的进程里面运行也能够合并为一个单独的进程运行。

这样作的主要缘由是让Chrome在不一样性能的硬件上有不一样的表现。当Chrome运行在一些性能比较好的硬件时,浏览器进程相关的服务会被放在不一样的进程运行以提升系统的稳定性。相反若是硬件性能很差,这些服务就会被放在同一个进程里面执行来减小内存的占用。其实在此次架构变化以前,Chrome在Android上面已经开始采起相似的作法了。

Chrome将浏览器相关的服务放在同一个进程里面运行和放在不一样的进程运行的区别

单帧渲染进程 - 网站隔离(Site Isolation)

网站隔离(Site Isolation)是最近Chrome浏览器启动的功能,这个功能会为网站内不一样站点的iframe分配一个独立的渲染进程。以前说过Chrome会为每一个tab分配一个单独的渲染进程,但是若是一个tab只有一个进程的话不一样站点的iframe都会跑在这个进程里面,这也意味着它们会共享内存,这就有可能会破坏同源策略。同源策略是浏览器最核心的安全模型,它能够禁止网站在未经赞成的状况下去获取另一个站点的数据,所以绕过同源策略是不少安全攻击的主要目的。而进程隔离(proces isolation)是隔离网站最好最有效的办法了。再加上CPU存在Meltdown和Spectre的隐患,网站隔离变得势在必行。所以在Chrome 67版本以后,桌面版的Chrome会默认开启网站隔离功能,这样每个跨站点的iframe都会拥有一个独立的渲染进程。

网站隔离功能会让跨站的iframe拥有独立的进程

网站隔离技术汇聚了咱们工程师好几年的研发努力,它其实远远没有想象中那样只是为不一样站点的iframe分配一个独立的渲染进程那么简单,由于它从根本上改变了各个iframe之间的通讯方式。网站隔离后,对于有iframe的网站,当用户打开右边的devtool时,Chrome浏览器其实要作不少幕后工做才能让开发者感受不出这和以前的有什么区别,这实际上是很难实现的。对于一些很简单的功能,例如在devtool里面用Ctrl + F键在页面搜索某个关键词,Chrome都要遍历多个渲染进程去完成。因此咱们的浏览器工程师在网站隔离这个功能发布后都感叹这是一个里程碑式的成就。

导航的时候都发生了什么

咱们探讨了浏览器高层次的架构设计以及多进程架构的带来的好处。同时咱们还讨论了服务化和网站隔离这些和浏览器多进程架构息息相关的技术。接下来咱们要开始深刻了解这些进程和线程是如何呈现咱们的网站页面的了。

让咱们来看一个用户浏览网页最简单的情景:你在浏览器导航栏里面输入一个URL而后按下回车键,浏览器接着会从互联网上获取相关的数据并把网页展现出来。在本篇文章中,咱们将会重点关注这个简单场景中网站数据请求以及浏览器在呈现网页以前作的准备工做 - 也就是导航(navigation)的过程。

一切都从浏览器进程开始

上面的文章中提到,浏览器中tab外面发生的一切都是由浏览器进程(browser process)控制的。浏览器进程有不少负责不一样工做的线程(worker thread),其中包括绘制浏览器顶部按钮和导航栏输入框等组件的UI线程(UI thread)、管理网络请求的网络线程(network thread)、以及控制文件读写的存储线程(storage thread)等。当你在导航栏里面输入一个URL的时候,其实就是UI线程在处理你的输入。

UI,网络和存储线程都是属于浏览器进程的

一次简单的导航

第一步:处理输入

当用户开始在导航栏上面输入内容的时候,UI线程(UI thread)作的第一件事就是询问:“你输入的字符串是一些搜索的关键词(search query)仍是一个URL地址呢?”。由于对于Chrome浏览器来讲,导航栏的输入既多是一个能够直接请求的域名还多是用户想在搜索引擎(例如Google)里面搜索的关键词信息,因此当用户在导航栏输入信息的时候UI线程要进行一系列的解析来断定是将用户输入发送给搜索引擎仍是直接请求你输入的站点资源。

UI线程在询问输入的字符串是搜索关键词仍是一个URL

第二步:开始导航

当用户按下回车键的时候,UI线程会叫网络线程(network thread)初始化一个网络请求来获取站点的内容。这时候tab上会展现一个提示资源正在加载中的旋转圈圈,并且网络线程会进行一系列诸如DNS寻址以及为请求创建TLS链接的操做。

UI线程告诉网络线程跳转到mysite.com

这时若是网络线程收到服务器的HTTP 301重定向响应,它就会告知UI线程进行重定向而后它会再次发起一个新的网络请求。

第三步:读取响应

网络线程在收到HTTP响应的主体(payload)流(stream)时,在必要的状况下它会先检查一下流的前几个字节以肯定响应主体的具体媒体类型(MIME Type)。响应主体的媒体类型通常能够经过HTTP头部的Content-Type来肯定,不过Content-Type有时候会缺失或者是错误的,这种状况下浏览器就要进行MIME类型嗅探来肯定响应类型了。MIME类型嗅探并非一件容易的事情,你能够从Chrome的源代码的注释来了解不一样浏览器是如何根据不一样的Content-Type来判断出主体具体是属于哪一个媒体类型的。

响应的头部有Content-Type信息,而响应的主体有真实的数据

若是响应的主体是一个HTML文件,浏览器会将获取的响应数据交给渲染进程(renderer process)来进行下一步的工做。若是拿到的响应数据是一个压缩文件(zip file)或者其余类型的文件,响应数据就会交给下载管理器(download manager)来处理。

网络线程在询问响应的数据是否是来自安全源的HTML文件

网络线程在把内容交给渲染进程以前还会对内容作SafeBrowsing检查。若是请求的域名或者响应的内容和某个已知的病毒网站相匹配,网络线程会给用户展现一个警告的页面。除此以外,网络线程还会作CORBCross Origin Read Blocking)检查来肯定那些敏感的跨站数据不会被发送至渲染进程。

第四步:寻找一个渲染进程(renderer process)

在网络线程作完全部的检查后而且可以肯定浏览器应该导航到该请求的站点,它就会告诉UI线程全部的数据都已经被准备好了。UI线程在收到网络线程的确认后会为这个网站寻找一个渲染进程(renderer process)来渲染界面。

网络线程告诉UI线程去寻找一个渲染进程来渲染界面

因为网络请求可能须要长达几百毫秒的时间才能完成,为了缩短导航须要的时间,浏览器会在以前的一些步骤里面作一些优化。例如在第二步中当UI线程发送URL连接给网络线程后,它其实已经知晓它们要被导航到哪一个站点了,因此在网络线程干活的时候,UI线程会主动地为这个网络请求启动一个渲染线程。若是一切顺利的话(没有重定向之类的东西出现),网络线程准备好数据后页面的渲染进程已经就准备好了,这就节省了新建渲染进程的时间。不过若是发生诸如网站被重定向到不一样站点的状况,刚刚那个渲染进程就不能被使用了,它会被摒弃,一个新的渲染进程会被启动。

第五步:提交(commit)导航

到这一步的时候,数据和渲染进程都已经准备好了,浏览器进程(browser process)会经过IPC告诉渲染进程去提交本次导航(commit navigation)。除此以外浏览器进程还会将刚刚接收到的响应数据流传递给对应的渲染进程让它继续接收到来的HTML数据。一旦浏览器进程收到渲染线程的回复说导航已经被提交了(commit),导航这个过程就结束了,文档的加载阶段(document loading phase)会正式开始。

到了这个时候,导航栏会被更新,安全指示符(security indicator)和站点设置UI(site settings UI)会展现新页面相关的站点信息。当前tab的会话历史(session history)也会被更新,这样当你点击浏览器的前进和后退按钮也能够导航到刚刚导航完的页面。为了方便你在关闭了tab或窗口(window)的时候还能够恢复当前tab和会话(session)内容,当前的会话历史会被保存在磁盘上面。

浏览器进程经过IPC来对渲染进程发起渲染页面的请求

额外步骤:初始加载完成(Initial load complete)

当导航提交完成后,渲染进程开始着手加载资源以及渲染页面。我会在后面的文章中讲述渲染进程渲染页面的具体细节。一旦渲染进程“完成”(finished)渲染,它会经过IPC告知浏览器进程(注意这发生在页面上全部帧(frames)的onload事件都已经被触发了并且对应的处理函数已经执行完成了的时候),而后UI线程就会中止导航栏上旋转的圈圈。

我这里用到“完成”这个词,由于后面客户端的JavaScript仍是能够继续加载资源和改变视图内容的。

渲染进程经过IPC告诉浏览器进程页面已经加载完成了

导航到不一样的站点

一个最简单的导航情景已经描述完了!但是若是这时用户在导航栏上输入一个不同的URL会发生什么呢?若是是这样,浏览器进程会从新执行一遍以前的那几个步骤来完成新站点的导航。不过在浏览器进程作这些事情以前,它须要让当前的渲染页面作一些收尾工做,具体就是询问一下当前的渲染进程需不须要处理一下beforeunload事件。

beforeunload能够在用户从新导航或者关闭当前tab时给用户展现一个“你肯定要离开当前页面吗?”的二次确认弹框。浏览器进程之因此要在从新导航的时候和当前渲染进程确认的缘由是,当前页面发生的一切(包括页面的JavaScript执行)是不受它控制而是受渲染进程控制,因此它也不知道里面的具体状况。

注意:不要随便给页面添加beforeunload事件监听,你定义的监听函数会在页面被从新导航的时候执行,所以这会增长重导航的时延。beforeunload事件监听函数只有在十分必要的时候才能被添加,例如用户在页面上输入了数据,而且这些数据会随着页面消失而消失。

浏览器进程经过IPC告诉渲染进程它将要离开当前页面导航到新的页面了

若是从新导航是在页面内被发起的呢?例如用户点击了页面的一个连接或者客户端的JavaScript代码执行了诸如window.location = "newsite.com"的代码。这种状况下,渲染进程会本身先检查一个它有没有注册beforeunload事件的监听函数,若是有的话就执行,执行完后发生的事情就和以前的状况没什么区别了,惟一的不一样就是此次的导航请求是由渲染进程给浏览器进程发起的。

若是是从新导航到不一样站点(different site)的话,会有另一个渲染进程被启动来完成此次重导航,而当前的渲染进程会继续处理如今页面的一些收尾工做,例如unload事件的监听函数执行。Overview of page lifecycle states这篇文章会介绍页面全部的生命周期状态,the Page Lifecycle API会教你如何在页面中监听页面状态的变化。

浏览器进程告诉新的渲染进程去渲染新的页面而且告诉当前的渲染进程进行收尾工做

Service Worker的情景

这个导航过程最近发生的一个改变是引进了service worker的概念。由于Service worker能够用来写网站的网络代理(network proxy),因此开发者能够对网络请求有更多的控制权,例如决定哪些数据缓存在本地以及哪些数据须要从网络上面从新获取等等。若是开发者在service worker里设置了当前的页面内容从缓存里面获取,当前页面的渲染就不须要从新发送网络请求了,这就大大加快了整个导航的过程。

这里要重点留意的是service worker其实只是一些跑在渲染进程里面的JavaScript代码。那么问题来了,当导航开始的时候,浏览器进程是如何判断要导航的站点存不存在对应的service worker并启动一个渲染进程去执行它的呢?

其实service worker在注册的时候,它的做用范围(scope)会被记录下来(你能够经过文章The Service Worker Lifecycle了解更多关于service worker做用范围的信息)。在导航开始的时候,网络线程会根据请求的域名在已经注册的service worker做用范围里面寻找有没有对应的service worker。若是有命中该URL的service worker,UI线程就会为这个service worker启动一个渲染进程(renderer process)来执行它的代码。Service worker既可能使用以前缓存的数据也可能发起新的网络请求。

网络线程会在收到导航任务后寻找有没有对应的service worker

UI线程会启动一个渲染进程来运行找到的service worker代码,代码具体是由渲染进程里面的工做线程(worker thread)执行

导航预加载 - Navigation Preload

在上面的例子中,你应该能够感觉到若是启动的service worker最后仍是决定发送网络请求的话,浏览器进程和渲染进程这一来一回的通讯包括service worker启动的时间其实增长了页面导航的时延。导航预加载就是一种经过在service worker启动的时候并行加载对应资源的方式来加快整个导航过程效率的技术。预加载资源的请求头会有一些特殊的标志来让服务器决定是发送全新的内容给客户端仍是只发送更新了的数据给客户端。

UI线程在启动一个渲染进程去运行service worker代码的同时会并行发送网络请求

渲染进程里面发生的事

了解了导航具体都发生了哪些事情以及浏览器优化导航效率采起的一些技术方案,接着让咱们深刻了解浏览器的渲染进程是如何解析咱们的HTML/CSS/JavaScript来呈现出网页内容的。

渲染进程会影响到Web性能的不少方面。页面渲染的时候发生的东西实在太多了,这里只能做一个大致的介绍。若是你想要了解更多相关的内容,Web Fundamentals的Performance栏目有不少资源能够查看。

渲染进程处理页面内容

渲染进程负责标签(tab)内发生的全部事情。在渲染进程里面,主线程(main thread)处理了绝大多数你发送给用户的代码。若是你使用了web worker或者service worker,相关的代码将会由工做线程(worker thread)处理。合成(compositor)以及光栅(raster)线程运行在渲染进程里面用来高效流畅地渲染出页面内容。

渲染进程的主要任务是将HTML,CSS,以及JavaScript转变为咱们能够进程交互的网页内容

渲染进程里面有:一个主线程(main thread),几个工做线程(worker threads),一个合成线程(compositor thread)以及一个光栅线程(raster thread)

解析

构建DOM

上文提到,渲染进程在导航结束的时候会收到来自浏览器进程提交导航(commit navigation)的消息,在这以后渲染进程就会开始接收HTML数据,同时主线程也会开始解析接收到的文本数据(text string)并把它转化为一个DOM(Document Object Model)对象

DOM对象既是浏览器对当前页面的内部表示,也是Web开发人员经过JavaScript与网页进行交互的数据结构以及API

如何将HTML文档解析为DOM对象是在HTML标准中定义的。不过在你的web开发生涯中,你可能历来没有遇到过浏览器在解析HTML的时候发生错误的情景。这是由于浏览器对HTML的错误容忍度很大。举些例子:若是一个段落缺失了闭合p标签(</p>),这个页面仍是会被当作为有效的HTML来处理;Hi! <b>I'm <i>Chrome</b>!</i> (闭合b标签写在了闭合i标签的前面) ,虽然有语法错误,不过浏览器会把它处理为Hi! <b>I'm <i>Chrome</i></b><i>!</i>。若是你想知道浏览器是如何对这些错误进行容错处理的,能够参考HTML规范里面的An introduction to error handling and strange cases in the parser内容。

子资源加载

除了HTML文件,网站一般还会使用到一些诸如图片,CSS样式以及JavaScript脚本等子资源。这些文件会从缓存或者网络上获取。主线程会按照在构建DOM树时遇到各个资源的循序一个接着一个地发起网络请求,但是为了提高效率,浏览器会同时运行“预加载扫描”(preload scanner)程序。若是在HTML文档里面存在诸如<img>或者<link>这样的标签,预加载扫描程序会在HTML解析器生成的token里面找到对应要获取的资源,并把这些要获取的资源告诉浏览器进程里面的网络线程。

主线程会解析HTML内容而且构建出DOM树

JavaScript会阻塞HTML的解析过程

当HTML解析器碰到script标签的时候,它会中止HTML文档的解析从而转向JavaScript代码的加载,解析以及执行。为何要这样作呢?由于script标签中的JavaScript可能会使用诸如document.write()这样的代码改变文档流(document)的形状,从而使整个DOM树的结构发生根本性的改变(HTML规范里面的overview of the parsing model部分有很好的示意图)。由于这个缘由,HTML解析器不得不等JavaScript执行完成以后才能继续对HTML文档流的解析工做。若是你想知道JavaScipt的执行过程都发生了什么,V8团队有不少关于这个话题的讨论以及博客

给浏览器一点如何加载资源的提示

Web开发者能够经过不少方式告诉浏览器如何才能更加优雅地加载网页须要用到的资源。若是你的JavaScript不会使用到诸如document.write()的方式去改变文档流的内容的话,你能够为script标签添加一个async或者defer属性来使JavaScript脚本进行异步加载。固然若是能知足到你的需求,你也可使用JavaScript Module。同时<link rel="preload">资源预加载能够用来告诉浏览器这个资源在当前的导航确定会被用到,你想要尽快加载这个资源。更多相关的内容,你可阅读Resource Prioritization - Getting the Browser to Help You这篇文章。

样式计算 - Style calculation

拥有了DOM树咱们还不足以知道页面的外貌,由于咱们一般会为页面的元素设置一些样式。主线程会解析页面的CSS从而肯定每一个DOM节点的计算样式(computed style)。计算样式是主线程根据CSS样式选择器(CSS selectors)计算出的每一个DOM元素应该具有的具体样式,你能够打开devtools来查看每一个DOM节点对应的计算样式。

主线程解析CSS来肯定每一个元素的计算样式

即便你的页面没有设置任何自定义的样式,每一个DOM节点仍是会有一个计算样式属性,这是由于每一个浏览器都有本身的默认样式表。由于这个样式表的存在,页面上的h1标签必定会比h2标签大,并且不一样的标签会有不一样的magin和padding。若是你想知道Chrome的默认样式是长什么样的,你能够直接查看代码

布局 - Layout

前面这些步骤完成以后,渲染进程就已经知道页面的具体文档结构以及每一个节点拥有的样式信息了,但是这些信息仍是不能最终肯定页面的样子。举个例子,假如你如今想经过电话告诉你的朋友你身边的一幅画的内容:“画布上有一个红色的大圆圈和一个蓝色的正方形”,单凭这些信息你的朋友是很难知道这幅画具体是什么样子的,由于他不知道大圆圈和正方形具体在页面的什么位置,是正方形在圆圈前面呢仍是圆圈在正方形的前面。

你站在一幅画面前经过电话告诉你朋友画上的内容

渲染网页也是一样的道理,只知道网站的文档流以及每一个节点的样式是远远不足以渲染出页面内容的,还须要经过布局(layout)来计算出每一个节点的几何信息(geometry)。布局的具体过程是:主线程会遍历刚刚构建的DOM树,根据DOM节点的计算样式计算出一个布局树(layout tree)。布局树上每一个节点会有它在页面上的x,y坐标以及盒子大小(bounding box sizes)的具体信息。布局树长得和先前构建的DOM树差很少,不一样的是这颗树只有那些可见的(visible)节点信息。举个例子,若是一个节点被设置为了display:none,这个节点就是不可见的就不会出如今布局树上面(visibility:hidden的节点会出如今布局树上面,你能够思考一下这是为何)。一样的,若是一个伪元素(pseudo class)节点有诸如p::before{content:"Hi!"}这样的内容,它会出如今布局上,而不存在于DOM树上。

主线程会遍历每一个DOM tree节点的计算样式信息来生成一棵布局树

即便页面的布局十分简单,布局这个过程都是很是复杂的。例如页面就是简单地从上而下展现一个又一个段落,这个过程就很复杂,由于你须要考虑段落中的字体大小以及段落在哪里须要进行换行之类的东西,它们都会影响到段落的大小以及形状,继而影响到接下来段落的布局。

浏览器得考虑段落是否是要换行

若是考虑到CSS的话将会更加复杂,由于CSS是一个很强大的东西,它可让元素悬浮(float)到页面的某一边,还能够遮挡住页面溢出的(overflow)元素,还能够改变内容的书写方向,因此单是想一下你就知道布局这个过程是一个十分艰巨和复杂的任务。对于Chrome浏览器,咱们有一整个负责布局过程的工程师团队。若是你想知道他们工做的具体内容,他们在BlinkOn Conference上面的相关讨论被录制了下来,有时间的话你能够去看一下。

绘画 - Paint

知道了DOM节点以及它的样式和布局其实仍是不足以渲染出页面来的。为何呢?举个例子,假如你如今想对着一幅画画一幅同样的画,你已经知道了画布上每一个元素的大小,形状以及位置,你仍是得思考一下每一个元素的绘画顺序,由于画布上的元素是会互相遮挡的(z-index)。

一我的拿着画笔站在画布前面,在思考着是先画一个圆仍是先画一个正方形

举个例子,若是页面上的某些元素设置了z-index属性,绘制元素的顺序就会影响到页面的正确性。

单纯按照HTML布局的顺序绘制页面的元素是错误的,由于元素的z-index元素没有被考虑到

在绘画这个步骤中,主线程会遍历以前获得的布局树(layout tree)来生成一系列的绘画记录(paint records)。绘画记录是对绘画过程的注释,例如“首先画背景,而后是文本,最后画矩形”。若是你曾经在canvas画布上有使用过JavaScript绘制元素,你可能会觉着这个过程不是很陌生。

主线程遍历布局树来生成绘画记录

高成本的渲染流水线(rendering pipeline)更新

关于渲染流水线有一个十分重要的点就是流水线的每一步都要使用到前一步的结果来生成新的数据,这就意味着若是某一步的内容发生了改变的话,这一步后面全部的步骤都要被从新执行以生成新的记录。举个例子,若是布局树有些东西被改变了,文档上那些被影响到的部分的绘画顺序是要从新生成的。

DOM+Style,布局以及绘画树

若是你的页面元素有动画效果(animating),浏览器就不得不在每一个渲染帧的间隔中经过渲染流水线来更新页面的元素。咱们大多数显示器的刷新频率是一秒钟60次(60fps),若是你在每一个渲染帧的间隔都能经过流水线移动元素,人眼就会看到流畅的动画效果。但是若是流水线更新时间比较久,动画存在丢帧的情况的话,页面看起来就会很“卡顿”。

流水线更新没有遇上屏幕刷新,动画就有点卡

即便你的渲染流水线更新是和屏幕的刷新频率保持一致的,这些更新是运行在主线程上面的,这就意味着它可能被一样运行在主线程上面的JavaScript代码阻塞。

某些动画帧被JavaScript阻塞了

对于这种状况,你能够将要被执行的JavaScript操做拆分为更小的块而后经过requestAnimationFrame这个API把他们放在每一个动画帧中执行。想知道更多关于这方面的信息的话,能够参考Optimize JavaScript Execution。固然你还能够将JavaScript代码放在WebWorkers中执行来避免它们阻塞主线程。

在动画帧上运行一小段JavaScript代码

合成

如何绘制一个页面?

到目前为止,浏览器已经知道了关于页面如下的信息:文档结构,元素的样式,元素的几何信息以及它们的绘画顺序。那么浏览器是如何利用这些信息来绘制出页面来的呢?将以上这些信息转化为显示器的像素的过程叫作光栅化(rasterizing)

可能一个最简单的作法就是只光栅化视口内(viewport)的网页内容。若是用户进行了页面滚动,就移动光栅帧(rastered frame)而且光栅化更多的内容以补上页面缺失的部分。Chrome的第一个版本其实就是这样作的。然而,对于现代的浏览器来讲,它们每每采起一种更加复杂的叫作合成(compositing)的作法。

最简单的光栅化过程

什么是合成

合成是一种将页面分红若干层,而后分别对它们进行光栅化,最后在一个单独的线程 - 合成线程(compositor thread)里面合并成一个页面的技术。当用户滚动页面时,因为页面各个层都已经被光栅化了,浏览器须要作的只是合成一个新的帧来展现滚动后的效果罢了。页面的动画效果实现也是相似,将页面上的层进行移动并构建出一个新的帧便可。

你能够经过Layers panel在DevTools查看你的网站是如何被浏览器分红不一样的层的。

页面合成过程

页面分层

为了肯定哪些元素须要放置在哪一层,主线程须要遍历渲染树来建立一棵层次树(Layer Tree)(在DevTools中这一部分工做叫作“Update Layer Tree”)。若是页面的某些部分应该被放置在一个单独的层上面(滑动菜单)但是却没有的话,你能够经过使用will-change CSS属性来告诉浏览器对其分层。

主线程遍历布局树来生成层次树

你可能会想要给页面上全部的元素一个单独的层,然而当页面的层超过必定的数量后,层的合成操做要比在每一个帧中光栅化页面的一小部分还要慢,所以衡量你应用的渲染性能是十分重要的一件事情。想要获取关于这方面的更多信息,能够参考文章Stick to Compositor-Only Properties and Manage Layer Count

在主线程以外光栅化和合成页面

一旦页面的层次树建立出来而且页面元素的绘制顺序肯定后,主线程就会向合成线程(compositor thread)提交这些信息。而后合成线程就会光栅化页面的每一层。由于页面的一层可能有整个网页那么大,因此合成线程须要将它们切分为一块又一块的小图块(tiles)而后将图块发送给一系列光栅线程(raster threads)。光栅线程会栅格化每一个图块而且把它们存储在GPU的内存中。

光栅线程建立图块的位图并发送给GPU

合成线程能够给不一样的光栅线程赋予不一样的优先级(prioritize),进而使那些在视口中的或者视口附近的页面能够先被光栅化。为了响应用户对页面的放大和缩小操做,页面的图层(layer)会为不一样的清晰度配备不一样的图块。

当图层上面的图块都被栅格化后,合成线程会收集图块上面叫作绘画四边形(draw quads)的信息来构建一个合成帧(compositor frame)。

  • 绘画四边形:包含图块在内存的位置以及图层合成后图块在页面的位置之类的信息。
  • 合成帧:表明页面一个帧的内容的绘制四边形集合

上面的步骤完成以后,合成线程就会经过IPC向浏览器进程(browser process)提交(commit)一个渲染帧。这个时候可能有另一个合成帧被浏览器进程的UI线程(UI thread)提交以改变浏览器的UI。这些合成帧都会被发送给GPU从而展现在屏幕上。若是合成线程收到页面滚动的事件,合成线程会构建另一个合成帧发送给GPU来更新页面。

合成线程构建出合成帧,合成帧会被发送给浏览器进程而后再发送给GPU

合成的好处在于这个过程没有涉及到主线程,因此合成线程不须要等待样式的计算以及JavaScript完成执行。这也就是为何说只经过合成来构建页面动画是构建流畅用户体验的最佳实践的缘由了。若是页面须要被从新布局或者绘制的话,主线程必定会参与进来的。

到达合成线程的输入

了解了渲染进程从解析HTML文件到合成页面整个的渲染流水线后,在接下来剩下的文章内容中,咱们将要查看合成线程更多的细节,来了解一下当用户在页面移动鼠标(mouse move)以及进行点击(click)的时候浏览器会作些什么事情。

从浏览器的角度来看输入事件

当你听到“输入事件”(input events)的时候,你可能只会想到用户在文本框中输入内容或者对页面进行了点击操做,但是从浏览器的角度来看的话,输入其实表明着来自于用户的任何手势动做(gesture)。因此用户滚动页面触碰屏幕以及移动鼠标等操做均可以看做来自于用户的输入事件。

当用户作了一些诸如触碰屏幕的手势动做时,浏览器进程(browser process)是第一个能够接收到这个事件的地方。但是浏览器进程只能知道用户的手势动做发生在什么地方而不知道如何处理,这是由于标签内(tab)的内容是由页面的渲染进程(render process)负责的。所以浏览器进程会将事件的类型(如touchstart)以及坐标(coordinates)发送给渲染进程。为了能够正确地处理这个事件,渲染进程会找到事件的目标对象(target)而后运行这个事件绑定的监听函数(listener)。

点击事件从浏览器进程路由到渲染进程

合成线程接收到输入事件

在上面的文章中,咱们查看了合成线程是如何经过合并页面已经光栅化好的层来保障流畅滚动体验(scroll smoothly)的。若是当前页面不存在任何用户事件的监听器(event listener),合成线程彻底不须要主线程的参与就能建立一个新的合成帧来响应事件。但是若是页面有一些事件监听器(event listeners)呢?合成线程是如何判断出这个事件是否须要路由给主线程处理的呢?

了解非快速滚动区域 - non-fast scrollable region

由于页面的JavaScript脚本是在主线程(main thread)中运行的,因此当一个页面被合成的时候,合成线程会将页面那些注册了事件监听器的区域标记为“非快速滚动区域”(Non-fast Scrollable Region)。因为知道了这些信息,当用户事件发生在这些区域时,合成线程会将输入事件发送给主线程来处理。若是输入事件不是发生在非快速滚动区域,合成线程就无须主线程的参与来合成一个新的帧。

非快速滚动区域有用户事件发生时的示意图

当你写事件监听器的时候留点心眼

Web开发的一个常见的模式是事件委托(event delegation)。因为事件会冒泡,你能够给顶层的元素绑定一个事件监听函数来做为其全部子元素的事件委托者,这样子节点的事件就能够统一被顶层的元素处理了。所以你可能看过或者写过相似于下面的代码:

document.body.addEventListener('touchstart', event => {
  if (event.target === area) {
    event.preventDefault()
  }
})
复制代码

只用一个事件监听器就能够服务到全部的元素,乍一看这种写法仍是挺实惠的。但是,若是你从浏览器的角度去看一下这段代码,你会发现上面给body元素绑定了事件监听器后实际上是将整个页面都标记为一个非快速滚动区域,这就意味着即便你页面的某些区域压根就不在意是否是有用户输入,当用户输入事件发生时,合成线程每次都会告知主线程而且会等待主线程处理完它才干活。所以这种状况下合成线程就丧失提供流畅用户体验的能力了(smooth scrolling ability)。

当整个页面都是非快速滚动区域时页面的事件处理示意图

为了减轻这种状况的发生,您能够为事件监听器传递passive:true选项。 这个选项会告诉浏览器您仍要在主线程中侦听事件,但是合成线程也能够继续合成新的帧。

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

查找事件的目标对象(event target)

当合成线程向主线程发送输入事件时,主线程要作的第一件事是经过命中测试(hit test)去找到事件的目标对象(target)。具体的命中测试流程是遍历在渲染流水线中生成的绘画记录(paint records)来找到输入事件出现的x, y坐标上面描绘的对象是哪一个。

主线程经过遍历绘画记录来肯定在x,y坐标上的是哪一个对象

最小化发送给主线程的事件数

在上面的文章中咱们有说过显示器的刷新频率一般是一秒钟60次以及咱们能够经过让JavaScript代码的执行频率和屏幕刷新频率保持一致来实现页面的平滑动画效果(smooth animation)。对于用户输入来讲,触摸屏通常一秒钟会触发60到120次点击事件,而鼠标通常则会每秒触发100次事件,所以输入事件的触发频率其实远远高于咱们屏幕的刷新频率。

若是每秒将诸如touchmove这种连续被触发的事件发送到主线程120次,由于屏幕的刷新速度相对来讲比较慢,它可能会触发过量的点击测试以及JavaScript代码的执行。

事件淹没了屏幕刷新的时间轴,致使页面很卡顿

为了最大程度地减小对主线程的过多调用,Chrome会合并连续事件(例如wheelmousewheelmousemovepointermovetouchmove),并将调度延迟到下一个requestAnimationFrame以前。

和以前相同的事件轴,但是此次事件被合并并延迟调度了

任何诸如keydownkeyupmouseupmousedowntouchstarttouchend等相对不怎么频繁发生的事件都会被当即派送给主线程。

使用getCoalesecedEvents来获取帧内(intra-frame)事件

对于大多数web应用来讲,合并事件应该已经足够用来提供很好的用户体验了,然而,若是你正在构建的是一个根据用户的touchmove坐标来进行绘图的应用的话,合并事件可能会使页面画的线不够顺畅和连续。在这种状况下,你可使用鼠标事件的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.
    }
});
复制代码

下一步

在这篇文章中,咱们以Chrome浏览器为例子探讨了浏览器的内部工做原理。若是你以前历来没有想过为何DevTools推荐你在事件监听器中使用passive:true选项或者在script标签中写async属性的话,我但愿这篇文章能够给你一些关于浏览器为何须要这些信息来提供更快更流畅的用户体验的缘由。

学习如何衡量性能

不一样网站的性能调整可能会有所不一样,你要本身衡量本身网站的性能并肯定最适合提高你的网站性能的方案。 你能够查看Chrome DevTools团队的一些教程来学习如何才能衡量本身网站的性能

为你的站点添加Feature Policy

若是你想更进一步,你能够了解一下Feature Policy这个新的Web平台功能,这个功能能够在你构建项目的时候提供一些保护让您的应用程序具备某些行为并防止你犯下错误。例如,若是你想确保你的应用代码不会阻塞页面的解析(parsing),你能够在同步脚本策略(synchronius scripts policy)中运行你的应用。具体作法是将sync-script设置为'none',这样那些会阻塞页面解析的JavaScript代码会被禁止执行。这样作的好处是避免你的代码阻塞页面的解析,并且浏览器无须担忧解析器(parser)暂停。

持续关注个人技术动态

我是进击的大葱,关注我和我一块儿进步成独当一面的全栈工程师!

文章首发于:窥探现代浏览器架构(一)

关注个人我的公众号获取个人最新技术推送!

相关文章
相关标签/搜索