浏览器的渲染流程的对于前端 er 来说是重要的一环,不仅在平时开发中的问题排查,更体现在网页性能优化中作为重要的一环
一、进程架构
(一)进程和线程
- 当启动一个程序时,操作系统会为该程序分配内存,用来存放代码、运行过程中的数据,这样的运行环境叫做进程
- 一个进程可以启动和管理多个线程 ,线程 之间可以共享进行数据,任何一个线程出错都可能会导致进程崩溃
(二)Chrome 的进程架构
- 浏览器主进程: 负责界面显示、用户交互和子进程管理
- 渲染进程: 排版引擎和 V8 引擎运行在该进程中,负责把
HTML
、CSS
和JavaScript
转变成网页 - 网络进程: 用来加载网络资源的
- GPU 进程: 用来实现
CSS3
和3D
效果 - 插件进程: 负责维护
chrome
浏览器上安装使用的每个插件
二、加载 HTML
- 主进程接收用户输入的
URL
- 主进程把该
URL
转发给网络进程 - 在网络进程中发起
URL
请求 - 网络进程接收到响应头数据并转发给主进程
- 主进程发送提交导航消息到渲染进程
- 渲染进程开始从网络进程接收 HTML 数据
- HTML 接收接受完毕后通知主进程确认导航
- 渲染进程开始 HTML 解析和加载子资源
- HTML 解析完毕和加载子资源页面加载完成后会通知主进程页面加载完成
(一)安装 npm 包
bash
npm install canvas css express htmlparser2 --save
(二)使用 express
启动一个静态服务器
仅仅访问打开静态 html 页面,然后比较 javascript 伪代码绘制的图像是否跟最终的浏览器绘制的页面一致
js
const express = require("express");
let app = express();
app.use(express.static("public"));
app.listen(80, () => {
console.log("server started at 80");
});
(三)写个简单的渲染模板
server/public/load.html
html
<html>
<body>
<div>hello</div>
<div>world</div>
</body>
</html>
(四)模拟浏览器输入 url
请求资源
client/request.js
js
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;
/** 浏览器主进程 **/
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 buffers = [];
response.on("data", (buffer) => {
// 8.渲染进程开始HTML解析和加载子资源
buffers.push(buffer);
});
response.on("end", () => {
const resultBuffer = Buffer.concat(buffers);
const html = resultBuffer.toString();
console.log(html);
// 7.HTML接收接受完毕后通知主进程确认导航
main.emit("confirmNavigation", html);
// 触发DOMContentLoaded事件
main.emit("DOMContentLoaded", html);
// 9.HTML解析完毕和加载子资源页面加载完成后会通知主进程页面加载完成
main.emit("Load");
});
});
// 1.主进程接收用户输入的URL
main.emit("request", { host, port, path: "/load.html" });
(五)渲染进程
排版引擎和 V8 引擎运行在该进程中,负责把 HTML、CSS 和 JavaScript 转变成网页
client/render.js
js
const EventEmitter = require("events");
class Render extends EventEmitter {}
const render = new Render();
module.exports = render;
(六)网络进程
用来加载网络资源的
client/network.js
js
const EventEmitter = require("events");
class Network extends EventEmitter {}
const network = new Network();
module.exports = network;
(七)gpu
进程
用来实现 CSS3 和 3D 效果
client/gpu.js
js
const EventEmitter = require("events");
class GPU extends EventEmitter {}
const gpu = new GPU();
module.exports = gpu;
(八)浏览器主进程
负责界面显示、用户交互和子进程管理
client/main.js
js
const EventEmitter = require("events");
class Main extends EventEmitter {}
const main = new Main();
module.exports = main;
三、页面渲染流水线
- 渲染进程把
HTML
转变为DOM
树型结构 - 渲染进程把
CSS
文本转为浏览器中的stylesheet
- 通过
stylesheet
计算出DOM
节点的样式 - 根据
DOM
树创建布局树 - 并计算各个元素的布局信息
- 根据布局树 生成分层树
- 根据分层树 进行生成绘制步骤
- 把绘制步骤 交给渲染进程中的合成线程进行合成
- 合成线程将图层分成图块(
tile
) - 合成线程会把分好的图块发给栅格化 线程池,栅格化 线程会把图片(
tile
)转化为位图 - 而其实栅格化 线程在工作的时候会把栅格化 的工作交给
GPU
进程来完成,最终生成的位图 就保存在了GPU 内存中 - 当所有的图块 都光栅化 之后合成线程会发送绘制图块 的命令(
DrawQuad
)给浏览器主进程 - 浏览器主进程接收合成线程发过来的
DrawQuad
命令,根据DrawQuad
命令,然后会从GPU 内存 中取出位图显示到页面上
(一)HTML 转 DOM 树
- 浏览器中的
HTML
解析器可以把HTML
字符串转换成DOM
结构 HTML
解析器边接收网络数据边解析HTML
- 解析
DOM
HTML
字符串转Token
Token
栈用来维护节点之间的父子关系,Token
会依次压入栈中- 如果是开始标签,把
Token
压入栈中并且创建新的DOM
节点并添加到父节点的children
中 - 如果是文本
Token
,则把文本节点添加到栈顶元素的children
中,文本Token
不需要入栈 - 如果是结束标签,此开始标签出栈
1. 分词
token
:有点类似 vue
的模板解析生成 render 函数 ,最终再生成 虚拟 dom
2. 渲染进程把HTML
转变为DOM
树型结构
client/request.js
diff
+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', () => {
- // const resultBuffer = Buffer.concat(buffers);
- // const 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' });
可以看下最终打印出来的DOM
树形结构的样子:
js
const 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
- 渲染进程把
CSS
文本转为浏览器中的stylesheet
CSS
来源可能有link
标签(外部样式 )、style
标签(内部样式 )和style
(行内样式)- 渲染引擎会把
CSS
转换为document.styleSheets
1. 渲染模版添加内部样式(load.html)
diff
<html>
+<head>
+ <style>
+ div {
+ color: red;
+ }
+ </style>
+</head>
<body>
<div>hello</div>
<div>world</div>
</body>
</html>
2. 解析 css,生成 css 规则
client/request.js
diff
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];
}
省略中间代码 ...
/** 渲染进程 **/
// 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({
省略中间代码 ...
/**
* 在预解析阶段,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: '/load.html' });
(三)计算出 DOM 节点的样式
- 根据
CSS
的继承和层叠规则计算DOM 节点的样式 - DOM 节点 的样式保存在了
ComputedStyle
中
1. 通过 stylesheet
计算出 DOM
节点的样式
client/request.js
diff
/** 渲染进程 **/
// 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];
省略中间代码 ...
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');
});
}
})
+/**
+ * 重新计算样式
+ * @param {*} cssRules css规则集
+ * @param {*} element 元素节点
+ * @param {*} parentStyle 父节点样式
+ */
+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: '/load.html' });
(四)创建布局树
- 创建布局树
- 创建一棵只包含可见元素的布局树
渲染模版添加不可见节点
server/public/load.html
diff
<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>
3.4.2 创建布局树
client/request.js
diff
省略中间代码 ...
/** 渲染进程 **/
// 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];
省略中间代码 ...
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;
+}
省略中间代码 ...
// 1.主进程接收用户输入的URL
main.emit('request', { host, port, path: '/load.html' });
(五)计算布局
- 计算各个元素的布局
1. 计算各个元素的布局信息
client/request.js
diff
省略中间代码 ...
/** 渲染进程 **/
// 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];
省略中间代码 ...
// 开始接收响应体
response.on('data', (buffer) => {
省略中间代码 ...
// 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');
省略代码 ...
+/**
+ * 计算布局树上每个元素的布局信息
+ * @param {*} element 节点元素
+ * @param {*} top 自己距离自己父节点的顶部的距离
+ * @param {*} parentTop 父节点距离顶部的距离
+ */
+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);
+ });
+}
省略代码 ...
(六)生成分层树
- 根据布局树生成分层树
- 渲染引擎需要为某些节点生成单独的图层,并组合成图层树
- z-index
- 绝对定位和固定定位
- 滤镜
- 透明
- 裁剪
- 这些图层合成最终的页面
详细可生成单独的图层属性列表如下:
1. 调整渲染模版的结构
server/public/load.html
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>
3.6.2 根据布局树生成分层树
client/request.js
diff
省略中间代码 ...
/** 渲染进程 **/
// 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];
省略中间代码 ...
// 开始接收响应体
response.on('data', (buffer) => {
省略中间代码 ...
// 5.并计算各个元素的布局信息
updateLayoutTree(layoutTree);
+ // 6. 根据布局树生成分层树
+ const layers = [layoutTree];
+ createLayerTree(layoutTree, layers);
+ console.log(layers);
// 触发DOMContentLoaded事件
main.emit('DOMContentLoaded');
// 9.HTML解析完毕和加载子资源页面加载完成后会通知主进程页面加载完成
main.emit('Load');
});
}
})
+/**
+ * 创建图层树
+ * @param {*} element 节点元素
+ * @param {*} layers 图层列表
+ * @returns
+ */
+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;
+}
省略中间代码 ...
// 1.主进程接收用户输入的URL
main.emit('request', { host, port, path: '/load.html' });
(七)绘制
- 根据分层树进行生成绘制步骤合成图层
- 每个图层会拆分成多个绘制指令 ,这些指令组合在一起成为绘制列表
3.6.1 栅格化线程奖图块(tile
)转换成位图
client/request.js
diff
/** 渲染进程 **/
// 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];
省略中间代码 ...
response.on('end', () => {
省略中间代码 ...
// 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');
});
}
})
+/**
+ * 合成图层
+ * @param {*} layers
+ * @returns
+ */
+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;
+}
省略中间代码 ...
// 1.主进程接收用户输入的URL
main.emit('request', { host, port, path: '/load.html' });
(八)合成线程
- 合成线程将图层分成图块 (
tile
) - 合成线程会把分好的图块 发给栅格化线程池,栅格化线程 会把图块 (
tile
)转化为位图 - 而其实栅格化线程 在工作的时候会把栅格化的工作交给GPU 进程 来完成,最终生成的位图 就保存在了GPU 内存中
- 当所有的图块 都光栅化之后合成线程会发送**绘制图块(
DrawQuad
)**的命令给浏览器主进程 - 浏览器主进程然后会从 GPU 内存 中取出位图显示到页面上
3.7.1 图块
- 图块 渲染也称基于瓦片渲染或基于小方块渲染
- 为什么要分成图块 ?因为分成一个个小的图块后,可以分给GPU 线程来合成,提高效率
- 它是一种通过规则的网格细分计算机图形图像并分别渲染图块 (
tile
)各部分的过程
3.7.2 栅格化
- 栅格化 是将矢量图形格式表示的图像转换成**位图(
bitMaps
)**以用于显示器输出的过程 - 栅格即像素
- 栅格化 即将矢量图形转化为位图(
bitMaps
)(栅格图像)
3.7.3 gpu 进程
client/gpu.js
diff
const EventEmitter = require('events');
class GPU extends EventEmitter {
constructor() {
super();
+ this.bitMaps = [];
}
}
const gpu = new GPU();
module.exports = gpu;
3.7.4 合成线程把分好的图块发给栅格化线程池, 转化为位图, 保存在 GPU 内存中
client/request.js
diff
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;
省略中间代码 ...
+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];
省略中间代码 ...
response.on('end', () => {
省略中间代码 ...
+ // 8.把绘制步骤交给渲染进程中的合成线程进行合成
+ // 9.合成线程会把图层划分为图块(tile)
+ const tiles = splitTiles(paintSteps);
+ // 10.合成线程会把分好的图块发给栅格化线程池
+ raster(tiles);
// 触发DOMContentLoaded事件
main.emit('DOMContentLoaded');
// 9.HTML解析完毕和加载子资源页面加载完成后会通知主进程页面加载完成
main.emit('Load');
});
}
})
+/**
+ * 切分为小图块
+ * @param {*} paintSteps 绘制步骤
+ * @returns
+ */
+function splitTiles(paintSteps) {
+ return paintSteps;
+}
+
+/**
+ * 把切好的图片进行光栅化处理,就是变成类似马赛克
+ * @param {*} tiles 图块
+ */
+function raster(tiles) {
+ // 11.栅格化线程会把图片(tile)转化为位图
+ tiles.forEach(tile => rasterThread(tile));
+ // 13.当所有的图块都光栅化之后合成线程会发送绘制图块的命令给浏览器主进程
+ main.emit('drawQuad');
+}
+
+/**
+ * 光栅化位图
+ * 光栅化线程:1个光栅化线程,1秒是1张;如果是10张图片,10个线程,一秒就可以画10张
+ * @param {*} tile 图块
+ */
+function rasterThread(tile) {
+ // 12.而其实栅格化线程在工作的时候会把栅格化的工作交给GPU进程来完成
+ gpu.emit('raster', tile);
+}
省略中间代码 ...
+gpu.on('raster', (tile) => {
+ // 13.最终生成的位图就保存在了GPU内存中
+ let bitMap = tile;
+ gpu.bitMaps.push(bitMap);
+});
// 1.主进程接收用户输入的URL
main.emit('request', { host, port, path: '/load.html' });
(九)资源加载
CSS
加载不会影响DOM
解析CSS
加载不会阻塞JS
加载,但是会阻塞JS
执行JS
会依赖CSS
加载,因此JS
会阻塞DOM
解析- 为什么?因为
JS
可能需要访问或者操作CSSOM
,也就是CSS
没解析执行完毕,JS
不能执行,需要等待CSS
解析执行完毕。所以如果没有JS
,CSS
就不会阻塞主线程,也就是不会阻塞DOMContentLoaded
事件
- 为什么?因为
1. 测试加载引用 style 和 script 的渲染模版
server/public/load.html
html
<html>
<head>
<link href="/hello.css" rel="stylesheet" />
</head>
<body>
<div id="hello">hello</div>
<script src="/hello.js"></script>
<script>
console.log("world");
</script>
</body>
</html>
3.8.2 内部样式
server/public/hello.css
css
#hello {
color: green;
width: 100px;
height: 100px;
background: red;
}
3.8.3 内部脚本
server/public/hello.js
js
console.log("hello");
3.8.4 网络进程加载资源
用来加载网络资源的
client/network.js
diff
const EventEmitter = require('events');
+const http = require('http');
class Network extends EventEmitter {
+ fetchResource(options) {
+ return new Promise((resolve) => {
+ // 3.在网络进程中发起URL请求
+ const request = http.request(options, (response) => {
+ // 4.网络进程接收到响应头数据并转发给主进程
+ const headers = response.headers;
+ const buffers = [];
+ response.on('data', (buffer) => {
+ buffers.push(buffer);
+ });
+ response.on('end', () => {
+ resolve({
+ headers,
+ body: Buffer.concat(buffers).toString()
+ });
+ });
+ });
+ // 结束请求体
+ request.end();
+ });
+ }
}
const network = new Network();
module.exports = network;
3.8.5 加载外部 style、script 的过程
client/request.js
diff
// 省略中间代码 ...
+const loadingLinks = {};
+const loadingScripts = {};
// 省略中间代码 ...
onclosetag(tagname) {
// 识别不同的标签做不同的处理
switch (tagname) {
case 'style':
const styleToken = tokenStack.top();
const cssAST = css.parse(styleToken.children[0].text);
cssRules.push(...cssAST.stylesheet.rules);
break;
+ case 'link':
+ const linkToken = tokenStack[tokenStack.length - 1];
+ const href = linkToken.attributes.href;
+ const options = { host, port, path: href }
+ const promise = network.fetchResource(options).then(({ body }) => {
+ delete loadingLinks[href];
+ const cssAST = css.parse(body);
+ cssRules.push(...cssAST.stylesheet.rules);
+ });
+ loadingLinks[href] = promise;
+ break;
+ case 'script':
+ const scriptToken = tokenStack[tokenStack.length - 1];
+ const src = scriptToken.attributes.src;
+ if (src) {
+ const options = { host, port, path: src };
+ const promise = network.fetchResource(options).then(({ body }) => {
+ delete loadingScripts[src];
+ return Promise.all([...Object.values(loadingLinks), Object.values(loadingScripts)]).then(() => {
+ eval(body);
+ });
+ });
+ loadingScripts[src] = promise;
+ } else {
+ const script = scriptToken.children[0].text;
+ const ts = Date.now() + '';
+ const promise = Promise.all([...Object.values(loadingLinks), ...Object.values(loadingScripts)]).then(() => {
+ delete loadingScripts[ts];
+ eval(script);
+ });
+ loadingScripts[ts] = promise;
+ }
+ break;
+ default:
+ break;
+ }
省略中间代码 ...
// 1.主进程接收用户输入的URL
+main.emit('request', { host, port, path: '/load.html' });
四、借助 canvas
绘制来模拟浏览器渲染出的页面效果
(一)canvas
绘制生成的 result.png
图片
这里图片高度默认值有点高,导致下面留白,可以做调整
(二)启动静态服务器,浏览器访问 index.html
页面
1. 启动静态服务器
sh
npm run start
2. 打开浏览器访问 index.html
页面
(三)比较最终绘制的图像是否浏览器绘制的页面一致
可以看出来使用 canvas
绘制的图片,跟浏览器绘制的页面效果,除了文字的样式有细微差别,基本是保持一致的。
五、示例代码
本篇文章所有的代码传送门🚀,可结合食用更佳🍜