浏览器渲染原理

进程和线程

  • 进程是操作系统资源分配的基本单位,进程包含线程
  • 线程是由进程所管理的,为了提升浏览器的稳定性和安全性,浏览器采用了多进程模型

浏览器中的5个进程

image-20240614144326928

  • 浏览器进程:负责页面显示、用户交互、子进程管理、提供存储等
  • 渲染进程:每个浏览器页签有单独的渲染进程(进程通信是IPC),核心用于渲染页面
  • 网络进程:主要处理网络资源加载(HTML、CSS、JS等)
  • GPU进程:3D绘制,提高性能
  • 插件进程:chrome中安装的一些插件

从输入URL到浏览器显示页面发生了什么?

  • 用户输入url地址(关键字会将关键字根据默认的引擎生成地址)会开始导航,浏览器进程里面做

    • 先去查找缓存,检测缓存是否过期,直接返回缓存中内容
    • 如果是首次访问,看域名是否被解析过,DNS协议将域名解析成ip地址(基于UDP),ip+端口号 host
    • 如果请求是https,会进行SSL协商,保证传输的安全性
    • 根据ip地址进行寻址,排队等待,同一时间最多能发送6个请求
    • tcp创建连接,三次握手传输。利用tcp传输数据(拆分成数据包,有序、可靠),服务器会按照顺序接收
    • http1.1中http请求,用keep-alive下次传输数据时,可以复用上次创建的连接
    • 服务器接收到数据后(响应式 响应头 响应体)
    • 服务器返回301 302浏览器会进行重定向操作
    • 服务器304去查询浏览器缓存
  • 浏览器进程会准备一个渲染进程用于渲染页面

  • 网络进程加载资源,最终将加载的资源来交给渲染进程来处理

  • 渲染完毕显示

渲染进程

image-20240614152101419

  • 浏览器无法直接使用 HTML,需要将 HTML 转化成 DOM 树。(document)
  • 浏览器无法解析纯文本的 css 样式,需要对 css 进行解析,解析成 styleSheets。CSSOM (document. styleSeets)
    • css样式不会阻塞html解析(link是异步加载)
    • 样式要放在头部,如果样式放在底部,可能会导致重绘效果
  • 计算出 DOM 树中每个节点的具体样式(Attachment)
  • 创建渲染(布局)树,将 DOM 树中可见节点,添加到布局树中。并计算节点渲染到页面的坐标位置。(layout)
  • 通过布局树,进行分层(根据定位属性、透明属性、transform 属性、clip 属性等)生产图层树
  • 将不同图层进行绘制,转交给合成线程处理。最终生产页面,并显示到浏览器上(Painting,Display)

浏览器是如何渲染页面的?

当浏览器的网络线程收到 HTML 文档后,会产生一个渲染任务,并将其传递给渲染主线程的消息队列。

在事件循环机制的作用下,渲染主线程取出消息队列中的渲染任务,开启渲染流程。


整个渲染流程分为多个阶段,分别是:HTML解析、样式计算、布局、分层、绘制、分块、光栅化、画。

每个阶段都有明确的输入输出,上一个阶段的输出会成为下一个阶段的输入。

这样,整个渲染流程就形成了一套组织严密的生产流水线。


渲染的第一步是解析HTML

解析过程中遇到 CSS 解析 CSS,遇到 JS 执行 JS。为了提高解析效率,浏览器在开始解析前,会启动一个预解析的线程,率先下载HTML中的外部CSS文件和外部的JS文件。

如果主线程解析到link位置,此时外部的CSS文件还没有下载解析好,主线程不会等待,继续解析后续的HTML。这是因为下载和解析CSS的工作是在预解析线程中进行的。这就是CSS不会阻塞HTML解析的根本原因。

如果主线程解析到script位置,会停止解析HTML,转而等待 JS 文件下载好,并将全局代码解析执行完成后,才能继续解析HTML。这是因为 JS 代码的执行过程可能会修改当前的 DOM 树,所以 DOM 树的生成必须暂停。这就是 JS 会阻塞 HTML 解析的根本原因。

第一步完成后,会得到 DOM 树和 CSSOM 树,浏览器的默认样式、内部样式、外部样式、行内样式均会包含在 CSSOM 树中。


渲染的下一步是样式计算

主线程会遍历得到的 DOM 树,依次为树中的每个节点计算出它最终的样式,称之为 Computed Style。

在这一过程中,很多预设值会变成绝对值,比如red会变成rgb(255,0,0);相对单位会变成绝对单位,比如em会变成px

这一步完成后,会得到一棵带有样式的 DOM 树。


接下来是布局,布局完成后会得到布局树。

布局阶段会依次遍历 DOM 树的每一个节点,计算每个节点的几何信息。例如节点的宽高、相对包含块的位置。

大部分时候,DOM 树和布局树并非一一对应。

比如display:none的节点没有几何信息,因此不会生成到布局树;又比如使用了伪元素选择器,虽然 DOM 树中不存在这些伪元素节点,但它们拥有几何信息,所以会生成到布局树中。还有匿名行盒、匿名块盒等等都会导致 DOM 树和布局树无法一一对应。


下一步是分层

主线程会使用一套复杂的策略对整个布局树中进行分层。

分层的好处在于,将来某一个层改变后,仅会对该层进行后续处理,从而提升效率。

滚动条、堆叠上下文、transform、opacity 等样式都会或多或少的影响分层结果,也可以通过will-change属性更大程度的影响分层结果。


再下一步是绘制

主线程会为每个层单独产生绘制指令集,用于描述这一层的内容该如何画出来。


完成绘制后,主线程将每个图层的绘制信息提交给合成线程,剩余工作将由合成线程完成。

合成线程首先对每个图层进行分块,将其划分为更多的小区域。

它会从线程池中拿取多个线程来完成分块工作。


分块完成后,进入光栅化阶段。

合成线程会将块信息交给 GPU 进程,以极高的速度完成光栅化。

GPU 进程会开启多个线程来完成光栅化,并且优先处理靠近视口区域的块。

光栅化的结果,就是一块一块的位图


最后一个阶段就是

合成线程拿到每个层、每个块的位图后,生成一个个「指引(quad)」信息。

指引会标识出每个位图应该画到屏幕的哪个位置,以及会考虑到旋转、缩放等变形。

变形发生在合成线程,与渲染主线程无关,这就是transform效率高的本质原因。

合成线程会把 quad 提交给 GPU 进程,由 GPU 进程产生系统调用,提交给 GPU 硬件,完成最终的屏幕成像。

为什么 transform 的效率高?

  • 因为 transform 既不会影响布局也不会影响绘制指令,它影响的只是渲染流程的最后一个「draw」阶段

  • 由于 draw 阶段在合成线程中,所以 transform 的变化几乎不会影响渲染主线程。反之,渲染主线程无论如何忙碌,也不会影响 transform 的变化

渲染流程

image-20240614163210333

一、解析HTML(Parse HTML)

  • 将HTML解析为DOM树和CSSOM树

image-20231015180439085

二、HTML转DOM树

  • 浏览器中的HTML解析器可以把HTML字符串转换成DOM结构
  • HTML解析器边接收网络数据边解析HTML
  • 解析DOM
    • HTML字符串转Token
    • Token栈用来维护节点之间的父子关系,Token会依次压入栈中
    • 如果是开始标签,把Token压入栈中并且创建新的DOM节点并添加到父节点的children中
    • 如果是文本Token,则把文本节点添加到栈顶元素的children中,文本Token不需要入栈
    • 如果是结束标签,此开始标签出栈

image-20231015182209370

client\request.js

+const htmlparser2 = require('htmlparser2');
const http = require('http');
const main = require('./main.js');
const network = require('./network.js');
const render = require('./render.js');
const host = 'localhost';
const port = 80;
+Array.prototype.top = function () {
+    return this[this.length - 1];
+}
/** 浏览器主进程 **/
main.on('request', function (options) {
    //2.主进程把该URL转发给网络进程
    network.emit('request', options);
})
//开始准备渲染页面
main.on('prepareRender', function (response) {
    //5.主进程发送提交导航消息到渲染进程
    render.emit('commitNavigation', response);
})
main.on('confirmNavigation', function () {
    console.log('confirmNavigation');
})
main.on('DOMContentLoaded', function () {
    console.log('DOMContentLoaded');
})
main.on('Load', function () {
    console.log('Load');
})

/** 网络进程 **/
network.on('request', function (options) {
    //3.在网络进程中发起URL请求
    let request = http.request(options, (response) => {
        //4.网络进程接收到响应头数据并转发给主进程
        main.emit('prepareRender', response);
    });
    //结束请求体
    request.end();
})

