浏览器组成

大部分的浏览器主要组成包括

  1. 用户界面 - 包括地址栏、前进/后退按钮、书签菜单等。除了浏览器主窗口显示的您请求的页面外,其他显示的各个部分都属于用户界面。
  2. 浏览器引擎 - 在用户界面和呈现引擎之间传送指令。
  3. 渲染引擎 - 负责显示请求的内容。如果请求的内容是 HTML,它就负责解析 HTML 和 CSS 内容,并将解析后的内容显示在屏幕上。
  4. 网络 - 用于网络调用,比如 HTTP 请求。其接口与平台无关,并为所有平台提供底层实现。
  5. 用户界面后端 - 用于绘制基本的窗口小部件,比如组合框和窗口。其公开了与平台无关的通用接口,而在底层使用操作系统的用户界面方法。
  6. JavaScript 解释器。用于解析和执行 JavaScript 代码。
  7. 数据存储。这是持久层。浏览器需要在硬盘上保存各种数据,例如 Cookie。新的 HTML 规范 (HTML5) 定义了“网络数据库”,这是一个完整(但是轻便)的浏览器内数据库。

在将一个页面呈现在用户面前时,是多个引擎共同合作所呈现的结果。

浏览器的进程与线程

进程被描述为一个应用的执行程序。线程则存在于进程内部负责执行某一部分。当您启动应用程序时,就会创建一个进程,进程可以创建自己的线程,也可以要求操作系统启动另外一个进程来协助运行任务。

也就是说,一个正在运行的应用程序,他可能包括多个进程,而每个进程有可能包括多个线程。

Eg:进程之间相互独立,祝进程通知操作系统启动了一个新的进程后,主进程可通过IPC机制进程进程间通信。

那当浏览器被打开时,会有几个线程被创建呢?其实每个浏览器是不同的,他们都有自己的调度机制,但发展至今,几乎所有的主流浏览器都具有多进程架构。

以chrome浏览器为例,它通常存在以下几个进程:

  • 浏览器进程:主进程,只有一个,负责用户界面和渲染引擎之间的交流,根据接收到的输入来查询和处理渲染引擎,管理各个页面。
  • 插件进程:控制网站使用的任何插件,当一个插件使用时,创建一个对应的插件进程。
  • 渲染器进程:用于渲染页面,将html、css、js转换为用户看到的网页。
  • GPU进程:只有一个,独立于其他进程处理 GPU 任务。例如3D绘制等
  • 网络进程:进行网络请求

对于单个页面(一个tab)来说,最重要的,是渲染器进程,它控制着页面的所有的事情,该进程也包括多个线程。

通常来说,渲染器进程会创建一个主线程,它使用渲染引擎去解析html并渲染页面,当遇到js文件时,会暂停渲染引擎的工作转而使用js引擎去解析js内容。所以,页面渲染与js的执行是互斥不可同时进行的。

比较 Chrome 和 Firefox

Chrome 和 Firefox 现在都支持多线程,但它们以不同的方式实现。在 Chrome 中,打开的每个标签都有自己的内容处理。十个选项卡,10 个渲染进程。一百个选项卡,100 个渲染进程。这种方法可以最大限度地提高性能,但您会在内存消耗和电池寿命方面付出沉重的代价。Firefox 没有采用这种方法来解决问题,而是默认情况下最多旋转四个内容进程线程。在 Firefox 中,前 4 个选项卡每个都使用这 4 个进程,其他选项卡使用这些进程中的线程进行调整。进程中的多个选项卡共享内存中已经存在的浏览器引擎,而不是每个选项卡都创建自己的。

浏览器的渲染

  • dom解析:当接收到html数据时,主线程开始解析html,转化为dom对象

    • 解析时遇到css、js、图片等资源时,需要从网络或缓存中获取,主线程会逐一发请求去获取。他会发将这些请求通知给浏览器进程下的网络线程进行资源下载
    • 构建时,如果遇到css资源时,不会堵塞dom树的构建
    • 构建时,如果遇到script标签,主线程将立刻停止dom树的构建,转而去执行js代码
  • 样式计算:主线程解析 CSS 并确定每个 DOM 节点的计算样式。生成CSSOM,这是关于基于 CSS 选择器将哪种样式应用于每个元素的信息。dom树的解析和css的解析时并行的,但他们同会被js的执行而堵塞。

  • 布局:主线程遍历 DOM 和计算样式并创建布局树(Layout Tree),其中包含 xy 坐标和边界框大小等信息。布局树可能与 DOM 树的结构相似,但它只包含与页面上可见的内容相关的信息。如果display: none应用,则该元素不是布局树的一部分。

  • 绘制:主线程遍历布局树以创建绘制记录(Paint Records)。绘画记录是对“先背景,后文字,再矩形”的绘画过程的记录。

  • 合并:将各个部分分成多层单独进行光栅化并在称为合成器线程的单独线程中合成为一个页面

    • 主线程遍历布局树以创建层树(这部分在 DevTools 性能面板中称为“更新层树”)。如果页面的某些部分应该是单独的层(如滑入式侧边菜单)没有得到,那么您可以通过使用will-changeCSS 中的属性来提示浏览器。

    • 一旦创建了层树并确定了绘制顺序,主线程就会将该信息提交给合成器线程。合成线程然后光栅化每一层。随后收集称为绘制四边形的切片信息来创建合成器框架,然后通过IPC将合成器框架交给浏览器进程。

    • 浏览器进程将它送予GPU将其显示在屏幕上。

