浏览器运行机制

1,944 阅读13分钟

浏览器运行机制

页面内容的快速加载和流畅的交互体验是前端开发者所希望的, 了解浏览器的运行机制有助于我们提升Web性能、优化用户体验.

区分进程和线程

进程是系统资源分配的最小单位,系统为每个进程分配一块内存.一个应用程序可以包含多个进程, 进程之间可以通信,但是代价比较大;

一个进程可以包含多个线程, 每个进程中的内存空间对于里面的线程来说都是共享的,但是当某个线程在使用进程中的共享内存时,其他线程必须等他使用结束,才能操作该共享内存。防止多个线程同时读写某一块内存区域.

image-20210607114205499

以上图中我们目前正常运行的Typora这个应用程序为例, 可以看到当前有多个相关进程在运行, 每个进程中都包含多个线程,它们共同协助使Typora这个应用程序正常运行.

浏览器多进程

根据上面Typora的例子,我们猜想浏览器也应该是多进程的,我们就以Chrome浏览器为例继续进行探讨实验, 首先只打开一个浏览器窗口,然后在活动监视器里观察进程情况:

image-20210612095756195

通常每新建一个tab页,假使浏览器就会新开起一个新的渲染进程(renderer)(根据上面实验应该是上不止一个的),浏览器的渲染进程是也是多线程的

现在我们可以确定的说浏览器是多进程的, 而且大致是下面这四种进程:

  1. 主进程(Google Chrome)

    也称为Browser进程, 是浏览器的主控进程, 只有一个,负责整体的调控、tab页面的打开关闭;

  2. GPU进程(GPU)

    图形处理器, 负责3D图形绘制,只有一个

  3. 渲染进程(Renderer)

    主要负责页面渲染相关工作, 有多个

  4. 插件进程

    每一个插件都会创建一个插件进程

浏览器中的渲染进程

上面四类进程中, 我们前端开发工作中会经常接触到的,主要就是渲染进程GPU进程; 像页面渲染、脚本执行、事件循环、接口请求等都在渲染线程中, 而我们平时在CSS3中使用的硬件加速则是在GPU进程中; 本篇文章主要讨论浏览器的渲染进程.

浏览器内核

浏览器内核是浏览器最重要的组成部分。浏览器内核分两部分:渲染引擎JS引擎;后来由于JS引擎越来越独立,浏览器内核就倾向于单指渲染引擎浏览器内核渲染引擎主要用来解释网页语法并将网页渲染到屏幕上。由于不同的浏览器使用不同的内核,导致对网页语法解释不同,所以就会出现最终渲染的效果不同;常见浏览器内核:

浏览器类型渲染引擎JS引擎
IETrident(['traɪd(ə)nt])Chakra(['gekəʊ])
Chrome以前Webkit, 现在BlinkV8
SafariWebkitJavaScriptCore

渲染进程(renderer)

浏览器的渲染进程(renderer)主要负责页面解析、JS执行、事件循环处理等操作, renderer进程也是多线程的,根据上面的进程图我们知道,任意一个渲染进程都可能包含十几个线程, 这里我们只挑其中的五大常驻线程说一下:

  1. GUI渲染线程:

    Graphical User Interface,简称 GUI,又称图形用户界面。

    负责将html解析成DOM tree, 将CSS解析成CSSOM tree,然后将二者整合生成Render tree,进行布局(Flow Layout)和绘图(Paint)。

    Render Tree 中部分或全部元素的尺寸、结构、或某些属性发生改变时,触发浏览器回流(Reflow), 或者当页面中元素样式的改变且不影响它在文档流中的位置时(例如:colorbackground-colorvisibility 等),触发的浏览器重绘(Repaint)。

    GUI渲染线程JS引擎线程是互斥的。 二者不会同时发生

  2. JS引擎线程:

    负责解析、执行JS脚本代码

    在空闲时执行任务队列中进来的异步任务

    每个tab页面只有一个JS引擎线程, 而且与GUI线程互斥; 这是在这门语言设计之初就确立的规则,如果JS引擎线程在增删DOM的时,渲染线程执行渲染页面的操作, 那么此时渲染线程就凌乱, 不知咋办了;

  3. 事件触发线程:

    浏览器为JS引擎线程开辟的助手,用于控制事件循环;

    因为JS引擎是单线程运行的,对于一些耗时的异步任务,JS引擎不能一直等待当前这些耗时操作(往往这些耗时操作并不是因为JS引擎运行速度慢、执行不过来导致的, 大多是由于网络请求、I/O操作等外部原因导致的)处理完再执行后面的代码, 否则就太影响效率了, 所以对于一些像(定时器事件、鼠标事件, 滚动事件, AJX事件),在事件触发时,事件触发线程会把回调任务添加到事件队列, 等待JS引擎线程来执行,我们经常说的事件循环机制(Event Loop)就发生在这个线程上.

  4. 定时器线程

    setInterval setTimeout事件所在的线程

    为了准确, 这些定时计数器是由单独的线程来维护的, 在d定时结束时,将任务添加到事件队列, 等待JS引擎线程来执行

  5. http请求线程

    当浏览器遇到http请求时,会开启一个http线程,待到状态变更时,将任务添加到事件队列,等待JS引擎线程来执行

