我有一颗复杂的心,很少人能读懂。-- 浏览器内核篇(一)

369 阅读13分钟

写在前面

刚学习前端的时候,基本上都是绕着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渲染出来。

网页渲染的十分复杂,大致总结为上述过程。我们可以看看访问网页时,浏览器的渲染流程切片图。

htmlcss解析.png

这里可以看到渲染过程中,先分析HTML,再分析样式表。

浏览器页面显示过程.png

这里直接为了展示页面渲染过程,略过了渲染树生成的步骤。图上主要有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文件非常耗时,那么渲染就迟迟不能开始渲染的工作,因为渲染树还没有构建好,浏览器界面就会长时间白屏

例如这样:

image.png

所以我们不应该将script标签直白地放在head中,可以选择放在body标签的底部,这样HTML代码开始解析,结合已经构建好的CSSOM树,渲染流程得以开始,就能够避免长时间的白屏。

image.png

当然把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线程渲染出页面
  • 重排重绘

浏览器内核的知识远不止这些,我也还有需要了解的知识。如果你觉得本文对你有点帮助,还请给个小赞,这将是我持续创作的动力,感谢!

本人拙见 若有错误 敬请指正