Eg:将文档的结构、每个元素的样式、页面的几何形状和绘制顺序,它如何绘制页面转换为屏幕上的像素称为光栅化。

Eg:合成的好处是它是在不涉及主线程的情况下完成的。合成器线程不需要等待样式计算或 JavaScript 执行。

渲染频率

日常中的浏览器通常是60帧,即16ms刷新一次。

也就是每16ms会调度浏览器执行进行一次渲染,在页面呈现的过程中,主线程要做的事情如下:

dom解析/样式计算 —— 布局 —— 绘制

因为合并操作的大部分时间是由合成器线程处理,所以可以暂时忽略

在连续渲染过程中,主线程上则是步骤交替

dom解析/样式计算 —— 布局 —— 绘制 —— dom解析/样式计算 —— 布局 —— 绘制

而目前的前端项目中大部分的内容都是js,大部分的时间会耗费在js文件的解析上,故上面的过程可以分为下面量大部分

js文件解析 —— 布局

多次渲染过程时

js文件解析 —— 布局 —— js文件解析 —— 布局 —— js文件解析 —— 布局

而只有我们布局过程结束后,才会将结果送至GPU进行渲染,如果布局没有计算完,或者没有执行,则GPU没有内容进行渲染。

如果某一次js文件解释时间过长,等于甚至超过了16ms,那么在本帧内,则没有了布局执行的时间,GPU也将不会渲染结果。所以在一些动画中,当我们的计算过于复杂时,浏览器计算的时间超过了16ms就会导致掉帧的现象。

事件循环机制

除了主线程会创建一个执行栈外,还会创建一个线程去管理着一个任务队列。

事件循环机制并不是js引擎去制造的,而是他的运行环境所提供的运行机制

执行栈中会一次执行。而当我们发出一些特殊的请求时,则就创建对应的工作线程,例如当检测到setTimeout方法时,将会被定时器线程检测到,它会对他进行管理,在设定的时间结束后,将回调方法堆入任务队列。

当执行栈中的任务执行完成后,会将任务队列中的方法加入其中。

所谓的异步任务,便是并没有在一开始就由主线程执行,而是先由其他进程进行管理,之后推入任务队列,最后加入执行栈再由主线程执行。所以,js仍是单线程,尽管有些方法中途由其他线程所管理。

Eg:每一帧就包括了一次画面的更新,但不代表只有一轮事件循环

web api优化js执行

requestAnimationFrame,在每次渲染页面前执行。

不论是执行动画还是拆分任务,requestAnimationFrame会比setTimeoutsetInterval有着更出色的优势。当我们调用了一个setTimeout,此时,该方法将暂时由定时触发器线程所管理,而主线程则继续执行渲染或者js文件。当时间到达时,将该方法推入任务队列,再等执行栈空之后,再推入执行栈进行执行。而在他被推入执行栈并准备执行的时刻,可能在一帧(16ms)的末尾,这时,去执行这个函数便可能由于事件过长从而使得没有时间进行页面渲染。所以,使用requestAnimationFrame,可以准确的在帧的开头去执行函数,可以有更多的时间去处理js。

requestIdleCallback,在浏览器的空闲时段内调用的函数排队

在当前帧中中,如果js计算复杂,没有时间再去执行回调,那么在本次渲染中,将不再执行。

在使用了requestIdleCallback之后,我们可以将大块任务拆分开来执行

例如说我们有一个很耗时的功能模块

let s = 0
while (s < 20000) {
   console.log(1)
   s++
}

当然,项目中可能还有其他一些代码逻辑

let s = 0
while (s < 20000) {
   console.log(1)
   s++
}
console.log(2)