/** 渲染进程 **/
//6.渲染进程开始从网络进程接收HTML数据
render.on('commitNavigation', function (response) {
+   const headers = response.headers;
+   const contentType = headers['content-type'];
+   if (contentType.indexOf('text/html') !== -1) {
+       //1. 渲染进程把HTML转变为DOM树型结构
+       const document = { type: 'document', attributes: {}, children: [] };
+       const tokenStack = [document];
+       const parser = new htmlparser2.Parser({
+           onopentag(name, attributes = {}) {
+               const parent = tokenStack.top();
+               const element = {
+                   type: 'element',
+                   tagName: name,
+                   children: [],
+                   attributes,
+                   parent
+               }
+               parent.children.push(element);
+               tokenStack.push(element);
+           },
+           ontext(text) {
+               if (!/^[\r\n\s]*$/.test(text)) {
+                   const parent = tokenStack.top();
+                   const textNode = {
+                       type: 'text',
+                       children: [],
+                       attributes: {},
+                       parent,
+                       text
+                   }
+                   parent.children.push(textNode);
+               }
+           },
+           /**
+            * 在预解析阶段,HTML发现CSS和JS文件会并行下载,等全部下载后先把CSS生成CSSOM,然后再执行JS脚本
+            * 然后再构建DOM树,重新计算样式,构建布局树,绘制页面
+            * @param {*} tagname 
+            */
+           onclosetag() {
+               tokenStack.pop();
+           },
+       });
+       //开始接收响应体
+       const buffers = [];
+       response.on('data', (buffer) => {
+           //8.渲染进程开始HTML解析和加载子资源
+           //网络进程加载了多少数据,HTML 解析器便解析多少数据。
+           parser.write(buffer.toString());
+       });
+       response.on('end', () => {
-           //let resultBuffer = Buffer.concat(buffers);
-           //let html = resultBuffer.toString();
-           console.dir(document, { depth: null });
+           //7.HTML接收接受完毕后通知主进程确认导航
+           main.emit('confirmNavigation');
+           //触发DOMContentLoaded事件
+           main.emit('DOMContentLoaded');
+           //9.HTML解析完毕和加载子资源页面加载完成后会通知主进程页面加载完成
+           main.emit('Load');
+       });
+  }
})

//1.主进程接收用户输入的URL
+main.emit('request', { host, port, path: '/html.html' });
let document = {
    type: 'document',
    children: [
        {
            type: 'element',
            tagName: 'html',
            children: [
                {
                    type: 'element',
                    tagName: 'body',
                    children: [
                        {
                            type: 'element',
                            tagName: 'div',
                            children: [
                                {
                                    type: 'text',
                                    text: 'hello'
                                }
                            ]
                        },
                        {
                            type: 'element',
                            tagName: 'div',
                            children: [
                                {
                                    type: 'text',
                                    text: 'world'
                                }
                            ]
                        }
                    ]
                }
            ]
        }
    ]
}

CSS转stylesheet,构建CSSOM树

  • 渲染进程把CSS文本转为浏览器中的stylesheet
  • CSS来源可能有link标签、style标签和style行内样式
  • 渲染引擎会把CSS转换为document.styleSheets
  • CSS也会被解析成CSSOM(CSS Object Model),也是树形结构,根节点(StyleSheetList)是网页中所有的样式表,二级子节点可能包含内部样式表外部样式表内联样式表浏览器默认样式表(取决于代码中是否有这些内容),如果有两个<link>,则会出现两个外部样式表节点

除了浏览器默认样式表内部样式表外部样式表内联样式表都可以通过 JS 访问到。

  • 内部样式表和外部样式表:使用
document.styleSheets

可以访问到一个数组,元素是样式表对象。

  • 使用document.styleSheets[0].addRule("div", "border: 1px solid red important")可以让页面上的所有div标签的边框变成红色,这种做法与传统的“获取所有div标签,再设置其style”的做法不同。

  • 内联样式表:使用dom.style访问

image-20231015195021172

index.html

<html>
+<head>
+    <style>
+        div {
+            color: red;
+        }
+    </style>
+</head>
<body>
    <div>hello</div>
    <div>world</div>
</body>
</html>

client\request.js

const htmlparser2 = require('htmlparser2');
const http = require('http');
+const css = require("css");
const main = require('./main.js');
const network = require('./network.js');
const render = require('./render.js');
const host = 'localhost';
const port = 80;
Array.prototype.top = function () {
    return this[this.length - 1];
}
/** 浏览器主进程 **/
main.on('request', function (options) {
    //2.主进程把该URL转发给网络进程
    network.emit('request', options);
})
//开始准备渲染页面
main.on('prepareRender', function (response) {
    //5.主进程发送提交导航消息到渲染进程
    render.emit('commitNavigation', response);
})
main.on('confirmNavigation', function () {
    console.log('confirmNavigation');
})
main.on('DOMContentLoaded', function () {
    console.log('DOMContentLoaded');
})
main.on('Load', function () {
    console.log('Load');
})

/** 网络进程 **/
network.on('request', function (options) {
    //3.在网络进程中发起URL请求
    let request = http.request(options, (response) => {
        //4.网络进程接收到响应头数据并转发给主进程
        main.emit('prepareRender', response);
    });
    //结束请求体
    request.end();
})

/** 渲染进程 **/
//6.渲染进程开始从网络进程接收HTML数据
render.on('commitNavigation', function (response) {
    const headers = response.headers;
    const contentType = headers['content-type'];
    if (contentType.indexOf('text/html') !== -1) {
        //1. 渲染进程把HTML转变为DOM树型结构
        const document = { type: 'document', attributes: {}, children: [] };
+       const cssRules = [];
        const tokenStack = [document];
        const parser = new htmlparser2.Parser({
            onopentag(name, attributes = {}) {
                const parent = tokenStack.top();
                const element = {
                    type: 'element',
                    tagName: name,
                    children: [],
                    attributes,
                    parent
                }
                parent.children.push(element);
                tokenStack.push(element);
            },
            ontext(text) {
                if (!/^[\r\n\s]*$/.test(text)) {
                    const parent = tokenStack.top();
                    const textNode = {
                        type: 'text',
                        children: [],
                        attributes: {},
                        parent,
                        text
                    }
                    parent.children.push(textNode);
                }
            },
            /**
             * 在预解析阶段,HTML发现CSS和JS文件会并行下载,等全部下载后先把CSS生成CSSOM,然后再执行JS脚本
             * 然后再构建DOM树,重新计算样式,构建布局树,绘制页面
             * @param {*} tagname 
             */
+           onclosetag(tagname) {
+               switch (tagname) {
+                   case 'style':
+                       const styleToken = tokenStack.top();
+                       const cssAST = css.parse(styleToken.children[0].text);
+                       cssRules.push(...cssAST.stylesheet.rules);
+                       break;
+                   default:
+                       break;
+               }
                tokenStack.pop();
            },
        });
        //开始接收响应体
        response.on('data', (buffer) => {
            //8.渲染进程开始HTML解析和加载子资源
            //网络进程加载了多少数据,HTML 解析器便解析多少数据。
            parser.write(buffer.toString());
        });
        response.on('end', () => {
+           console.log(cssRules);
            //7.HTML接收接受完毕后通知主进程确认导航
            main.emit('confirmNavigation');
            //触发DOMContentLoaded事件
            main.emit('DOMContentLoaded');
            //9.HTML解析完毕和加载子资源页面加载完成后会通知主进程页面加载完成
            main.emit('Load');
        });
    }

})

//1.主进程接收用户输入的URL
main.emit('request', { host, port, path: '/index.html' });

HTML解析过程遇到CSS怎么办?

为了提高解析效率,浏览器会启动一个预解析器率先下载和解析CSS。

渲染主线程在解析HTML的时候,会关注每一个标签;而预解析线程只关注外部样式表的标签<link>,尽快地完成CSS的下载与解析。

这样做的目的是防止CSS的解析阻塞了HTML的解析。

image-20231015215414216

HTML解析过程遇到JS怎么办?

渲染主线程遇到 JS 的script标签时必须暂停一切行为,等待下载 JS 文件,并且启用V8引擎解析执行 JS 代码,然后才能继续解析 HTML。

原因:JS 代码可能修改 DOM 树。

预解析线程可以分担一点下载 JS 的任务。

image-20231015222008522

三、样式计算(Recalculate Style)

  • 根据CSS的继承和层叠规则计算DOM节点的样式
  • 在这一过程中,很多预设值会变成绝对值,比如red会变成rgb(255,0,0);相对单位会变成绝对单位,比如em会变成px
  • DOM节点的样式保存在了ComputedStyle

image-20231015230406588

样式计算过程计算每一个DOM节点的最终样式(Computed Style)。

计算样式如何查看:在浏览器上打开开发者工具,查看“计算样式”,并选择“全部显示”。

image-20231016165419278

通过上一过程,得到的 DOM 树和 CSSOM 树。通过遍历 DOM 树,为每一个 DOM 节点,计算它的所有 CSS 属性。

属性值的计算过程,分为如下4个步骤:

  1. 确定声明值;
  2. 层叠冲突;
  3. 使用继承;
  4. 使用默认值。

确定声明值

如果先不考虑冲突的话,那么通过 页面作者书写的CSS样式用户代理样式表(浏览器内置的样式表) 的声明值相加得到全部的声明值,并且将部分值进行转换。

例如,将color: red;转换为color: rgb(255, 0, 0);,将font-size: 2em;转换为font-size: 14px;

层叠冲突