页面渲染流程

用户在浏览器地址栏输入URL后(此处省略好多字😭😭......)浏览器获取内容后通过RendererHost接口转交给Renderer进程,渲染引擎拿到资源(html/js/css/img)后,开始解析渲染页面,其中大致会经历下面几个步骤:

  1. 将html解析成DOM Tree, 将CSS代码解析成CSSOM Tree

    浏览器解析HTML的过程就是构建DOM Tree的过程,解析后的DOM Tree是同HTML文档中的节点应该是一一对应的. 而 dom树的构建又是一个深度遍历的过程,只有当前节点的所有后代节点都遍历完成后,才会去处理当前节点的下一个兄弟节点.

CSSOM 包含了浏览器元素自带样式user agent stylesheet 和所有我们自己编写的样式.这些构成了如何展示 DOM 的信息

  1. DOM Tree,和CSSOM Tree合并成渲染树 Render Tree

    渲染树包括了内容和样式:DOMCSSOM树结合为渲染树。为了构造渲染树,浏览器检查每个节点,从DOM树的根节点开始,并且决定哪些 CSS 规则被添加。

    渲染树只包含了可见内容。头部(通常)不包含任何可见信息,因此不会被包含在渲染树种。如果有元素上有 display: none;,它本身和其后代都不会出现在渲染树中。

20201222145116204

  1. 布局(Flow Layout)

    根据Render Tree计算出各元素的排列位置、尺寸大小、从属关系等信息

    第一次确定节点的大小和位置称为布局。随后对节点大小和位置的重新计算称为回流

  2. 绘制(Paint)

    将带有样式信息的节点的每个可视部分绘制到屏幕上,包括文本、颜色、边框、背景、阴影.

    绘制可以将布局树中的元素分解为多个层,例如某些特殊属性:opacitytransform 3D等, 会使该元素通过GPU实例化一个层; 层可以提高绘制和重绘性能, 但是它以内存管理为代价,因此不应过度使用。

  3. 合并图层(Composite)

    当文档的各个部分以不同的层绘制,相互重叠时,必须进行合成,以确保它们以正确的顺序绘制到屏幕上,并正确显示内容

1460000012934765

预加载扫描器(preload scanner)

预加载扫描仪可以在解析前请求高优先级资源,如CSSImageJavaScriptweb字体。使浏览器不必等到解析器解析到外部资源的引用的时候才向服务器请求。它将在后台请求资源,以便在主HTML解析器到达请求的资源时,资源可能已经在运行,或者已经被下载。

<link rel="stylesheet" src="style.css"/>
<script src="./async.js" async></script>
<img src="im g.jpg"/>
<script src="./defer.js" defer></script>

在上面例子中,当主线程在解析HTMLCSS时,预加载扫描器会先找到脚本和图像,并开始下载它们。

JavaScript 运行次序

通常HTML页面是从上到下同步加载和执行,在脚本的加载和执行过程中会阻塞后续的DOM渲染; 而且Javascript代码也是从上往下的顺序执行(不管是通过script标签的src属性从外部引用,还是直接写在script标签内的内部代码),这意味着我们需要注意书写代码的顺序;