此时,我们可以看到浏览器会一直输出1直到输出够20000个,才会在浏览器中输出2。

当然,如果你的电脑性能足够强大使得你在一瞬间执行完成,请调大执行的次数

所以,对于此处的高耗时任务,我们可以使用requestIdleCallback来进行改写。

let s = 0
function fn() {
  if (s < 20000) {
     console.log(1)
     s++
     requestIdleCallback(fn)
  }
}
requestIdleCallback(fn)
console.log(2)

当然,这样使用的前提是requestIdleCallback中的任务的优先级允许你这样做。

Web Worker开启多线程

在主线程上,不仅要渲染页面,还要执行js,如果js的执行时间过长,就会导致失帧,所以,有些工作我们可以不在主线程上进行。例如纯计算工作。利用web worker可以让浏览器建立一个条新的线程去执行指定内容。

浏览器之所以要使用单线程去执行js,是因为js中涉及的某些操作必须严格的去遵守他的顺序执行,例如dom操作。所以,在开启多线程时,严禁执行dom操作。具体的执行规范可点击

通常来说,一些计算性较强的任务我们通常会交由后端去完成,因为前端js的长时间执行会导致堵塞。但随着前端逐渐接近于一个完整的应用,对于计算的需求也逐渐变大,当任务被交由后端后,必然会涉及到http请求,那么该功能便依赖于网络。所以,在现在的业务开发需求中,前端也应承担其能力范围内的计算任务。

例如在

const number = document.querySelector('#number'); // input表单
const result = document.querySelector('#result'); // div或者其他用于展示的内容的标签

number.addEventListener('change', function ({ target: { value } }) {
  result.innerHTML = worker(value)
  console.log('其他业务')
})

function worker(numbers) {
  const startTime = new Date().getTime();
  let sum = 0, i;

  if (numbers === 0) {
    return 0;
  }

  for (i = 0; i < numbers; i++) {
    sum += i;
  }

  const endTime = new Date().getTime();
  return (endTime - startTime) / 1000
}

这段代码中,worker是一个极其耗时的任务,并且在他执行完成后,会输出它的执行时间。

我们在输入框中输入1000000000,回车后浏览器开始进行计算,几秒钟后,他会输出时间,并且在控制台中打印出其他业务。也就是,这个worker堵塞了当前js的执行。

const number = document.querySelector('#number');
const result = document.querySelector('#result');

const myWorker = new Worker("worker.js");

number.addEventListener('change', function ({ target: { value } }) {
  myWorker.postMessage(value)
  console.log('其他业务')
})
myWorker.onmessage = (e) => {
  result.innerHTML = e.data;
};

// worker.js
onmessage = ({ data: numbers }) => {
  const startTime = new Date().getTime();
  let sum = 0, i;

  if (numbers === 0) {
    return 0;
  }

  for (i = 0; i < numbers; i++) {
    sum += i;
  }

  const endTime = new Date().getTime();
  postMessage((endTime - startTime) / 1000)
};

使用worker线程后,我们便可以将此任务使用其他线程去执行,主线程则继续执行已有业务。

Shared Workers

SharedWorker 接口代表一种特定类型的 worker,可以从几个浏览上下文中访问,例如几个窗口、iframe 或其他 worker。例如,在多页面应用中,我们仍可以让不同的html文件同时调用一个worker脚本来执行计算。

// 1.html / 2.html
const number = document.querySelector('#number');
const result = document.querySelector('#result');

const myWorker = new SharedWorker("worker.js").port;

number.addEventListener('change', function ({ target: { value } }) {
  myWorker.postMessage(value)
  console.log('其他业务')
})

myWorker.onmessage = (e) => {
  result.innerHTML = e.data;
};
// worker.js
onconnect = (e) => {
  const port = e.ports[0];

  port.addEventListener('message', function ({ data: numbers }) {
    const startTime = new Date().getTime();
    let sum = 0, i;

    if (numbers === 0) {
      return 0;
    }

    for (i = 0; i < numbers; i++) {
      sum += i;
    }

    const endTime = new Date().getTime();
    port.postMessage((endTime - startTime) / 1000)
  });

  port.start();
};

在多页面可以使用同一个js文件后,我们也可以利用此特性进行页面之间的通信

此外web api还提供了Service WorkersAudio workers来支持不同业务情况的workers

参考文章:

https://developers.google.com/web/updates/2018/09/inside-browser-part3#compositing

https://www.html5rocks.com/en/tutorials/internals/howbrowserswork/#The_rendering_engine

https://yeefun.github.io/event-loop-in-depth/

https://www.freecodecamp.org/news/web-workers-in-action-2c9ff33be266/