在确定声明值时,可能出现一种情况,那就是声明的样式规则发生了冲突。

此时会进入解决层叠冲突的流程。而这一步又可以细分为下面这三个步骤:

  • 比较源的重要性
  • 比较优先级
  • 比较次序
比较源的重要性

样式有三种来源:

  1. 浏览器会有一个基本的样式表来给任何网页设置默认样式。这些样式统称用户代理样式
  2. 网页的作者可以定义文档的样式,这是最常见的样式表,称之为页面作者样式
  3. 浏览器的用户,可以使用自定义样式表定制使用体验,称之为用户样式

对应的重要性顺序依次为:页面作者样式 > 用户样式 > 用户代理样式。

可以在 MDN 中找到更详细的说明:CSS 层叠 - CSS:层叠样式表 | MDN (mozilla.org)

比较优先级

如果在同一源中出现了样式声明冲突,则比较其优先级。

简单来说就是:ID选择器 > 类名选择器 > 标签选择器。

更详细的说明可以查阅 MDN 的文章:优先级 - CSS:层叠样式表 | MDN (mozilla.org)

比较次序

如果出现同源同权重的情况,则比较样式的声明次序。

后声明的样式会覆盖先声明的样式。

p{
    /* 会被覆盖 */
    color: red;
}

p{
    /* 生效 */
	color: green;
}

显然,不存在次序相同的情况。至此,样式声明中存在冲突的所有情况都解决了。

使用继承

上文提到了,对于每一个 DOM 节点,都会去计算它的所有 CSS 属性。

层叠冲突这一步骤完成之后,声明值已全部确定。

而对于未声明的属性,并不是直接使用默认值,而是使用继承值。

例如:

<div>
	<p>hello world</p>
</div>
div{
	color: red;
}

这里<p>标签会继承来自<div>color: red样式。

继承原则

  • 继承谁的?答:就近原则,谁近就继承谁的,与权重无关。

  • 哪些属性能够继承?答:大部分字体相关的属性都是可继承的,可以在MDN上查找属性是否可继承。

    image-20231018135903596

使用默认值

如果经过上述过程仍不能确定属性值,则使用默认值。

server\public\index.html

<html>
+<head>
+    <style>
+        #hello {
+            color: red;
+        }
+        .world {
+            color: green;
+        }
+    </style>
+</head>
<body>
+   <div id="hello">hello</div>
+   <div class="world">world</div>
</body>
</html>

client\request.js

const htmlparser2 = require('htmlparser2');
const http = require('http');
const css = require("css");
const main = require('./main.js');
const network = require('./network.js');
const render = require('./render.js');
const host = 'localhost';
const port = 80;
Array.prototype.top = function () {
    return this[this.length - 1];
}
/** 浏览器主进程 **/
main.on('request', function (options) {
    //2.主进程把该URL转发给网络进程
    network.emit('request', options);
})
//开始准备渲染页面
main.on('prepareRender', function (response) {
    //5.主进程发送提交导航消息到渲染进程
    render.emit('commitNavigation', response);
})
main.on('confirmNavigation', function () {
    console.log('confirmNavigation');
})
main.on('DOMContentLoaded', function () {
    console.log('DOMContentLoaded');
})
main.on('Load', function () {
    console.log('Load');
})

/** 网络进程 **/
network.on('request', function (options) {
    //3.在网络进程中发起URL请求
    let request = http.request(options, (response) => {
        //4.网络进程接收到响应头数据并转发给主进程
        main.emit('prepareRender', response);
    });
    //结束请求体
    request.end();
})

/** 渲染进程 **/
//6.渲染进程开始从网络进程接收HTML数据
render.on('commitNavigation', function (response) {
    const headers = response.headers;
    const contentType = headers['content-type'];
    if (contentType.indexOf('text/html') !== -1) {
        //1. 渲染进程把HTML转变为DOM树型结构
        const document = { type: 'document', attributes: {}, children: [] };
        const cssRules = [];
        const tokenStack = [document];
        const parser = new htmlparser2.Parser({
            onopentag(name, attributes = {}) {
                const parent = tokenStack.top();
                const element = {
                    type: 'element',
                    tagName: name,
                    children: [],
                    attributes,
                    parent
                }
                parent.children.push(element);
                tokenStack.push(element);
            },
            ontext(text) {
                if (!/^[\r\n\s]*$/.test(text)) {
                    const parent = tokenStack.top();
                    const textNode = {
                        type: 'text',
                        children: [],
                        attributes: {},
                        parent,
                        text
                    }
                    parent.children.push(textNode);
                }
            },
            /**
             * 在预解析阶段,HTML发现CSS和JS文件会并行下载,等全部下载后先把CSS生成CSSOM,然后再执行JS脚本
             * 然后再构建DOM树,重新计算样式,构建布局树,绘制页面
             * @param {*} tagname 
             */
            onclosetag(tagname) {
                switch (tagname) {
                    case 'style':
                        const styleToken = tokenStack.top();
                        const cssAST = css.parse(styleToken.children[0].text);
                        cssRules.push(...cssAST.stylesheet.rules);
                        break;
                    default:
                        break;
                }
                tokenStack.pop();
            },
        });
        //开始接收响应体
        response.on('data', (buffer) => {
            //8.渲染进程开始HTML解析和加载子资源
            //网络进程加载了多少数据,HTML 解析器便解析多少数据。
            parser.write(buffer.toString());
        });
        response.on('end', () => {
            //7.HTML接收接受完毕后通知主进程确认导航
            main.emit('confirmNavigation');
            //3. 通过stylesheet计算出DOM节点的样式
+           recalculateStyle(cssRules, document);
+           console.dir(document, { depth: null });
            //触发DOMContentLoaded事件
            main.emit('DOMContentLoaded');
            //9.HTML解析完毕和加载子资源页面加载完成后会通知主进程页面加载完成
            main.emit('Load');
        });
    }
})

+function recalculateStyle(cssRules, element, parentComputedStyle = {}) {
+    const attributes = element.attributes;
+    element.computedStyle = {color:parentComputedStyle.color}; // 计算样式
+    Object.entries(attributes).forEach(([key, value]) => {
+        //stylesheets
+        cssRules.forEach(rule => {
+            let selector = rule.selectors[0].replace(/\s+/g, '');
+            if ((selector == '#' + value && key == 'id') || (selector == '.' + value && key == 'class')) {
+                rule.declarations.forEach(({ property, value }) => {
+                    element.computedStyle[property] = value;
+                })
+            }
+        })
+        //行内样式
+        if (key === 'style') {
+            const attributes = value.split(';');
+            attributes.forEach((attribute) => {
+                const [property, value] = attribute.split(/:\s*/);
+                element.computedStyle[property] = value;
+            });
+        }
+    });
+    element.children.forEach(child => recalculateStyle(cssRules, child,element.computedStyle));
+}

//1.主进程接收用户输入的URL
main.emit('request', { host, port, path: '/index.html' });

四、布局(Layout)

img

image-20231019160522446

根据 DOM 树里每个节点的样式,计算出每个节点的尺寸和位置

有一些数值,例如:百分比,或者auto,在上一步骤无法算出来,在布局这个过程才能算出来。

对于一个元素来说,它的尺寸和位置经常与它的**包含块(containing block)**有关。

这里简单地记录包含块的知识,更详细的说明可以查阅 MDN 文档:👉布局和包含块

盒模型:每一个盒子被划分为4个区域,即内容区内边距区边框区外边距区

对于一个元素而言,大部分时候,它的包含块就是它父元素的内容区。但在一些情况下并不如此。

包含块影响这些内容的计算widthheightmarginpadding,偏移量(positionabsolutefixed的时候),以及使用百分比的时候,是依照其包含块数值为基准计算的。

如何确定包含块:确定一个元素的包含块的过程完全依赖于这个元素的position属性。

  • staticrelativesticky:包含块可能由它的最近的祖先块元素(比如说 inline-block, block 或 list-item 元素)的内容区的边缘组成;

  • absolute:由它的最近的 position 的值不是 static 的祖先元素的内边距区的边缘组成;

  • fixed:在连续媒体的情况下包含块是viewport

  • absolute
    

    fixed
    

    :包含块也可能是由满足以下条件的最近父级元素的内边距区的边缘组成的。

    • transformperspective 的值不是 none
    • will-change的值是 transformperspective。
    • filter的值不是 nonewill-change 的值是 filter(只在 Firefox 下生效)。
    • contain的值是 paint(例如:contain: paint;)。
    • backdrop-filter的值不是 none(例如:backdrop-filter: blur(10px);)。

image-20231019163413788

image-20231019164608705

image-20231020203157145

如上图所示,Layout树和DOM树不一定是一一对应的。

原因是:

  1. 布局树是记录节点的几何信息(尺寸和位置)的,如果设置了display: none;,则节点失去几何信息,不会被添加到布局树中。

  2. 伪元素节点不存在于DOM树中,但是有几何信息,因此会被生成到布局树中。

  3. 布局过程存在两个规则(w3c规定):

    • 内容必须在行盒中
    • 行盒和块盒不能相邻

    如果在块盒中直接写入内容,则会在中间生成一个匿名行盒;如果块盒和行盒相邻,则为行盒外部生成一个匿名块盒。(参考上图)

