写在前面
刚学习前端的时候,基本上都是绕着HTML JS CSS三个内容兜兜转转。一度经历过 写HTML元素取名纠结 JS API看的眼花缭乱,CSS写得想要自闭。不过好在熟能生巧,我在常常练习中慢慢地找到了状态。
最近,我又写了一个小demo,但是却在<body></body>中 把<script></script>放错了位置😭。于是就想好好理一理浏览器处理HTML JS CSS文件的机制。
浏览器内核
浏览器的内核是HTML JS CSS代码交互的核心地区,之所以能够读懂这些代码,主要得益于内核的两个引擎。
- 渲染引擎(例如WebKit的WebCore部分):
渲染引擎负责构建并更新页面。
具体来说,渲染引擎:负责解析HTML CSS代码,构建出渲染树,并通过计算HTML元素在页面上的布局,将渲染树的元素绘制在页面上呈现给用户。
- JS引擎(例如V8引擎):
JS引擎负责执行JS代码,对用户与网页的交互做出响应。
具体来说:JS引擎负责:解析JS代码生成语法树,执行计算机编译好的代码,通过调用栈和任务队列进行事件循环,配合垃圾回收器最终完成用户与网页的交互行为。
浏览器工作的执行引擎固然重要,但是引擎运行任务所在线程也十分值得我们考究。
一、浏览器的线程
线程实际上是计算机操作系统中的一个概念:是操作系统(OS)进行资源调度的最小单位。
在浏览器中:能够处理或者并行处理不同任务的执行单元叫做线程。主要有以下几种:
1.主线程(Main Thread)
- 解析HTML,构建DOM树(文档对象模型)。
- 解析CSS构建,CSSOM树(CSS对象模型)。
- 执行JavaScript代码。
- 布局(Layout),计算渲染树内元素的布局。
- 绘制(Paint),将元素绘制到页面上。
特点:浏览器内核的核心线程,主线程的性能对于页面的响应影响深远。
2.渲染线程(Compositor Thread)
- 合成(Compositor):将不同的图层(Layer)合成最终的页面图像。
- 动画效果:处理页面的滚动以及CSS动画。
特点:渲染线程在GPU上运行,通过分层与合成来减少重绘的开销。
3.工作线程(Worker Thread)
- Web Workers:在后台处理主线程之外的任务,如网络请求等。
特点:不能直接访问DOM,通过消息传递机制与主线程通信。
4. 网络线程(Network Thread)
- 网络请求:处理http/https请求,下载页面资源(HTML、CSS、JS、图像等)。
- 缓存决策:在浏览器发送网络请求时,网络线程会做出决策,提高资源加载速度。
特点:独立于主线程运行,可以并行处理多个网络请求,确保网络请求不阻塞主线程。
5.存储线程(Storage Thread)
- 本地存储:管理LocalStorage、SessionStorage、IndexedDB等浏览器存储机制。
特点:确保数据读取不会阻塞主线程,提高数据的访问效率。
其实,浏览器内核的线程还有很多,但是以上的基础认知对于我接下来要解释的内容足够了。
我们现在先了解一下包含HTML JS CSS代码在内的文件是如何被浏览器读处理,最终呈现出一个页面的。
二、网页渲染过程
当浏览器运行时,页面是如何渲染出来的呢?这里我们主要看渲染引擎做了哪些事。
- 1.解析
解析HTML构建DOM树(文档对象模型),解析CSS构建CSSOM树(CSS对象模型)。
- 2.构建渲染树
结合DOM树和CSSOM树,构建渲染树,渲染树包含可见节点(非display:none)及其样式信息。
- 3.图层布局计算(Layout/Reflow)
渲染引擎会计算出渲染树中每个节点的大小和位置,这一过程也称为"布局"或"重排"。
- 4.元素分层(Layering)
在确定布局之后,浏览器会决定元素渲染时的所在图层。这个过程可能会和布局交错进行。
- 5.图层绘制(Painting)
绘制操作会将布局好的内容转化为屏幕上的像素,再将像素数据输出到帧缓冲区,等待GPU线程渲染。
- 6.合成(Compositor)
所有图层被结合在一起,形成最终的页面,页面由GPU渲染出来。
网页渲染的十分复杂,大致总结为上述过程。我们可以看看访问网页时,浏览器的渲染流程切片图。
这里可以看到渲染过程中,先分析HTML,再分析样式表。
这里直接为了展示页面渲染过程,略过了渲染树生成的步骤。图上主要有5个步骤:
重新计算样式:当窗口大小改变、元素属性被修改时,浏览器会重新计算被修改元素的样式,更新CSSOM树和渲染树的对应样式。(CSSOM树和渲染树并不会重新加载)
预绘制:其实这个过程是图层布局计算过程(Layout),因为重新计算了样式(元素位置大小可能改变),所以需要重新对元素进行布局。
画图:代表图层绘制(Painting),这个过程其实和分层的顺序可以交换,具体来看浏览器的渲染策略。因为每个元素布局已经计算好了,先转化为像素点或者先分层都有可能。
分层:根据元素位置信息将元素分到特定图层。
提交:将最终的图层提交到渲染进程。
注: 浏览器的渲染策略非常灵活,在GPU渲染真实图像之前,我们可以把所有元素理解为一份待定的数据,这样对我们理解理论与实际的差异可能更友好。大致的过程,也就是:解析->构建渲染树->图层布局计算->元素分层->图层绘制->合成。
三、渲染阻塞
页面渲染的过程中,除了最终的合成需要借助渲染线程去调度GPU,大部分任务还是在主线程当中完成的。没错,就是我们常说的JS是单线程执行”的那个线程。
事实上,为了确保用户的操作和界面更新的一致性,在设计之初渲染引擎和JS引擎就都依赖主线程(Main Thread)。但是正因为页面渲染和JS代码的执行都需要主线程,我们在编写JS代码时需要格外小心。
任何长时间的JS脚本都会阻塞界面的渲染,造成用户体验不佳,接下来我们考虑JS代码在主线程的执行时机。
一般的html文档长这样:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>页面</title>
<link rel="stylesheet" href="style.css">
<script src="./index.js"></script>
</head>
<body>
//HTML代码...
//可能的JS代码...
</body>
</html>
对于这样一份代码,浏览器只会从上往下读取。
当代码执行到 <link rel="stylesheet" href="style.css"> 时,渲染引擎会暂停解析HTML,而是先去构建CSSOM树。
当执行到 <script src="./index.js"></script> 时,浏览器就会乖乖将主线程的使用权给JS引擎,script内的JS代码开始运行。
其实这样就会带来问题,如果这个JS文件非常耗时,那么渲染就迟迟不能开始渲染的工作,因为渲染树还没有构建好,浏览器界面就会长时间白屏。
例如这样:
所以我们不应该将script标签直白地放在head中,可以选择放在body标签的底部,这样HTML代码开始解析,结合已经构建好的CSSOM树,渲染流程得以开始,就能够避免长时间的白屏。
当然把JS代码全部放到body标签底部实在是不够优雅,我们最好使用script标签在head中引入,并加上defer标签:
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>页面</title>
<link rel="stylesheet" href="style.css">
<script defer src="./index.js"></script>
</head>
注意,这里的script标签我添加了defer属性,他能够确保异步加载JS脚本,并且在HTML文档解析完成后再执行。
总之,主线程就像一个只会埋头苦干的牛马🐂,执行渲染任务还是JS代码,取决于先遇到什么任务,但是作为开发者我们应该确保渲染过程不被JS代码阻塞,让用户先看到界面十分重要。
四、重排和重绘
接下来我们聊点细节且高级的内容,重排(Reflow)和重绘(Repaint)。同样是阻塞渲染,但是,重排和重绘比JS代码纯粹的“插队行为”要更难以发现,这正是开发中的重点。
- 重排(Reflow):重排字面上理解就是布局重新排列,也就是上面提到的图层布局计算。当元素的位置或者大小改变时,就需要重新计算该元素的布局。
因素:元素位置大小改变,DOM节点的增删,浏览器窗口的大小变化,CSS动画,使用JS代码中有关布局相关的API(offsetWidth)等。
🌰: 假设我们在设计一个社交应用。要求有一个动态帖子功能,每当有新的帖子被创建发布后,用户能够快速看到该文章。
//HTML部分
<div id="container">
<form id="dynamicForm">
<!-- 动态生成的表单字段将添加到这里 -->
</form>
<button id="addField">添加字段</button>
</div>
//JS部分
const newsFeedElement = document.getElementById('newsFeed');
// 模拟从服务器获取的一系列帖子数据
const postsData = [
{ title: "Post1", content: "第一条帖子" },
{ title: "Post2", content: "第二条帖子" },
{ title: "Post3", content: "第三条帖子" }
];
postsData.forEach(post => {
const postElement = document.createElement('div');
postElement.classList.add('post');
const titleElement = document.createElement('h2');
titleElement.textContent = post.title;
const contentElement = document.createElement('p');
contentElement.textContent = post.content;
postElement.appendChild(titleElement);
postElement.appendChild(contentElement);
// 直接将每个帖子元素添加到 DOM 中
newsFeedElement.appendChild(postElement);
});
如果这个社交应用十分火爆,在3秒内有三篇帖子过审,那么在三秒内,这里进行三次DOM树更新操作,引发了三次重排。
其实对于帖子的动态更新没必要纠结一两秒,我们完全可以设定对于一分钟内的帖子统一操作。这里我们就需要使用文档碎片(DocumentFrame),它是一种内存中的临时DOM节点组,能够积累DOM节点,只在最后一次引发重排。
const newsFeedElement = document.getElementById('newsFeed');
// 模拟从服务器获取的一系列帖子数据
const postsData = [
{ title: "Post1", content: "第一条帖子" },
{ title: "Post2", content: "第二条帖子" },
{ title: "Post3", content: "第三条帖子" }
];
// 使用 DocumentFragment 创建和添加多个帖子
const fragment = document.createDocumentFragment();
postsData.forEach(post => {
const postElement = document.createElement('div');
postElement.classList.add('post');
const titleElement = document.createElement('h2');
titleElement.textContent = post.title;
const contentElement = document.createElement('p');
contentElement.textContent = post.content;
postElement.appendChild(titleElement);
postElement.appendChild(contentElement);
// 将每个帖子元素添加到 DocumentFragment 中
fragment.appendChild(postElement);
});
// 当所有帖子都准备好后,一次性将 DocumentFragment 添加到 DOM 中
3newsFeedElement.appendChild(fragment);
这样一操作,不管有多少帖子需要动态展示(具体更新时间待定),都只需要操作一次DOM树,引发一次重排。
- 重绘(Repaint):重绘理解为重新绘制,是上面所提及的图层绘制,当页面重排,或者元素的颜色属性(非几何属性)改变时,元素的像素点数据需要更新。
因素:改变元素颜色信息,改变文本内容,改变元素的可见性,CSS中使用某些伪类(:hover,:focus)等。
🌰:如果页面上有一个元素需要在鼠标悬浮时改变其样式,我们可能会这样设计。
//HTML部分
<div id="box" style="width: 100px; height: 100px; background-color: blue;"></div>
//JS部分
let box = document.getElementById('box');
box.addEventListener('mouseover', function() {
this.style.backgroundColor = 'red';
});
box.addEventListener('mouseout', function() {
this.style.backgroundColor = 'blue';
});
这样悬浮一次会引发一次重绘,离开又会引发一次重绘。这时我们可以使用CSS3的硬件加速特性,换成transition完成效果。
#box {
width: 100px;
height: 100px;
background-color: blue;
transition: background-color 0s;
}
#box:hover {
background-color: red;
}
这样,虽然也会引发两次重绘,但是使用CSS3的硬件加速,契合了浏览器原生的渲染功能,不需要额外的JS开销。类似的属性还有filter、transform、opacity。
🚀来个脑筋急转弯!
1.重排了一定会引起重绘吗?
2.重绘了一定说明重排了吗?
💡:重排了说明元素几何属性(位置大小层级)发生变化,需要重新计算元素的布局,那么元素后续的像素点的数量必定受到影响,所以说重排了一定会引起重绘。
💡:反过来,重绘了说明元素像素点数据改变,除了数量改变,还有可能单纯的视觉效果改变,如颜色和透明度...单一地说重绘,可能其元素的颜色属性改变了,但是布局并没有改变,则此时元素的几何属性没有受到影响,所以,重绘了不一定重排了!
重排和重绘都会影响渲染效率,我们应当避免不必要的重排重绘发生,但是当我们不得不选一个时,我们应该选择重绘,避免重排:
一般来说,重排的代价比较大。
简单理解:想一想,重排了会引起重绘,但是重绘了不一定重排,结果显而易见,多一事不如少一事。
我们再深入理解一下: 对于重排来说,一定涉及到DOM元素的布局计算,这个影响可能就大了去了。
🌰我举个例子,你在食堂排队打饭,这个时候大家都乖乖排好了队,相当于DOM元素的布局已经计算好,确定了下来。但是,前面突然有个人插队(新增DOM节点),这时候所有人都要往后退,相当于后面所有DOM元素布局需要重新计算。
五、减少渲染阻塞的方式
我们从JS和CSS的方向谈谈:
JS
- 异步加载,使用
<script async src="...">标签可以告知浏览器异步加载脚本,即脚本的加载不会阻塞页面的解析,当脚步加载完成再立即执行。 - 异步且延迟加载:使用
<script defer src="...">标签可以告知浏览器异步加载脚本,且在解析完HTML后执行。 - JS模块化:规范导出引入JS代码,减少不必要脚本的执行。
- 使用CDN:对于一些网络的JS脚本资源,使用内容分发网络,且选择引入压缩版本的脚本。
CSS
- 媒体查询:使用媒体查询(@media)来限制一些样式仅在特定情况下加载,如对于不同设备使用不同倍率的图标。
- 避免使用!important:这样的做法会增加样式权重计算的复杂度,增加渲染树的构建时间。
- CSS模块化:规范导出引入CSS样式,减少不必要样式的解析。
总结:
本文讲了:
- 浏览器内核
- 渲染引擎
- JS引擎
- 浏览器的线程
- 主线程
- 渲染线程
- 工作线程
- 网络线程
- 存储线程
- 网页渲染
- 解析HTML/CSS,构建DOM树,CSSOM树
- 结合形成渲染树
- 计算图层中DOM元素布局
- 为DOM元素分层
- 绘制DOM元素的像素点,放入帧缓冲区
- GPU线程渲染出页面
- 重排重绘
浏览器内核的知识远不止这些,我也还有需要了解的知识。如果你觉得本文对你有点帮助,还请给个小赞,这将是我持续创作的动力,感谢!