对于通过script标签的src属性引用的外部js, script标签提供了deferasync两种属性来解决该问题, 可以根据使用环境选择其中的一个, 而且这两个属性只对外部引用的script标签生效

  • defer: 使得脚本能够异步加载,不阻塞后续DOM解析,在HTML页面解析完成后执行; 如果有多个defer脚本, 浏览器会在所有defer脚本加载完毕之后按文档顺序执行; 需要注意的是 defer脚本会在DOMContentLoaded事件触发前执行

  • async: 也能够让脚本异步加载,加载完后就立即执行, 如果有多个async的脚本, 浏览器会并行加载,谁先加载完毕就先执行谁,不管文档中的先后顺序.

image-20210524153838059

如果脚本无需等待页面解析,且无依赖独立运行,那么应使用 async,

如果脚本需要等待页面解析,且依赖于其它脚本,调用这些脚本时应使用 defer, 将关联的脚本按所需顺序置于 HTML 中。

脚本的加载是可以异步的, 但是脚本的解析看上去却是同步的,这是因为GUI渲染线程跟JS引擎线程是互斥的, 在JS引擎线程执行的时候GUI渲染线程挂起.

对于写在<script>标签内的内部代码,我们可以通过监听DOMContentLoaded事件来避免 在使用Javascript操作DOM时,HTML元素还没解析完的问题:

document.addEventListener("DOMContentLoaded",  function() {
  // HTML 文档体加载、解析完毕后要执行的操作
})

注意DOMContentLoaded事件是当初始的 HTML文档被完全加载和解析完成之后,事件被触发,而无需等待样式表、图像和子框架的完全加载。这是MDN上面的解释。通过一个实验模拟一下,发现这句话是有条件的, 我们通过express模拟一个静态服务器:

const express = require('express')
const path = require('path')
const app = express()
app.get('*', (req, res) => {
  	//让客户端请求的资源文件延迟3秒返回
    setTimeout(() => {
      //资源文件都放在服务器端的static文件夹下面
        res.sendFile(path.join(__dirname, './static/', req.url))
    }, 3000)
})
app.listen(8888)

再看一下客户端代码, 请求一些外部资源, 然后分别监听window.onloaddocument.DOMContentLoaded事件; 为了避免其他因素影响,这里引用的JS文件, 内容都是只有一行console语句.

<link async rel="stylesheet" href="http://localhost:8888/style.css">
<link defer rel="stylesheet" href="http://localhost:8888/style2.css">
<script src="http://localhost:8888/async.js" async></script> 
<script src="http://localhost:8888/defer1.js" defer></script>
<script src="http://localhost:8888/default.js"></script>
<script src="http://localhost:8888/defer2.js" defer></script> 
 <script>
   window.addEventListener("load", (res) => {
   	console.log("onload", res)
   })
   document.addEventListener("DOMContentLoaded", (res) => {
   	console.log("DOMComponentLoaded", res)
   })
 </script>
<body>
    脚本的加载顺序
    <img src="http://localhost:8888/image.png">
</body>

看一下控制台Network中的网络请求和console中的打印

htmlLoad

根据图中的打印结果, 我们可以得到以下结论:

  1. 样式的的加载是会阻塞后面HTML渲染的

    浏览器本身就是至上而下同步解析代码的, 而且在页面呈现到屏幕之前,需要将解析生成的DOM TreeCSS Tree 合并成Render Tree, 所以CSS的加载解析过程会阻塞页面渲染;

  2. 脚本的加载也是会阻塞后面HTML渲染的

    但是对于外链脚本可以使用defer、async属性来,使脚本异步加载

  3. defer属性的脚本按原文档中顺序执行

    对于有多个defer属性的脚本,浏览器会按照它们在文档中的顺序依次加载解析, 并且会在dom解析完成后,DOMContentLoaded事件触发之前执行, 根据打印的顺序,确实是在控制台先后输出