📌插播小知识

html标签只表明语义,不区分行盒或块盒,css决定元素是行盒还是块盒。

通常理解的<p>, <div>是块盒,是因为浏览器默认样式给它们设置了display: block;

<head><meta>等标签都是隐藏的,是因为浏览器默认样式表给它们设置了display: none;

可以在github上chromium的源代码中找到这些默认样式。

image-20231019164839415

上述的DOM树和布局树都是指浏览器底层的C++对象,它们以不同程度暴露到JS中。

对于DOM树,JS提供了document对象可以访问;而布局树,只暴露出了很少的内容,例如:clientWidthoffsetWidth等属性。

创建布局

server\public\index.html

<html>
<head>
    <style>
        #hello {
            color: red;
        }
        .world {
            color: green;
        }
    </style>
</head>
<body>
    <div id="hello">hello</div>
+   <div class="world" style="display:none">world</div>
</body>
</html>

client\request.js

const htmlparser2 = require('htmlparser2');
const http = require('http');
const css = require("css");
const main = require('./main.js');
const network = require('./network.js');
const render = require('./render.js');
const host = 'localhost';
const port = 80;
Array.prototype.top = function () {
    return this[this.length - 1];
}
/** 浏览器主进程 **/
main.on('request', function (options) {
    //2.主进程把该URL转发给网络进程
    network.emit('request', options);
})
//开始准备渲染页面
main.on('prepareRender', function (response) {
    //5.主进程发送提交导航消息到渲染进程
    render.emit('commitNavigation', response);
})
main.on('confirmNavigation', function () {
    console.log('confirmNavigation');
})
main.on('DOMContentLoaded', function () {
    console.log('DOMContentLoaded');
})
main.on('Load', function () {
    console.log('Load');
})

/** 网络进程 **/
network.on('request', function (options) {
    //3.在网络进程中发起URL请求
    let request = http.request(options, (response) => {
        //4.网络进程接收到响应头数据并转发给主进程
        main.emit('prepareRender', response);
    });
    //结束请求体
    request.end();
})

/** 渲染进程 **/
//6.渲染进程开始从网络进程接收HTML数据
render.on('commitNavigation', function (response) {
    const headers = response.headers;
    const contentType = headers['content-type'];
    if (contentType.indexOf('text/html') !== -1) {
        //1. 渲染进程把HTML转变为DOM树型结构
        const document = { type: 'document', attributes: {}, children: [] };
        const cssRules = [];
        const tokenStack = [document];
        const parser = new htmlparser2.Parser({
            onopentag(name, attributes = {}) {
                const parent = tokenStack.top();
                const element = {
                    type: 'element',
                    tagName: name,
                    children: [],
                    attributes,
                    parent
                }
                parent.children.push(element);
                tokenStack.push(element);
            },
            ontext(text) {
                if (!/^[\r\n\s]*$/.test(text)) {
                    const parent = tokenStack.top();
                    const textNode = {
                        type: 'text',
                        children: [],
                        attributes: {},
                        parent,
                        text
                    }
                    parent.children.push(textNode);
                }
            },
            /**
             * 在预解析阶段,HTML发现CSS和JS文件会并行下载,等全部下载后先把CSS生成CSSOM,然后再执行JS脚本
             * 然后再构建DOM树,重新计算样式,构建布局树,绘制页面
             * @param {*} tagname 
             */
            onclosetag(tagname) {
                switch (tagname) {
                    case 'style':
                        const styleToken = tokenStack.top();
                        const cssAST = css.parse(styleToken.children[0].text);
                        cssRules.push(...cssAST.stylesheet.rules);
                        break;
                    default:
                        break;
                }
                tokenStack.pop();
            },
        });
        //开始接收响应体
        response.on('data', (buffer) => {
            //8.渲染进程开始HTML解析和加载子资源
            //网络进程加载了多少数据,HTML 解析器便解析多少数据。
            parser.write(buffer.toString());
        });
        response.on('end', () => {
            //7.HTML接收接受完毕后通知主进程确认导航
            main.emit('confirmNavigation');
            //3. 通过stylesheet计算出DOM节点的样式
            recalculateStyle(cssRules, document);
+           //4. 根据DOM树创建布局树,就是复制DOM结构并过滤掉不显示的元素
+           const html = document.children[0];
+           const body = html.children[1];
+           const layoutTree = createLayout(body);
+           console.dir(layoutTree, { depth: null });
            //触发DOMContentLoaded事件
            main.emit('DOMContentLoaded');
            //9.HTML解析完毕和加载子资源页面加载完成后会通知主进程页面加载完成
            main.emit('Load');
        });
    }
})
+function createLayout(element) {
+    element.children = element.children.filter(isShow);
+    element.children.forEach(child => createLayout(child));
+    return element;
+}
+function isShow(element) {
+    let isShow = true;
+   if (element.tagName === 'head' || element.tagName === 'script') {
+        isShow = false;
+    }
+    const attributes = element.attributes;
+    Object.entries(attributes).forEach(([key, value]) => {
+        if (key === 'style') {
+            const attributes = value.split(';');
+            attributes.forEach((attribute) => {
+                const [property, value] = attribute.split(/:\s*/);
+                if (property === 'display' && value === 'none') {
+                    isShow = false;
+                }
+            });
+        }
+    });
+    return isShow;
+}
function recalculateStyle(cssRules, element, parentComputedStyle = {}) {
    const attributes = element.attributes;
    element.computedStyle = {color:parentComputedStyle.color}; // 计算样式
    Object.entries(attributes).forEach(([key, value]) => {
        //stylesheets
        cssRules.forEach(rule => {
            let selector = rule.selectors[0].replace(/\s+/g, '');
            if ((selector == '#' + value && key == 'id') || (selector == '.' + value && key == 'class')) {
                rule.declarations.forEach(({ property, value }) => {
                    element.computedStyle[property] = value;
                })
            }
        })
        //行内样式
        if (key === 'style') {
            const attributes = value.split(';');
            attributes.forEach((attribute) => {
                const [property, value] = attribute.split(/:\s*/);
                element.computedStyle[property] = value;
            });
        }
    });
    element.children.forEach(child => recalculateStyle(cssRules, child,element.computedStyle));
}

//1.主进程接收用户输入的URL
main.emit('request', { host, port, path: '/index.html' });

计算布局

client\request.js

const htmlparser2 = require('htmlparser2');
const http = require('http');
const css = require("css");
const main = require('./main.js');
const network = require('./network.js');
const render = require('./render.js');
const host = 'localhost';
const port = 80;
Array.prototype.top = function () {
    return this[this.length - 1];
}
/** 浏览器主进程 **/
main.on('request', function (options) {
    //2.主进程把该URL转发给网络进程
    network.emit('request', options);
})
//开始准备渲染页面
main.on('prepareRender', function (response) {
    //5.主进程发送提交导航消息到渲染进程
    render.emit('commitNavigation', response);
})
main.on('confirmNavigation', function () {
    console.log('confirmNavigation');
})
main.on('DOMContentLoaded', function () {
    console.log('DOMContentLoaded');
})
main.on('Load', function () {
    console.log('Load');
})

/** 网络进程 **/
network.on('request', function (options) {
    //3.在网络进程中发起URL请求
    let request = http.request(options, (response) => {
        //4.网络进程接收到响应头数据并转发给主进程
        main.emit('prepareRender', response);
    });
    //结束请求体
    request.end();
})

