使用 javascript 伪代码来模拟浏览器的渲染流程

浏览器的渲染流程的对于前端 er 来说是重要的一环,不仅在平时开发中的问题排查,更体现在网页性能优化中作为重要的一环

一、进程架构

(一)进程和线程

  • 当启动一个程序时,操作系统会为该程序分配内存,用来存放代码、运行过程中的数据,这样的运行环境叫做进程
  • 一个进程可以启动和管理多个线程线程 之间可以共享进行数据,任何一个线程出错都可能会导致进程崩溃

(二)Chrome 的进程架构

  • 浏览器主进程: 负责界面显示、用户交互和子进程管理
  • 渲染进程: 排版引擎和 V8 引擎运行在该进程中,负责把 HTMLCSSJavaScript 转变成网页
  • 网络进程: 用来加载网络资源的
  • GPU 进程: 用来实现 CSS33D 效果
  • 插件进程: 负责维护 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",
                },
              ],
            },
          ],
        },
      ],
    },
  ],
};

(二)CSSstylesheet

  • 渲染进程把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 &amp;&amp; key == 'id') || (selector == '.' + value &amp;&amp; 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' &amp;&amp; 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' &amp;&amp; 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解析执行完毕。所以如果没有 JSCSS就不会阻塞主线程,也就是不会阻塞 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 绘制的图片,跟浏览器绘制的页面效果,除了文字的样式有细微差别,基本是保持一致的。

五、示例代码

本篇文章所有的代码传送门🚀,可结合食用更佳🍜

相关推荐
小镇程序员12 分钟前
vue2 src_Todolist全局总线事件版本
前端·javascript·vue.js
野槐14 分钟前
前端图像处理(一)
前端
程序猿阿伟21 分钟前
《智能指针频繁创建销毁:程序性能的“隐形杀手”》
java·开发语言·前端
疯狂的沙粒23 分钟前
对 TypeScript 中函数如何更好的理解及使用?与 JavaScript 函数有哪些区别?
前端·javascript·typescript
瑞雨溪31 分钟前
AJAX的基本使用
前端·javascript·ajax
力透键背34 分钟前
display: none和visibility: hidden的区别
开发语言·前端·javascript
程楠楠&M1 小时前
node.js第三方Express 框架
前端·javascript·node.js·express
盛夏绽放1 小时前
Node.js 和 Socket.IO 实现实时通信
前端·后端·websocket·node.js
想自律的露西西★1 小时前
用el-scrollbar实现滚动条,拖动滚动条可以滚动,但是通过鼠标滑轮却无效
前端·javascript·css·vue.js·elementui·前端框架·html5
白墨阳1 小时前
vue3:瀑布流
前端·javascript·vue.js