通过上面的实验也发现了一些问题, MDN上不是说当纯HTML被完全加载以及解析时,DOMContentLoaded 事件会被触发,而不必等待样式表,图片或者子框架完成加载的么? 但是我们明明看到的却是在样式表和图像完全加载出来之前,DOMContentLoaded事件不会触发; 但是如果我们把link标签和图片的位置置于**DOMContentLoaded** 事件之后,就会立刻看到打印了。

js事件循环机制

js是单线程的,但是会有同步任务和异步任务

​ 同步任务都是在主线程上执行的(JS引擎线程),形成一个执行栈,

​ 主线程之外,事件触发线程管理着一个任务队列,当异步任务触发并完成后,事件触发线程会将该异步任务(异步任务的回调),放置到任务队列,

​ 只要当主线程执行完之后(执行栈清空),主线程就会去读取任务队列中的事件,如果事件队列(消息队列)中有任务,主线程就会按照队列先近先出的原则, 获取队列中的排在首位的第一个任务并执行;

image-20200526211109662
(function() {
  console.log('1');
  
  setTimeout(function cb() {
    console.log('2');
  });
  
  console.log('3');
  
  setTimeout(function cb1() {
    console.log('4');
  }, 0);
  
  console.log('5');
})();

// 1, 3, 5, 2, 4

先执行同步任务,在执行栈清空后, 在处理消息队列中的异步任务, 所以最终打印的结果顺序是 1, 3, 5, 2, 4.

任务和微任务

JS中的队列(queue)有我们常见的任务队列和微任务队列两种之分,我们先来看一个例子,再说明其中原因:

(function() {
  console.log('1');
  
  setTimeout(function cb() {
    console.log('2');
  });
  
  console.log('3');
  
 	Promise.resolve().then(function cb() {
    console.log('4');
  })
  
  console.log('5');
})();

跟上面事件循环中的🌰基本上相同, 就是打印字符串4的代码变成了Promise对象, 但是打印结果却不同,控制台依次打印1、3、5、4、2,为什么会发生这样的事情呢?这里就涉及到了JS对微任务的处理; 先来看一下在MDN中对二者区别的解释:

  • 当执行来自任务队列中的任务时,在每一次新的事件循环开始迭代的时候运行时都会执行队列中的每个任务。在每次迭代开始之后加入到队列中的任务需要在下一次迭代开始之后才会被执行.
  • 每次当一个任务退出且执行上下文为空的时候,微任务队列中的每一个微任务会依次被执行。不同的是它会等到微任务队列为空才会停止执行——即使中途有微任务加入。换句话说,微任务可以添加新的微任务到队列中,并在下一个任务开始执行之前且当前事件循环结束之前执行完所有的微任务。

官方文档的解释太晦涩了, 其实它们最主要的区别就在于二者的执行时机; 首先二者有个共同点: 都需要在当前执行栈中的同步任务执行完毕后执行, 任务队列中的任务需要在下一次迭代开始之后才会被执行, 而微任务队列中的任务是在事件循环开始之前就执行的; 换句话说, 就是js线程先执行完当前执行栈中的任务, 执行完毕之后,接着先去执行微任务队列中的任务, 再执行完毕之后, 最后才是事件循环去执行任务队列中的任务; 下面用代码验证一下:

(function() {
  console.log('1');
  
  setTimeout(function cb() {
    console.log('2');
  },0);

  Promise.resolve().then(function cb() {
    console.log('6');
  })

  for(let i=0;i<=10000;i++) {
     for(let j=0;j<=10000; j++ ) {
       if(i==10000 && j==10000) {
           console.log('3');
        }
     }
  }
  
  Promise.resolve().then(function cb() {
    console.log('4');
  })
  
  console.log('5');
})();
//依次打印 1,3,5,6,4,2

微任务除了通过Promise的then函数调用产生微任务外, window对象提供了queueMicrotask方法可以生产微任务:

setTimeout(() => {
  console.log("job")
}, 0)

queueMicrotask(() => {
  console.log("microtask")
})
// microtask  job

参考文章

  1. 渲染页面:浏览器的工作原理
  2. 从浏览器多进程到js单线程
  3. 在 JavaScript 中通过 queueMicrotask() 使用微任务
  4. 深入:微任务与Javascript运行时环境
  5. DOMContentLoaded
  6. Document: DOMContentLoaded 事件