/** 渲染进程 **/
//6.渲染进程开始从网络进程接收HTML数据
render.on('commitNavigation', function (response) {
    const headers = response.headers;
    const contentType = headers['content-type'];
    if (contentType.indexOf('text/html') !== -1) {
        //1. 渲染进程把HTML转变为DOM树型结构
        const document = { type: 'document', attributes: {}, children: [] };
        const cssRules = [];
        const tokenStack = [document];
        const parser = new htmlparser2.Parser({
            onopentag(name, attributes = {}) {
                const parent = tokenStack.top();
                const element = {
                    type: 'element',
                    tagName: name,
                    children: [],
                    attributes,
                    parent
                }
                parent.children.push(element);
                tokenStack.push(element);
            },
            ontext(text) {
                if (!/^[\r\n\s]*$/.test(text)) {
                    const parent = tokenStack.top();
                    const textNode = {
                        type: 'text',
                        children: [],
                        attributes: {},
                        parent,
                        text
                    }
                    parent.children.push(textNode);
                }
            },
            /**
             * 在预解析阶段,HTML发现CSS和JS文件会并行下载,等全部下载后先把CSS生成CSSOM,然后再执行JS脚本
             * 然后再构建DOM树,重新计算样式,构建布局树,绘制页面
             * @param {*} tagname 
             */
            onclosetag(tagname) {
                switch (tagname) {
                    case 'style':
                        const styleToken = tokenStack.top();
                        const cssAST = css.parse(styleToken.children[0].text);
                        cssRules.push(...cssAST.stylesheet.rules);
                        break;
                    default:
                        break;
                }
                tokenStack.pop();
            },
        });
        //开始接收响应体
        response.on('data', (buffer) => {
            //8.渲染进程开始HTML解析和加载子资源
            //网络进程加载了多少数据,HTML 解析器便解析多少数据。
            parser.write(buffer.toString());
        });
        response.on('end', () => {
            //7.HTML接收接受完毕后通知主进程确认导航
            main.emit('confirmNavigation');
            //3. 通过stylesheet计算出DOM节点的样式
            recalculateStyle(cssRules, document);
           //4. 根据DOM树创建布局树,就是复制DOM结构并过滤掉不显示的元素
           const html = document.children[0];
           const body = html.children[1];
           const layoutTree = createLayout(body);
+          //5.并计算各个元素的布局信息
+          updateLayoutTree(layoutTree);
           //触发DOMContentLoaded事件
           main.emit('DOMContentLoaded');
           //9.HTML解析完毕和加载子资源页面加载完成后会通知主进程页面加载完成
           main.emit('Load');
        });
    }
})
+function updateLayoutTree(element, top = 0, parentTop = 0) {
+    const computedStyle = element.computedStyle;
+    element.layout = {
+        top: top + parentTop,
+        left: 0,
+        width: computedStyle.width,
+        height: computedStyle.height,
+        background: computedStyle.background,
+        color: computedStyle.color
+    }
+    let childTop = 0;
+    element.children.forEach(child => {
+        updateLayoutTree(child, childTop, element.layout.top);
+        childTop += parseInt(child.computedStyle.height || 0);
+    });
+}
function createLayout(element) {
    element.children = element.children.filter(isShow);
    element.children.forEach(child => createLayout(child));
    return element;
}
function isShow(element) {
    let isShow = true;
    if (element.tagName === 'head' || element.tagName === 'script') {
        isShow = false;
    }
    const attributes = element.attributes;
    Object.entries(attributes).forEach(([key, value]) => {
        if (key === 'style') {
            const attributes = value.split(';');
            attributes.forEach((attribute) => {
                const [property, value] = attribute.split(/:\s*/);
                if (property === 'display' && value === 'none') {
                    isShow = false;
                }
            });
        }
    });
    return isShow;
}
function recalculateStyle(cssRules, element, parentComputedStyle = {}) {
    const attributes = element.attributes;
    element.computedStyle = {color:parentComputedStyle.color}; // 计算样式
    Object.entries(attributes).forEach(([key, value]) => {
        //stylesheets
        cssRules.forEach(rule => {
            let selector = rule.selectors[0].replace(/\s+/g, '');
            if ((selector == '#' + value && key == 'id') || (selector == '.' + value && key == 'class')) {
                rule.declarations.forEach(({ property, value }) => {
                    element.computedStyle[property] = value;
                })
            }
        })
        //行内样式
        if (key === 'style') {
            const attributes = value.split(';');
            attributes.forEach((attribute) => {
                const [property, value] = attribute.split(/:\s*/);
                element.computedStyle[property] = value;
            });
        }
    });
    element.children.forEach(child => recalculateStyle(cssRules, child,element.computedStyle));
}

//1.主进程接收用户输入的URL
main.emit('request', { host, port, path: '/index.html' });

五、分层(Layer)

  • 根据布局树生成分层树
  • 渲染引擎需要为某些节点生成单独的图层,并组合成图层树
    • z-index
    • 绝对定位和固定定位
    • 滤镜
    • 透明
    • 裁剪
  • 这些图层合成最终的页面
  • 现在的页面大多都十分复杂,并且交互效果很多。如果不分层,用户的一个简单交互将导致整个页面的重新渲染,效率低下。
  • 分层的好处在于可以局部的渲染,提高性能。
  • 老旧的浏览器没有分层概念,现代浏览器都有分层这个内容了。

image-20231020211057711

以Edge浏览器打开百度为例,开发者工具中切换到3D视图(不同浏览器或者因为语言不同可能是不同选项),左侧可以看到多个分层。

通常来说不会太多层,因为分层虽然可以提高渲染效率,但是占用很大内存空间。

分层与部分CSS属性有关,通常页面越复杂则层越多,但是也不一定,因为不同浏览器的分层策略可能不同。

总结:与堆叠上下文有关的属性,会影响分层的决策,最后依据不同浏览器的具体实现,生成分层的结果。

与堆叠上下文有关的属性:z-indexopacitytransform

引用自MDN

绘制可以将布局树中的元素分解为多个层。将内容提升到 GPU 上的层(而不是 CPU 上的主线程)可以提高绘制和重新绘制性能。有一些特定的属性和元素可以实例化一个层,包括videocanvas,任何 CSS 属性为opacity、3D transformwill-change的元素,还有一些其他元素。这些节点将与子节点一起绘制到它们自己的层上,除非子节点由于上述一个(或多个)原因需要自己的层。

分层确实可以提高性能,但是它以内存管理为代价,因此不应作为 web 性能优化策略的一部分过度使用。

📌插播小知识

will-change:通常大多数元素例如<div>不会单独分为一层,但是如果它的内容经常需要更新、需要重新渲染,可以添加一个属性:will-change

如果这个元素的transform属性需要经常发生变化,那么可以声明will-change: transform;,告知浏览器其需要经常更新,但是最后是否决定分层依然是浏览器的具体实现决定的。

server\public\index.html

<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">

    <script type="text/javascript" src="main.js">
    </script>
    <title>chrome</title>
    <style>
        * {
            padding: 0;
            margin: 0;
        }

        #container {
            width: 100px;
            height: 100px;
        }

        .main {
            background: red;
        }

        #hello {
            background: green;
            width: 100px;
            height: 100px;
        }

        #world {
            background: blue;
            width: 100px;
            height: 100px;
        }

        #absolute {
            background: pink;
            width: 50px;
            height: 50px;
            left: 0px;
            top: 0px;
        }
    </style>
</head>

<body>
    <div id="container" class="main"></div>
    <div id="hello" style="color:blue;">hello</div>
    <div id="world" style="display:none">world</div>
    <div id="absolute" style="position:absolute">
        abs
    </div>
</body>

</html>

client\request.js

const htmlparser2 = require('htmlparser2');
const http = require('http');
const css = require("css");
const main = require('./main.js');
const network = require('./network.js');
const render = require('./render.js');
const host = 'localhost';
const port = 80;
Array.prototype.top = function () {
    return this[this.length - 1];
}
/** 浏览器主进程 **/
main.on('request', function (options) {
    //2.主进程把该URL转发给网络进程
    network.emit('request', options);
})
//开始准备渲染页面
main.on('prepareRender', function (response) {
    //5.主进程发送提交导航消息到渲染进程
    render.emit('commitNavigation', response);
})
main.on('confirmNavigation', function () {
    console.log('confirmNavigation');
})
main.on('DOMContentLoaded', function () {
    console.log('DOMContentLoaded');
})
main.on('Load', function () {
    console.log('Load');
})

/** 网络进程 **/
network.on('request', function (options) {
    //3.在网络进程中发起URL请求
    let request = http.request(options, (response) => {
        //4.网络进程接收到响应头数据并转发给主进程
        main.emit('prepareRender', response);
    });
    //结束请求体
    request.end();
})

