作为一名前端开发工程师,你或许每天都在与浏览器打交道,编写代码、调试页面、优化性能。但你有没有想过,浏览器是如何在幕后运作的?
深入了解这些机制可以帮助我们更好地理解后续的很多核心知识点,如事件循环机制、渲染阻塞、JavaScript 执行优先级以及多线程编程等。这些知识在日常开发中直接影响着我们如何编写代码来优化页面加载速度、提升用户交互体验和处理复杂的应用逻辑。
浏览器的进程模型
浏览器的进程模型是指浏览器如何组织和管理其内部的各个进程来协调工作,确保高效运行和稳定性。
现代浏览器多采用多进程架构,将不同的功能模块分配到不同的进程中,如浏览器主进程、渲染进程、插件进程等。此设计不仅提高了浏览器的稳定性和安全性,还避免了单个页面崩溃影响其他页面。
通过多进程模型,浏览器能实现更好的资源管理和隔离,使复杂网页应用运行时保持流畅。
为了深入理解浏览器的多进程模型,我们需要明确两个关键概念:进程 与 线程。
进程
进程 是操作系统中运行应用程序的最小单位,每个进程都有自己独立的内存空间和资源。 每个进程都有自己独立的内存空间、系统资源(如文件句柄、网络连接)以及运行上下文。进程之间通常是相互隔离的,即使一个进程崩溃,也不会直接影响到其他进程。
在浏览器中,进程的隔离设计是其多进程架构的重要特性,大幅提高了浏览器的安全性、稳定性以及性能。以下是一些典型场景说明:
- 页面崩溃隔离:如果一个页面发生崩溃,例如运行了错误的 JavaScript 或占用过多的资源,这个问题仅会影响该页面对应的渲染进程,而不会影响其他页面或浏览器整体的运行。
- 安全性增强:恶意页面无法直接访问或修改其他页面的内容,也无法通过渲染进程直接访问用户的系统资源,因为所有渲染进程都在沙盒环境中运行。
- 插件隔离:插件(如 Flash)运行在独立的插件进程中,即使插件发生故障或崩溃,也不会影响浏览器主进程或其他页面的渲染进程。
在手机上,不同的应用程序通常运行在不同的进程中。例如,QQ、微信、王者荣耀,它们分别是独立的进程,彼此隔离。如果 QQ 因某种原因崩溃,并不会影响微信或王者荣耀的正常运行。并且每个进程有自己独立的内存空间,保证了数据安全性,比如在 QQ 的聊天记录不会因为其他应用的操作而泄露。
而不同的进程之间也不是完全不能进行通信,只要双方都同意,它们可以通过操作系统提供的 进程间通信(IPC,Inter-Process Communication)机制 来实现数据的传递与协作。
线程
线程 是进程中的更小执行单元,一个进程至少拥有一个线程。通常在进程启动时,会自动创建一个线程来运行代码,该线程称为主线程,负责执行程序的主任务,如果主线程结束,整个进程通常也会随之终止,即使其他子线程仍在运行。
当一个程序需要同时处理多个任务时,可以通过创建多个 子线程 来实现并发执行。子线程独立于主线程运行,但共享进程的资源(如内存、文件句柄)。这种设计既提高了执行效率,又保持了资源使用的一致性。
线程与进程的关系可以类比为“部门”和“员工”:一个进程可以包含多个线程,就像一个部门有多个员工一起完成任务。每个员工都负责特定的工作,但共享同一个部门的资源,例如办公设备、文件或会议室。类似地,线程共享进程的资源(如内存、文件句柄、网络连接),这使线程之间的协作更加高效。
比如在《王者荣耀》中,除了 主线程 负责处理核心的游戏逻辑和用户交互(如界面点击、技能释放等),还有多个 子线程 分工协作提升性能。
比如,游戏线程 负责计算角色的状态和技能效果,网络线程 处理与服务器的数据传输,确保玩家操作的实时同步,音效线程 播放技能音效和背景音乐,渲染线程 绘制动态画面并提升帧率,物理线程 负责碰撞检测和技能轨迹计算,AI线程 则控制兵线、野怪等角色行为。
多线程的高效协作,保障了游戏的流畅性和响应速度。
浏览器有哪些进程和线程
现代浏览器(如 Chrome)采用 多进程多线程架构,为了避免相互影响,减少连环崩溃的概率,启动浏览器后会自动启动多个进程。浏览器将不同的功能模块划分到独立的进程中,通过进程和线程的协同工作,极大地提升了稳定性、安全性和性能。
在浏览器中,最主要的进程有 浏览器进程(主进程) 、 网络进程 、 渲染进程 等等。
浏览器主进程 是整个浏览器的核心,负责管理用户界面(如地址栏、书签栏、标签页),控制浏览器生命周期(例如创建和销毁标签页),并协调其他进程之间的通信。它还负责处理磁盘操作,例如下载文件、存储书签等,同时确保浏览器整体的顺畅运行。
渲染进程 专注于页面内容的解析和绘制。渲染进程启动后会开启一个渲染主线程,用于解析 HTML 和 CSS、执行 JavaScript 并管理 DOM 操作,最终将页面内容呈现到屏幕上。默认情况下,浏览器会为每一个标签页分配一个独立的渲染进程,以确保页面的隔离性和稳定性。【当前是默认模式(一个标签页一个进程),将来可能会进行变更,比如桌面平台采用全站点隔离(每个进程一个站点)、Android 采用部分站点隔离,以及其他模式,具体在 chrome 官方说明文档】
网络进程 负责处理所有与网络相关的任务,包括发起 HTTP/HTTPS 请求、WebSocket 通信,以及管理资源的下载和缓存。它独立于其他进程运行,确保即使网络任务出现问题,也不会影响页面的稳定性。
另外还有 GPU 进程 负责与 GPU 硬件交互,处理复杂的图形任务,插件进程 专门运行浏览器的插件等等。浏览器通过将任务分配到多个独立的进程和线程中,实现了模块间的隔离,确保了高性能、稳定性和安全性。
我们可以在浏览器的任务管理器中查看当前的所有进程。
在这些进程中,跟我们前端息息相关的就是 渲染进程,因为它直接负责页面的解析、执行和渲染,是我们前端开发内容最终呈现的核心环节。
渲染主线程是如何工作的?
渲染主线程是浏览器中最繁忙的线程,承担了页面渲染和交互的大量核心任务。
它需要处理以下工作:解析 HTML 和 CSS、计算样式、执行布局和绘制操作,同时还负责管理页面的图层,将页面呈现到屏幕上。此外,渲染主线程还需要执行 JavaScript,包括全局代码的执行、事件处理函数的回调、计时器的回调函数等。
这些任务共同决定了页面的渲染性能和用户交互的流畅性,因此优化主线程的工作负载是前端性能优化的关键所在。
浏览器要处理如此多的任务,主线程就面临了一个前所未有的难题:如何去调度任务?
浏览器的主线程需要应对各种任务,比如 JavaScript 执行、事件处理、定时器回调、页面渲染等。这些任务可能同时发生,并且都需要主线程来处理,但主线程一次只能执行一个任务。因此,任务的优先级和调度顺序成为了浏览器需要解决的核心问题。
以下是一些典型场景:
-
JavaScript 执行中断与事件处理冲突:当主线程正在执行一个 JavaScript 函数时,如果用户点击了按钮,会触发一个点击事件。那么,主线程是否应该立即中断当前的任务去处理这个点击事件呢?如果立即中断,可能会导致当前任务无法完成;如果继续执行当前任务,用户可能会感受到延迟。
-
JavaScript 执行与计时器回调冲突:当主线程正在执行一个 JavaScript 函数时,如果某个计时器(如
setTimeout)到达了指定时间,是否应该立即执行它的回调函数?如果不立即执行,可能影响计时器的精度;但如果中断当前任务,又会破坏任务的连贯性。 -
事件与计时器的优先级:如果浏览器进程同时通知渲染进程“用户点击了按钮”和“某个计时器到达了时间”,主线程应该优先处理哪个?用户交互的响应可能需要更高的优先级,但计时器任务也可能是重要的业务逻辑的一部分。
-
渲染任务与脚本执行冲突:如果页面需要在 16ms 内完成渲染 以维持流畅的帧率,而主线程此时被 JavaScript 脚本占用,是否应该中断脚本以优先完成渲染任务?
为了解决这些问题,渲染主线程想出了一个绝妙的主意来处理这些任务:排队。
浏览器通过 任务队列 和 事件循环 的机制,将各种任务进行分类排队,按照一定的优先级和顺序逐一执行。这种排队机制既保证了主线程任务的有序性,又能够动态调整任务的优先级,从而在多种场景下提供最佳的用户体验。
在浏览器的运行机制中,渲染主线程在启动时会进入一个无限循环,专门用于处理任务。这种循环机制是浏览器任务调度的核心,被称为 事件循环(消息循环) 。
graph LR
A(主线程启动) --> B{消息队列中是否有任务?}
B -- 是 --> C(取出队列第一个任务)
C --> D(执行任务)
D --> B
B -- 否 --> E(进入休眠状态)
F(新任务加入消息队列) --> G{主线程休眠状态?}
G -- 是 --> H(唤醒主线程)
H --> B
G -- 否 --> B
在浏览器的运行机制中,渲染主线程启动后会进入一个 无限循环,用于处理页面的各种任务。
每一次循环,主线程都会检查消息队列中是否有任务存在。如果消息队列中有任务,主线程会取出第一个任务进行执行,执行完成后返回循环起点继续检查下一个任务。如果消息队列中没有任务,主线程会进入休眠状态,暂时释放资源,直到有新任务到来。
其他线程(包括其他进程的线程)可以随时向消息队列中添加任务,例如用户触发的事件(如点击、输入)、定时器的回调函数以及网络请求的响应数据。这些新任务会被追加到消息队列的末尾,按照先进先出的规则等待主线程处理。如果主线程正处于休眠状态,新任务的加入会唤醒主线程,使其重新进入循环继续工作。
通过这种机制,浏览器确保了任务处理的有序性和高效性。无论任务的来源是什么,主线程都可以按照队列的顺序逐一执行,使得页面渲染和交互能够有条不紊地进行。整个过程被称为 事件循环(消息循环) ,它是浏览器调度任务的核心机制,既能够提升系统资源的利用率,又支持异步任务的高效处理。
无法立即处理的任务——异步
在浏览器运行中,有些操作是无法立即完成的,例如网络请求、定时器到期后的回调、文件读写等。
由于渲染主线程承担着极其重要的工作,无论如何都不能阻塞,如果让渲染主线程等待这些任务的时机达到,就会导致主线程长期处于「阻塞」的状态,从而导致浏览器「卡死」。
因此浏览器需要在不阻塞主线程的情况下,同时支持以下场景:
- 网络请求:例如,向服务器请求数据,响应时间可能在毫秒到数秒不等,无法预测。
- 定时器回调:
setTimeout或setInterval的触发时间不确定。 - 文件操作:读取本地文件或保存数据可能需要较长时间。
- 用户交互:例如等待用户输入或点击,事件发生的时间是不可控的。
如果主线程必须等待这些操作完成才能继续执行其他任务,整个页面将会陷入「阻塞」,导致无法及时响应用户操作或渲染更新。
为了避免这种情况,浏览器通过引入 异步机制 ,将这些任务的等待过程交由消息队列管理,让渲染主线程继续执行其他任务。
异步任务的核心目标是:不阻塞主线程,同时确保任务的正确性和有序性。
例如,当代码执行到
setTimeout时,浏览器不会立即执行其中的回调函数。相反,浏览器会将该回调注册到定时器模块中,开始计时。当计时器到达指定时间后,回调函数被放入消息队列中,等待主线程空闲时再取出并执行。这种设计允许主线程继续处理其他任务,同时确保回调函数在正确的时机被执行。
同样的逻辑适用于网络请求。当发起一个
fetch请求时,主线程不会等待服务器的响应,而是继续执行后续代码。当网络模块收到服务器的响应数据后,会将处理响应的回调函数添加到消息队列,等待主线程处理。
异步任务的执行依赖于浏览器的 事件循环机制。以下是一个典型的过程:
- 主线程遇到异步任务(如
setTimeout、网络请求),将其交给浏览器的对应模块(如定时器模块、网络模块)。 - 浏览器模块在任务完成或达到触发条件时,将回调函数添加到消息队列中。
- 主线程继续执行同步任务,直到消息队列为空。
- 当主线程空闲时,从消息队列中取出回调函数,按顺序执行。
通过这种设计,异步任务可以在后台执行,而主线程能够保持流畅运行。浏览器不会因为等待异步任务的完成而阻塞主线程。
面试题:如何去理解 JavaScript 的异步?
JavaScript 是一门 单线程 的语言,这是因为它运行在浏览器的渲染主线程中,而渲染主线程只有一个。
在浏览器中渲染主线程承担着诸多的工作,解析 HTML 和 CSS、计算样式、执行布局和绘制操作、执行 JavaScript 等都在这里运行。
由于任务繁多,如果使用同步的方式,就极有可能导致主线程产生阻塞,从而导致消息队列中的很多其他任务无法得到执行。这样一来,一方面会导致繁忙的主线程白白的消耗时间,另一方面导致页面无法及时更新,给用户造成卡死现象。
所以浏览器采用异步的方式来避免。具体做法是当某些任务发生时,比如遇到计时器、网络请求、事件监听等需要等待的任务时,主线程将任务交给其他线程去处理,自身立即结束任务的执行,转而执行后续同步代码。
当其他线程完成时,将事先传递的回调函数包装成任务,加入到消息队列的末尾排队,等待主线程调度执行。
在这种异步模式下,浏览器永不阻塞,从而最大限度的保证了单线程的流畅运行。
为什么 JavaScript 会阻塞渲染?
我们先来运行以下代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>JavaScript 阻塞测试</title>
</head>
<body>
<h1>按钮1测试</h1>
<h2>按钮2测试</h2>
<button class="btn1">按钮1</button>
<button class="btn2">按钮2</button>
<script>
const h1 = document.querySelector('h1');
const h2 = document.querySelector('h2');
const btn1 = document.querySelector('.btn1');
const btn2 = document.querySelector('.btn2');
// 死循环指定的时间
function delay(duration) {
let start = Date.now();
while (Date.now() - start < duration) {}
}
btn1.onclick = function () {
h1.textContent = '文本阻塞3s后才被修改!';
delay(3000); // 阻塞3秒钟
};
btn2.onclick = function () {
h2.textContent = '文本被修改!';
};
</script>
</body>
</html>
在这段代码中,当我们点击“按钮1”时,h1.textContent 被设置成新的内容。虽然 JavaScript 代码已经改变了 DOM,但此时页面不会立即更新。这是因为浏览器的渲染引擎会将渲染任务放入队列中,并在当前同步代码执行完成后才会进行页面的重绘和回流。
紧接着,delay(3000) 被调用,这导致主线程运行了一个耗时 3 秒的死循环。由于 JavaScript 是单线程的,主线程被这个同步任务占用,浏览器在此期间无法执行其他任务。这种情况下,页面无法重新渲染,用户交互(如点击“按钮2”)也无法得到响应,浏览器表现为“卡死”状态。
当 delay(3000) 执行期间,如果用户尝试点击“按钮2”,虽然看起来没有任何反应,但该点击事件会被加入到浏览器的事件队列中。等到“按钮1”的事件处理完成后,主线程会从事件队列中取出下一个任务(即“按钮2”的点击事件)并立即执行。由于“按钮2”的事件处理函数没有阻塞主线程,执行速度很快,因此会迅速完成并更新页面内容。这就形成了一种 看起来页面内容卡顿不更新,用户交互无响应,但在长任务结束后立即触发并执行事件处理 的 “假死卡顿” 现象。
为什么会出现这种情况?
浏览器渲染引擎与 JavaScript 引擎是协同工作的。渲染引擎负责解析 HTML 和 CSS,构建 DOM 和 CSSOM 树,并进行布局和绘制。
而 JavaScript 引擎是单线程的,如果某段 JavaScript 代码在主线程长时间执行形成“卡死”,渲染引擎的操作会被暂停,直到 JavaScript 代码执行完毕。这个机制是为了避免在操作 DOM 时,渲染引擎与 JavaScript 引擎发生冲突。
任务有优先级吗?
既然渲染主线程要执行那么多的任务,那这些任务有优先级吗?存不存在某些任务我需要优先去执行,某些任务我们可以放在后面执行呢?
任务没有优先级,在消息队列中先进先出,而消息队列存在优先级。
在浏览器的事件循环和任务调度机制中,任务 本身并没有明确的优先级,而是由 消息队列 (task queue)管理任务的执行顺序。
这一机制意味着虽然浏览器能够处理多种类型的任务(如用户输入、定时器回调、网络请求等),但任务的执行顺序并不是由任务本身的优先级来决定的,而是由消息队列的处理方式和队列中任务的类别、顺序来管理的。
这段话怎么理解呢?
首先,任务没有优先级。因为任务本身并不会被赋予明确的优先级标签。举个例子,如果你在浏览器中运行一个 JavaScript 脚本,它可能会触发一系列的事件(如 DOM 更新、网络请求的响应处理、定时器回调等)。这些任务的类型和来源不同,但它们都会按顺序被加入到消息队列中,等待主线程的处理。每个任务的执行时间和顺序都受限于它在队列中的位置,而不是任务本身的优先级。
虽然任务本身没有优先级,但是浏览器会通过将任务放入不同的消息队列的方式来管理任务的执行顺序。
根据 W3C 的最新解释:
- 每个任务都有一个任务类型,同一个类型的任务必须在一个队列,不同类型的任务可以分属于不同的队列。 在一次事件循环中,浏览器可以根据实际情况从不同的队列中取出任务执行。
- 浏览器必须准备好一个微队列,微队列中的任务优先所有其他任务执行 html.spec.whatwg.org/multipage/w…
随着浏览器的复杂度急剧提升,W3C 不再使用宏队列的说法,而是细分为微队列和其他队列。
在目前 chrome 的实现中,至少包含了下面的队列:
- 微队列:用户存放需要最快执行的任务,优先级「最高」
- 交互队列:用于存放用户操作后产生的事件处理任务,优先级「高」
- 延时队列:用于存放计时器到达后的回调任务,优先级「中」
- ....
目前来说我们只需要知道渲染主线程执行完全局 JavaScript 代码后会优先执行微队列中的任务。
微队列的任务总是优先于其他队列中的任务执行,每次事件循环结束时都会检查并清空微队列,它的优先级最高。
至于其他的一些队列可以进行一些简单的了解,但实际不太需要考虑。
总结:JavaScript 的事件循环
事件循环又叫做消息循环,是浏览器渲染主线程的工作方式。
在 Chrome 的源码中,它开启一个不会结束的 for 循环,每次循环从消息队列中取出第一个任务执行,而其他线程只需要在合适的时候将任务加入到队列末尾即可。
过去把消息队列简单分为宏队列和微队列,这种说法目前已无法满足复杂的浏览器环境,取而代之的是一种更加灵活多变的处理方式(微队列和其他队列)。
根据 W3C 官方的解释,每个任务有不同的类型,同类型的任务必须在同一个队列,不同的任务可以属于不同的队列。而不同任务队列会有不同的优先级,在一次事件循环中,由浏览器自行决定取哪一个队列的任务。但浏览器必须有一个微队列,微队列的任务一定具有最高的优先级,必须优先调度执行。