/** 渲染进程 **/
//6.渲染进程开始从网络进程接收HTML数据
render.on('commitNavigation', function (response) {
    const headers = response.headers;
    const contentType = headers['content-type'];
    if (contentType.indexOf('text/html') !== -1) {
        //1. 渲染进程把HTML转变为DOM树型结构
        const document = { type: 'document', attributes: {}, children: [] };
        const cssRules = [];
        const tokenStack = [document];
        const parser = new htmlparser2.Parser({
            onopentag(name, attributes = {}) {
                const parent = tokenStack.top();
                const element = {
                    type: 'element',
                    tagName: name,
                    children: [],
                    attributes,
                    parent
                }
                parent.children.push(element);
                tokenStack.push(element);
            },
            ontext(text) {
                if (!/^[\r\n\s]*$/.test(text)) {
                    const parent = tokenStack.top();
                    const textNode = {
                        type: 'text',
                        children: [],
                        attributes: {},
                        parent,
                        text
                    }
                    parent.children.push(textNode);
                }
            },
            /**
             * 在预解析阶段,HTML发现CSS和JS文件会并行下载,等全部下载后先把CSS生成CSSOM,然后再执行JS脚本
             * 然后再构建DOM树,重新计算样式,构建布局树,绘制页面
             * @param {*} tagname 
             */
            onclosetag(tagname) {
                switch (tagname) {
                    case 'style':
                        const styleToken = tokenStack.top();
                        const cssAST = css.parse(styleToken.children[0].text);
                        cssRules.push(...cssAST.stylesheet.rules);
                        break;
                    default:
                        break;
                }
                tokenStack.pop();
            },
        });
        //开始接收响应体
        response.on('data', (buffer) => {
            //8.渲染进程开始HTML解析和加载子资源
            //网络进程加载了多少数据,HTML 解析器便解析多少数据。
            parser.write(buffer.toString());
        });
        response.on('end', () => {
            //7.HTML接收接受完毕后通知主进程确认导航
            main.emit('confirmNavigation');
            //3. 通过stylesheet计算出DOM节点的样式
            recalculateStyle(cssRules, document);
            //4. 根据DOM树创建布局树,就是复制DOM结构并过滤掉不显示的元素
            const html = document.children[0];
            const body = html.children[1];
            const layoutTree = createLayout(body);
            //5.并计算各个元素的布局信息
            updateLayoutTree(layoutTree);
+           //6. 根据布局树生成分层树
+           const layers = [layoutTree];
+           createLayerTree(layoutTree, layers);
+           console.log(layers);
            //触发DOMContentLoaded事件
            main.emit('DOMContentLoaded');
            //9.HTML解析完毕和加载子资源页面加载完成后会通知主进程页面加载完成
            main.emit('Load');
        });
    }
})
+function createLayerTree(element, layers) {
+    element.children = element.children.filter((child) => createNewLayer(child, layers));
+    element.children.forEach(child => createLayerTree(child, layers));
+    return layers;
+}
+function createNewLayer(element, layers) {
+    let created = true;
+    const attributes = element.attributes;
+    Object.entries(attributes).forEach(([key, value]) => {
+        if (key === 'style') {
+            const attributes = value.split(';');
+            attributes.forEach((attribute) => {
+                const [property, value] = attribute.split(/:\s*/);
+                if (property === 'position' && value === 'absolute') {
+                    updateLayoutTree(element);//对单独的层重新计算位置
+                    layers.push(element);
+                    created = false;
+                }
+            });
+        }
+    });
+    return created;
+}
function updateLayoutTree(element, top = 0, parentTop = 0) {
    const computedStyle = element.computedStyle;
    element.layout = {
        top: top + parentTop,
        left: 0,
        width: computedStyle.width,
        height: computedStyle.height,
        background: computedStyle.background,
        color: computedStyle.color
    }
    let childTop = 0;
    element.children.forEach(child => {
        updateLayoutTree(child, childTop, element.layout.top);
        childTop += parseInt(child.computedStyle.height || 0);
    });
}
function createLayout(element) {
    element.children = element.children.filter(isShow);
    element.children.forEach(child => createLayout(child));
    return element;
}
function isShow(element) {
    let isShow = true;
    if (element.tagName === 'head' || element.tagName === 'script') {
        isShow = false;
    }
    const attributes = element.attributes;
    Object.entries(attributes).forEach(([key, value]) => {
        if (key === 'style') {
            const attributes = value.split(';');
            attributes.forEach((attribute) => {
                const [property, value] = attribute.split(/:\s*/);
                if (property === 'display' && value === 'none') {
                    isShow = false;
                }
            });
        }
    });
    return isShow;
}
function recalculateStyle(cssRules, element, parentComputedStyle = {}) {
    const attributes = element.attributes;
    element.computedStyle = {color:parentComputedStyle.color}; // 计算样式
    Object.entries(attributes).forEach(([key, value]) => {
        //stylesheets
        cssRules.forEach(rule => {
            let selector = rule.selectors[0].replace(/\s+/g, '');
            if ((selector == '#' + value && key == 'id') || (selector == '.' + value && key == 'class')) {
                rule.declarations.forEach(({ property, value }) => {
                    element.computedStyle[property] = value;
                })
            }
        })
        //行内样式
        if (key === 'style') {
            const attributes = value.split(';');
            attributes.forEach((attribute) => {
                const [property, value] = attribute.split(/:\s*/);
                element.computedStyle[property] = value;
            });
        }
    });
    element.children.forEach(child => recalculateStyle(cssRules, child,element.computedStyle));
}

//1.主进程接收用户输入的URL
main.emit('request', { host, port, path: '/index.html' });

六、绘制

  • 根据分层树进行生成绘制步骤复合图层
  • 每个图层会拆分成多个绘制指令,这些指令组合在一起成为绘制列表

首先需要生成绘制的指令,为每个层生成绘制指令集,用于描述这一层的内容该如何画出来。

绘制指令类似于canvas的操作方法:

  • 移动画笔到xxx
  • 绘制宽为x,高为y的矩形

事实上,canvas是浏览器将绘制过程封装后提供给开发者的工具。

image-20231020213147598

client\request.js

const htmlparser2 = require('htmlparser2');
const http = require('http');
const css = require("css");
const main = require('./main.js');
const network = require('./network.js');
const render = require('./render.js');
const host = 'localhost';
const port = 80;
Array.prototype.top = function () {
    return this[this.length - 1];
}
/** 浏览器主进程 **/
main.on('request', function (options) {
    //2.主进程把该URL转发给网络进程
    network.emit('request', options);
})
//开始准备渲染页面
main.on('prepareRender', function (response) {
    //5.主进程发送提交导航消息到渲染进程
    render.emit('commitNavigation', response);
})
main.on('confirmNavigation', function () {
    console.log('confirmNavigation');
})
main.on('DOMContentLoaded', function () {
    console.log('DOMContentLoaded');
})
main.on('Load', function () {
    console.log('Load');
})

/** 网络进程 **/
network.on('request', function (options) {
    //3.在网络进程中发起URL请求
    let request = http.request(options, (response) => {
        //4.网络进程接收到响应头数据并转发给主进程
        main.emit('prepareRender', response);
    });
    //结束请求体
    request.end();
})

/** 渲染进程 **/
//6.渲染进程开始从网络进程接收HTML数据
render.on('commitNavigation', function (response) {
    const headers = response.headers;
    const contentType = headers['content-type'];
    if (contentType.indexOf('text/html') !== -1) {
        //1. 渲染进程把HTML转变为DOM树型结构
        const document = { type: 'document', attributes: {}, children: [] };
        const cssRules = [];
        const tokenStack = [document];
        const parser = new htmlparser2.Parser({
            onopentag(name, attributes = {}) {
                const parent = tokenStack.top();
                const element = {
                    type: 'element',
                    tagName: name,
                    children: [],
                    attributes,
                    parent
                }
                parent.children.push(element);
                tokenStack.push(element);
            },
            ontext(text) {
                if (!/^[\r\n\s]*$/.test(text)) {
                    const parent = tokenStack.top();
                    const textNode = {
                        type: 'text',
                        children: [],
                        attributes: {},
                        parent,
                        text
                    }
                    parent.children.push(textNode);
                }
            },
            /**
             * 在预解析阶段,HTML发现CSS和JS文件会并行下载,等全部下载后先把CSS生成CSSOM,然后再执行JS脚本
             * 然后再构建DOM树,重新计算样式,构建布局树,绘制页面
             * @param {*} tagname 
             */
            onclosetag(tagname) {
                switch (tagname) {
                    case 'style':
                        const styleToken = tokenStack.top();
                        const cssAST = css.parse(styleToken.children[0].text);
                        cssRules.push(...cssAST.stylesheet.rules);
                        break;
                    default:
                        break;
                }
                tokenStack.pop();
            },
        });
        //开始接收响应体
        response.on('data', (buffer) => {
            //8.渲染进程开始HTML解析和加载子资源
            //网络进程加载了多少数据,HTML 解析器便解析多少数据。
            parser.write(buffer.toString());
        });
        response.on('end', () => {
            //7.HTML接收接受完毕后通知主进程确认导航
            main.emit('confirmNavigation');
            //3. 通过stylesheet计算出DOM节点的样式
            recalculateStyle(cssRules, document);
            //4. 根据DOM树创建布局树,就是复制DOM结构并过滤掉不显示的元素
            const html = document.children[0];
            const body = html.children[1];
            const layoutTree = createLayout(body);
            //5.并计算各个元素的布局信息
            updateLayoutTree(layoutTree);
            //6. 根据布局树生成分层树
            const layers = [layoutTree];
            createLayerTree(layoutTree, layers);
+           //7. 根据分层树进行生成绘制步骤并复合图层
+           const paintSteps = compositeLayers(layers);
+           console.log(paintSteps.flat().join('\r\n'));
            //触发DOMContentLoaded事件
            main.emit('DOMContentLoaded');
            //9.HTML解析完毕和加载子资源页面加载完成后会通知主进程页面加载完成
            main.emit('Load');
        });
    }
})
+function compositeLayers(layers) {
+    //10.合成线程会把分好的图块发给栅格化线程池,栅格化线程会把图片(tile)转化为位图
+    return layers.map(layout => paint(layout));
+}
+function paint(element, paintSteps = []) {
+    const { background = 'black', color = 'black', top = 0, left = 0, width = 100, height = 0 } = element.layout;
+    if (element.type === 'text') {
+        paintSteps.push(`ctx.font = '20px Impact;'`);
+        paintSteps.push(`ctx.strokeStyle = '${color}';`);
+        paintSteps.push(`ctx.strokeText("${element.text.replace(/(^\s+|\s+$)/g, '')}", ${left},${top + 20});`);
+    } else {
+        paintSteps.push(`ctx.fillStyle="${background}";`);
+        paintSteps.push(`ctx.fillRect(${left},${top}, ${parseInt(width)}, ${parseInt(height)});`);
+    }
+    element.children.forEach(child => paint(child, paintSteps));
+    return paintSteps;
+}
function createLayerTree(element, layers) {
    element.children = element.children.filter((child) => createNewLayer(child, layers));
    element.children.forEach(child => createLayerTree(child, layers));
    return layers;
}
function createNewLayer(element, layers) {
    let created = true;
    const attributes = element.attributes;
    Object.entries(attributes).forEach(([key, value]) => {
        if (key === 'style') {
            const attributes = value.split(';');
            attributes.forEach((attribute) => {
                const [property, value] = attribute.split(/:\s*/);
                if (property === 'position' && value === 'absolute') {
                    updateLayoutTree(element);//对单独的层重新计算位置
                    layers.push(element);
                    created = false;
                }
            });
        }
    });
    return created;
}
function updateLayoutTree(element, top = 0, parentTop = 0) {
    const computedStyle = element.computedStyle;
    element.layout = {
        top: top + parentTop,
        left: 0,
        width: computedStyle.width,
        height: computedStyle.height,
        background: computedStyle.background,
        color: computedStyle.color
    }
    let childTop = 0;
    element.children.forEach(child => {
        updateLayoutTree(child, childTop, element.layout.top);
        childTop += parseInt(child.computedStyle.height || 0);
    });
}
function createLayout(element) {
    element.children = element.children.filter(isShow);
    element.children.forEach(child => createLayout(child));
    return element;
}
function isShow(element) {
    let isShow = true;
    if (element.tagName === 'head' || element.tagName === 'script') {
        isShow = false;
    }
    const attributes = element.attributes;
    Object.entries(attributes).forEach(([key, value]) => {
        if (key === 'style') {
            const attributes = value.split(';');
            attributes.forEach((attribute) => {
                const [property, value] = attribute.split(/:\s*/);
                if (property === 'display' && value === 'none') {
                    isShow = false;
                }
            });
        }
    });
    return isShow;
}
function recalculateStyle(cssRules, element, parentComputedStyle = {}) {
    const attributes = element.attributes;
    element.computedStyle = {color:parentComputedStyle.color};// 计算样式
    Object.entries(attributes).forEach(([key, value]) => {
        //stylesheets
        cssRules.forEach(rule => {
            let selector = rule.selectors[0].replace(/\s+/g, '');
            if ((selector == '#' + value && key == 'id') || (selector == '.' + value && key == 'class')) {
                rule.declarations.forEach(({ property, value }) => {
                    element.computedStyle[property] = value;
                })
            }
        })
        //行内样式
        if (key === 'style') {
            const attributes = value.split(';');
            attributes.forEach((attribute) => {
                const [property, value] = attribute.split(/:\s*/);
                element.computedStyle[property] = value;
            });
        }
    });
    element.children.forEach(child => recalculateStyle(cssRules, child,element.computedStyle));
}

//1.主进程接收用户输入的URL
main.emit('request', { host, port, path: '/index.html' });

七、分块(Tiling)

分块将每一层分为多个小的区域。

image-20231020213915128

tiles

这一步的目的是,优先画出视口内以及接近视口的内容。

  • 图块渲染也称基于瓦片渲染或基于小方块渲染
  • 它是一种通过规则的网格细分计算机图形图像并分别渲染图块(tile)各部分的过程

想象一个很长的、需要滚动很久才能到底的页面。

页面很大,但是接近视口的内容优先级最高,因为我们希望用户能尽早的看到页面的内容。于是分块,接近视口的块优先级高,优先显示出来。

可以将其视为更底层的“懒加载”。

image-20231020215405445

分块的工作是交给多个线程同时进行的。

渲染主线程先将分块任务交给合成线程,合成线程会从线程池中拿取多个线程来完成分块工作。

其中的合成线程和渲染主线程都位于渲染进程里。

目前大多数浏览器的策略是每个标签页都对应一个渲染进程,渲染进程里面包含多个线程。

八、光栅化(Raster)

也叫栅格化

  • 栅格化是将矢量图形格式表示的图像转换成位图以用于显示器输出的过程
  • 栅格即像素
  • 栅格化即将矢量图形转化为位图(栅格图像)

img

image-20231020220109543

光栅化将每个块变成位图,既然上一步已经分块了,这一步自然是优先处理接近视口的块。

位图:可以简单理解成用二维数组存储的像素信息。

像素信息:例如(red, green, blue, alpha)

合成线程会将块信息交给GPU进程完成光栅化,而GPU进程内部又会开启多个线程完成光栅化,优先处理靠近视口区域的块。

client\gpu.js

const EventEmitter = require('events');
class GPU extends EventEmitter {
    constructor() {
        super();
+       this.bitMaps = [];
    }
}
const gpu = new GPU();
module.exports = gpu;

client\request.js

const htmlparser2 = require('htmlparser2');
const http = require('http');
const css = require("css");
+const { createCanvas } = require('canvas')
+const fs = require('fs')
const main = require('./main.js');
const network = require('./network.js');
const render = require('./render.js');
+const gpu = require('./gpu.js');
const host = 'localhost';
const port = 80;
Array.prototype.top = function () {
    return this[this.length - 1];
}
/** 浏览器主进程 **/
main.on('request', function (options) {
    //2.主进程把该URL转发给网络进程
    network.emit('request', options);
})
//开始准备渲染页面
main.on('prepareRender', function (response) {
    //5.主进程发送提交导航消息到渲染进程
    render.emit('commitNavigation', response);
})
main.on('confirmNavigation', function () {
    console.log('confirmNavigation');
})
main.on('DOMContentLoaded', function () {
    console.log('DOMContentLoaded');
})
main.on('Load', function () {
    console.log('Load');
})
+main.on('drawQuad', function () {
+    //14.浏览器主进程然后会从GPU内存中取出位图显示到页面上
+    let drawSteps = gpu.bitMaps.flat();
+    const canvas = createCanvas(150, 250);
+    const ctx = canvas.getContext('2d');
+    eval(drawSteps.join('\r\n'));
+    fs.writeFileSync('result.png', canvas.toBuffer('image/png'));
+})

/** 网络进程 **/
network.on('request', function (options) {
    //3.在网络进程中发起URL请求
    let request = http.request(options, (response) => {
        //4.网络进程接收到响应头数据并转发给主进程
        main.emit('prepareRender', response);
    });
    //结束请求体
    request.end();
})

/** 渲染进程 **/
//6.渲染进程开始从网络进程接收HTML数据
render.on('commitNavigation', function (response) {
    const headers = response.headers;
    const contentType = headers['content-type'];
    if (contentType.indexOf('text/html') !== -1) {
        //1. 渲染进程把HTML转变为DOM树型结构
        const document = { type: 'document', attributes: {}, children: [] };
        const cssRules = [];
        const tokenStack = [document];
        const parser = new htmlparser2.Parser({
            onopentag(name, attributes = {}) {
                const parent = tokenStack.top();
                const element = {
                    type: 'element',
                    tagName: name,
                    children: [],
                    attributes,
                    parent
                }
                parent.children.push(element);
                tokenStack.push(element);
            },
            ontext(text) {
                if (!/^[\r\n\s]*$/.test(text)) {
                    const parent = tokenStack.top();
                    const textNode = {
                        type: 'text',
                        children: [],
                        attributes: {},
                        parent,
                        text
                    }
                    parent.children.push(textNode);
                }
            },
            /**
             * 在预解析阶段,HTML发现CSS和JS文件会并行下载,等全部下载后先把CSS生成CSSOM,然后再执行JS脚本
             * 然后再构建DOM树,重新计算样式,构建布局树,绘制页面
             * @param {*} tagname 
             */
            onclosetag(tagname) {
                switch (tagname) {
                    case 'style':
                        const styleToken = tokenStack.top();
                        const cssAST = css.parse(styleToken.children[0].text);
                        cssRules.push(...cssAST.stylesheet.rules);
                        break;
                    default:
                        break;
                }
                tokenStack.pop();
            },
        });
        //开始接收响应体
        response.on('data', (buffer) => {
            //8.渲染进程开始HTML解析和加载子资源
            //网络进程加载了多少数据,HTML 解析器便解析多少数据。
            parser.write(buffer.toString());
        });
        response.on('end', () => {
            //7.HTML接收接受完毕后通知主进程确认导航
            main.emit('confirmNavigation');
            //3. 通过stylesheet计算出DOM节点的样式
            recalculateStyle(cssRules, document);
            //4. 根据DOM树创建布局树,就是复制DOM结构并过滤掉不显示的元素
            const html = document.children[0];
            const body = html.children[1];
            const layoutTree = createLayout(body);
            //5.并计算各个元素的布局信息
            updateLayoutTree(layoutTree);
            //6. 根据布局树生成分层树
            const layers = [layoutTree];
            createLayerTree(layoutTree, layers);
            //7. 根据分层树进行生成绘制步骤并复合图层
            const paintSteps = compositeLayers(layers);
            console.log(paintSteps.flat().join('\r\n'));
+           //8.把绘制步骤交给渲染进程中的合成线程进行合成
+           //9.合成线程会把图层划分为图块(tile)
+           const tiles = splitTiles(paintSteps);
+           //10.合成线程会把分好的图块发给栅格化线程池
+           raster(tiles);
            //触发DOMContentLoaded事件
            main.emit('DOMContentLoaded');
            //9.HTML解析完毕和加载子资源页面加载完成后会通知主进程页面加载完成
            main.emit('Load');
        });
    }
})
+function splitTiles(paintSteps) {
+    return paintSteps;
+}
+function raster(tiles) {
+    //11.栅格化线程会把图片(tile)转化为位图
+    tiles.forEach(tile => rasterThread(tile));
+    //13.当所有的图块都光栅化之后合成线程会发送绘制图块的命令给浏览器主进程
+    main.emit('drawQuad');
+}
+function rasterThread(tile) {
+    //12.而其实栅格化线程在工作的时候会把栅格化的工作交给GPU进程来完成
+    gpu.emit('raster', tile);
+}
function compositeLayers(layers) {
    //10.合成线程会把分好的图块发给栅格化线程池,栅格化线程会把图片(tile)转化为位图
    return layers.map(layout => paint(layout));
}
function paint(element, paintSteps = []) {
    const { background = 'black', color = 'black', top = 0, left = 0, width = 100, height = 0 } = element.layout;
    if (element.type === 'text') {
        paintSteps.push(`ctx.font = '20px Impact;'`);
        paintSteps.push(`ctx.strokeStyle = '${color}';`);
        paintSteps.push(`ctx.strokeText("${element.text.replace(/(^\s+|\s+$)/g, '')}", ${left},${top + 20});`);
    } else {
        paintSteps.push(`ctx.fillStyle="${background}";`);
        paintSteps.push(`ctx.fillRect(${left},${top}, ${parseInt(width)}, ${parseInt(height)});`);
    }
    element.children.forEach(child => paint(child, paintSteps));
    return paintSteps;
}
function createLayerTree(element, layers) {
    element.children = element.children.filter((child) => createNewLayer(child, layers));
    element.children.forEach(child => createLayerTree(child, layers));
    return layers;
}
function createNewLayer(element, layers) {
    let created = true;
    const attributes = element.attributes;
    Object.entries(attributes).forEach(([key, value]) => {
        if (key === 'style') {
            const attributes = value.split(';');
            attributes.forEach((attribute) => {
                const [property, value] = attribute.split(/:\s*/);
                if (property === 'position' && value === 'absolute') {
                    updateLayoutTree(element);//对单独的层重新计算位置
                    layers.push(element);
                    created = false;
                }
            });
        }
    });
    return created;
}
function updateLayoutTree(element, top = 0, parentTop = 0) {
    const computedStyle = element.computedStyle;
    element.layout = {
        top: top + parentTop,
        left: 0,
        width: computedStyle.width,
        height: computedStyle.height,
        background: computedStyle.background,
        color: computedStyle.color
    }
    let childTop = 0;
    element.children.forEach(child => {
        updateLayoutTree(child, childTop, element.layout.top);
        childTop += parseInt(child.computedStyle.height || 0);
    });
}
function createLayout(element) {
    element.children = element.children.filter(isShow);
    element.children.forEach(child => createLayout(child));
    return element;
}
function isShow(element) {
    let isShow = true;
    if (element.tagName === 'head' || element.tagName === 'script') {
        isShow = false;
    }
    const attributes = element.attributes;
    Object.entries(attributes).forEach(([key, value]) => {
        if (key === 'style') {
            const attributes = value.split(';');
            attributes.forEach((attribute) => {
                const [property, value] = attribute.split(/:\s*/);
                if (property === 'display' && value === 'none') {
                    isShow = false;
                }
            });
        }
    });
    return isShow;
}
function recalculateStyle(cssRules, element, parentComputedStyle = {}) {
    const attributes = element.attributes;
    element.computedStyle = {color:parentComputedStyle.color}; // 计算样式
    Object.entries(attributes).forEach(([key, value]) => {
        //stylesheets
        cssRules.forEach(rule => {
            let selector = rule.selectors[0].replace(/\s+/g, '');
            if ((selector == '#' + value && key == 'id') || (selector == '.' + value && key == 'class')) {
                rule.declarations.forEach(({ property, value }) => {
                    element.computedStyle[property] = value;
                })
            }
        })
        //行内样式
        if (key === 'style') {
            const attributes = value.split(';');
            attributes.forEach((attribute) => {
                const [property, value] = attribute.split(/:\s*/);
                element.computedStyle[property] = value;
            });
        }
    });
    element.children.forEach(child => recalculateStyle(cssRules, child,element.computedStyle));
}
+gpu.on('raster', (tile) => {
+    //13.最终生成的位图就保存在了GPU内存中
+    let bitMap = tile;
+    gpu.bitMaps.push(bitMap);
+});
//1.主进程接收用户输入的URL
main.emit('request', { host, port, path: '/index.html' });

九、画(Draw)

image-20231020221506669

合成线程计算出每个位图在屏幕上的位置,交给GPU进行最终呈现。

其中的quad称为“指引信息”,指明位图信息位于屏幕上的哪一个像素点。

为什么合成线程不直接将结果交给硬件,而要先转交给GPU?

合成线程和渲染主线程都是隶属于渲染进程的,渲染进程处于沙盒中,无法进行系统调度,即无法直接与硬件GPU通信。

沙盒是一种浏览器安全策略,使得渲染进程无法直接与操作系统、硬件通信,可以避免一些网络病毒的攻击。

综上,合成线程将计算结果先转交给浏览器的GPU进程,再由其发送给硬件GPU,最终将内容显示到屏幕上。

👉CSS中的transform是在这一步确定的,只需要对位图进行矩阵变换。

这也是transform效率高的主要原因,因为它与渲染主线程无关,这个过程发生在合成线程中。

重排/回流(reflow)

回流也叫重排,回流就是当render树中的一部分因为元素的尺寸、位置或内容的变化而需要重新构建

  • reflow 的本质就是重新计算 layout 树。

  • 当进行了会影响布局树的操作后,需要重新计算布局树,会引发 layout。

  • 为了避免连续的多次操作导致布局树反复计算,浏览器会合并这些操作,当 JS 代码全部完成后再进行统一计算。所以,改动属性造成的 reflow 是异步完成的。

  • 也同样因为如此,当 JS 获取布局属性时,就可能造成无法获取到最新的布局信息。

  • 浏览器在反复权衡下,最终决定获取属性立即 reflow。


  • 页面首页渲染
  • 浏览器窗口大小变化
  • 内容变化导致大小尺寸变化
  • 添加或删除节点
  • 激活css伪类(例如:hover)
  • 改变元素位置,例如:margin、padding、border等都会引起重排

重绘(repaint)

当render树中的一部分因为元素的样式变化而需要重新构建,但元素的位置和尺寸没有

  • repaint 的本质就是重新根据分层信息计算了绘制指令。
  • 当改动了可见样式后,就需要重新计算,会引发 repaint。
    • 由于元素的布局信息也属于可见样式,所以 reflow 一定会引起 repaint。
  • 改变元素外观属性,例如:color、background-color、visibility、outline等都会引起重绘

重绘与重排的区别

  • 重排必将引起重绘,而重绘不一定会引起重排。
    • 比如:只有颜色改变的时候就只会发生重绘而不会引起回流
  • 当页面布局和几何属性改变时就需要重排。
    • 比如:添加或者删除可见的DOM元素,元素位置改变,元素尺寸改变——边距、填充、边框、宽度和高度,内容改变。

性能影响

  • 渲染速度变慢:回流和重绘操作都需要消耗一定的计算资源。尤其是回流,由于涉及到页面布局的重新计算,其开销相对较大。因此,当页面频繁触发回流和重绘时,会导致页面的渲染速度变慢,从而影响用户的体验。

  • 页面闪烁和卡顿:当浏览器进行回流和重绘操作时,页面可能会出现闪烁或卡顿的现象。这是因为浏览器在重新渲染页面时,会先清空原有的内容,然后再重新绘制。这个过程可能导致用户看到页面的短暂变化或停顿,尤其是在性能较低的设备或复杂的页面上。

  • 资源消耗:频繁的回流和重绘操作不仅会导致CPU和内存的消耗增加,还可能引发其他资源的过度使用,如电池续航时间的减少等。

优化策略

  • 减少DOM操作:避免不必要的DOM读写操作,可以缓存常用DOM对象到变量中,以减少直接访问DOM的次数。
  • 利用CSS优化:尽量使用CSS动画代替JavaScript动画,因为CSS动画在浏览器层面进行优化,通常比JavaScript动画性能更好。同时,通过优化CSS选择器的性能,减少样式计算的开销。
  • 批量修改:如果需要对多个元素进行样式修改或DOM操作,尽量将其合并成一次操作,以减少回流和重绘的次数。
  • 使用请求动画帧(requestAnimationFrame):这个函数可以在浏览器的下一次重绘之前执行代码,从而确保动画的流畅性,并减少不必要的回流和重绘。
  • 通过以上优化措施,可以有效地减轻回流和重绘对网页性能的影响,提升用户体验。
  • 29
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值