浏览器渲染原理

进程

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

Chrome的进程架构

  • 浏览器进程: 负责界面显示,用户交互,子进程管理,提供存储等
  • 渲染进程:排版引擎和V8引擎主要运行在该进程中,负责把html, css, js转变成网页等等。
  • 网络进程:主要处理网络资源加载(HTML, CSS ,JS等)
  • GPU进程:3d绘制,提高性能。
  • 插件进程:chrome种安装的一些插件。
输入url到html返回
  • 浏览器进程接受用户输入的url

  • 浏览器进程把该URL转发给网络进程

  • 在网络进程中发起URL请求

  • 网络进程接收到响应头数据并转发给浏览器进程

  • 浏览器进程发送提交导航消息到渲染进程

  • 渲染进程开始从网络进程接收到html数据

  • HTML接收完毕后通知浏览器进程确认导航

  • 渲染进程开始HTML解析和加载子资源。

  • HTML解析完毕和子资源页面加载完成后会通知浏览器进程页面加载完毕。

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-u0H6C3Cl-1648467428429)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20220226143551312.png)]

用js模拟各个进程的工作

在浏览器基本原理,使用node的net模块模拟了请求,并获取数据,使用htmlparser2解析了返回来的html数据,这次继续使用js,模拟浏览器各个进程之间的工作。也就是模拟上面图的行为。

创建四个类

// gpu进程
const EventEmitter = require('events')
class Gpu extends EventEmitter {}
const gpu = new Gpu()
module.exports = gpu
// 主进程
const EventEmitter = require('events')
class Main extends EventEmitter {}
const main = new Main()
module.exports = main
// 网络进程
const EventEmitter = require('events')
class NetWork extends EventEmitter {}
const netWork = new NetWork()
module.exports = netWork
// 渲染进程
const EventEmitter = require('events')
class Render extends EventEmitter {}
const render = new Render()
module.exports = render

服务端

    const Http = require("http");
    const fs = require("fs");
    const path = require("path");
    const server = new Http.createServer();

    server.on("request", (req, res) => {
    console.log(req.headers);
    fs.createReadStream("./1.html").pipe(res);
    });

    server.listen(3000);

响应的html是

  <div class="div">123123</div>
    <script>
        let i = 0
        while (i < 100000) {
            i++
        }
        console.log(i);
    </script>
    <div>123123</div>
请求流程
// 请求文件
const http = require('http')
const main = require('./main')
const network = require('./network')
const render = require('./render')

/********** 浏览器进程 *******/
// 1 浏览器监听用户输入的url,转发给网络进程
main.on('request', (options)=>{
    network.emit('request', options)
})

// 4 浏览器进程发送提交导航消息到渲染进程
main.on('prepareRender',(response)=>{
    render.emit('commitNavigation',response)
})


/************ 网络进程 ************/
// 2 网络进程接手到浏览器进程的url,发起请求
network.on('request',(options)=>{
    // 调用http模块发起请求
    const request = http.request(options, (response)=>{
        // 3 网络进程接收到响应头数据并转发给浏览器进程
        main.emit('prepareRender',response)
    })
    request.end()
})

/********** 渲染进程 ***********/

// 5 渲染进程开始从网络进程接收到html数据, response是一个流
render.on('commitNavigation',(response)=>{
    const buffers = []
    response.on('data',(data)=>{
        buffers.push(data)
    })
    // 接收完毕
    response.on('end',()=>{
        const data = Buffer.concat(buffers) //二进制缓冲区
        const html = data.toString() //转成html字符串
        console.log('data',html);
        main.emit('DOMContentLoaded') //dom加载完毕之后主进程会触发该事件。
        main.emit('Load') //等css和图片加载之后就触发load事件
    })
})




// 模拟输入请求
const host = 'localhost'
const port = 3000
// 由主进程接收用户输入的url地址
main.emit('request',{
    host,
    port,
    path: '/index.html'
})

使用http模块发送请求。查看打印。[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wBV8BHO0-1648467428430)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20220226145710293.png)]

这里没有响应头是因为http模块已经帮我们处理好了。

渲染流程

  • 1 渲染进程将html转为dom树结构,将css文本转为stylesheet
  • 重新计算样式:根据stylesheet计算出dom节点样式; 创建布局树:根据dom树创建布局树。
  • 更新布局树:计算每个元素的布局信息;生成分层树:根据布局树,生成分层树;生成绘制指令:根据分层树进行生成绘制指令合集。
  • 把绘制指令合集交给渲染进程中的合成线程进行合成。
  • 分图块:合成线程将图层分为图块;他会把分好的图块交给栅格化线程池,栅格化线程池会把图块转为位图
  • 外包GPU:栅格化线程在工作的时候,会把栅格化的工作交给GPU进程来完成,最终生成的位图就保存在了GPU内存中
  • 当所有的图块都光栅化之后,合成线程发送绘制图块的命令给浏览器主进程
  • 浏览器主进程会从GPU内存中,取出位图显示到页面上。
1 HTML转为DOM树
  • 浏览器中的html解析器可以把hmtl字符串转成dom结构

  • html解析器边接收网络数据边解析html

  • 解析DOM: html转为token。通过维护一个token栈结构,模拟使用HtmlParser.parser,在遇到html标签的时候,将其压入栈中,然后匹配到内容的时候,将其存入对应元素的children,再到闭合标签的时候,将其推出栈。

    如下是token流,深度优先遍历。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TQjHvkQj-1648467428430)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20220226151213715.png)]

代码

// 请求文件
const http = require("http");
const css = require('css')
const main = require("./main");
const network = require("./network");
const render = require("./render");
const HtmlParser = require("htmlparser2");

/********** 浏览器进程 *******/
// 1 浏览器监听用户输入的url,转发给网络进程
main.on("request", (options) => {
  network.emit("request", options);
});

// 4 浏览器进程发送提交导航消息到渲染进程
main.on("prepareRender", (response) => {
  render.emit("commitNavigation", response);
});

/************ 网络进程 ************/
// 2 网络进程接手到浏览器进程的url,发起请求
network.on("request", (options) => {
  // 调用http模块发起请求
  const request = http.request(options, (response) => {
    // 3 网络进程接收到响应头数据并转发给浏览器进程
    main.emit("prepareRender", response);
  });
  request.end();
});

/********** 渲染进程 ***********/

// 5 渲染进程开始从网络进程接收到html数据, response是一个流
render.on("commitNavigation", (response) => {
  const headers = response.headers;
  // 不同的类型渲染进程解析不同
  const contentType = headers["content-type"];
  if (contentType.indexOf("text/html") !== -1) {
    // 1 通过渲染进程将html字符串转成dom树。

    // 维护一个token栈
    let stack = [{ type: "document", children: [] }];
    const parser = new HtmlParser.Parser({
      onopentag(name, attributes) {
        let parent = stack[stack.length - 1];
        let element = {
          tagName: name,
          attributes,
          children: [],
          parent,
        };
        parent.children.push(element);
        stack.push(element); // 当前的tag可能也有儿子
      },
      // 获取tag的内容
      ontext(text) {
        if (/^[\r\n\s]*$/.test(text)) {
          return;
        }
        let parent = stack[stack.length - 1]; // 因为上面的tag已经作为一个元素Push进stack了,这里直接获取,将text放入到children就行
        let textNode = {
          type: "text",
          text,
        };
        parent.children.push(textNode);
      },
      // 关闭tag,遇到闭合的,就将其从栈中取出,到最后的html退出,stack此时剩一个document,他是一颗树,通过children跟parent将整个Html变成dom树。
      onclosetag(name) {
        //只考虑内联css
        if (name === "style") {
          let parent = stack[stack.length - 1];
          const cssText = parent.children[0].text;
          parserCss(cssText);
          console.log("cssText", cssText);
        }

        // 遇到闭合,
        stack.pop();
        if (stack.length === 1) {
          //console.dir(stack, { depth: null });
        }
      },
    });

    response.on("data", (data) => {
      // 边接收数据,边解析
      parser.write(data);
    });
    // 接收完毕
    response.on("end", () => {
     // console.dir(stack, { depth: null });
      main.emit("DOMContentLoaded"); //dom加载完毕之后主进程会触发该事件。
      main.emit("Load"); //等css和图片加载之后就触发load事件
    });
  }
});


  //解析css
  function parserCss(styleText){
    const ast =  css.parse(styleText) //解析成styleSheet
    console.dir(ast, { depth: null });
 }

// 模拟输入请求
const host = "localhost";
const port = 3000;
// 由主进程接收用户输入的url地址
main.emit("request", {
  host,
  port,
  path: "/index.html",
});

解析html和解析css跟之前一样。

接着根据stylesheet计算出dom节点样式

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PkDeCgTn-1648467428431)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20220226161709513.png)]

看下css解析后的样子

{
  type: 'stylesheet',
  stylesheet: {
    source: undefined,
    rules: [
      {
        type: 'rule',
        selectors: [ '#hello' ],
        declarations: [
          {
            type: 'declaration',
            property: 'color',
            value: 'red',
            position: Position {
              start: { line: 3, column: 13 },
              end: { line: 3, column: 23 },
              source: undefined
            }
          }
        ],
        position: Position {
          start: { line: 2, column: 9 },
          end: { line: 4, column: 10 },
          source: undefined
        }
      },
      {
        type: 'rule',
        selectors: [ '#heelo2' ],
        declarations: [
          {
            type: 'declaration',
            property: 'color',
            value: 'green',
            position: Position {
              start: { line: 6, column: 13 },
              end: { line: 6, column: 25 },
              source: undefined
            }
          }
        ],
        position: Position {
          start: { line: 5, column: 9 },
          end: { line: 7, column: 10 },
          source: undefined
        }
      }
    ],
    parsingErrors: []
  }
}
  • 重新计算样式步骤:

可以看到,有selectore,值在declarations里面。所以我们写一个方法,来获取每个元素上的属性,然后匹配这些选择器,如果匹配成功,就表示该元素需要这些样式。

// 计算样式
function recalculateStyle(cssRules, element, parentStyle = {}) {
  const attributes = element.attributes; //获取元素的属性
  element.computedStyle = { color: parentStyle.color }; //样式继承
  Object.entries(attributes).forEach(([key, value]) => {
    // 遍历元素属性和css stylesheet,找到符合的样式
    cssRules.forEach((rule) => {
      let selector = rule.selectors[0];
      if (
        (key === "id" && selector === "#" + value) ||
        (key === "class" && selector === "." + value)
      ) {
        //匹配成功
        rule.declarations.forEach(item=>{
            const {property, value} = item
            // 将样式整合到元素的computedStyle对象上
            element.computedStyle[property] = value
        })
      }
    });


    if(key === 'style'){
        //行内样式
        const attributes = value.split(';')
        attributes.forEach(item=>{
          // style="display: none;"
            const [property, value] = item.split(/:\s*/)
            if(!property){
                return ;
            }
             // 将样式整合到元素的computedStyle对象上
             element.computedStyle[property] = value
        })
    }
  });
  // 递归让儿子执行。
  element.children.forEach(item=>recalculateStyle(cssRules, item, element.computedStyle))
}

默认color继承于父亲的样式,如果css的规则匹配到了元素,就在元素的computedStyle对象上添加,然后还要处理style行内样式。最后处理每个元素的children,递归处理。看结果:

在接收完毕之后,我们会会获取到一颗完整的dom树。
// 接收完毕
    response.on("end", () => {
      const document = stack[0]
      // 计算每个dom节点的具体样式 继承 层叠
      recalculateStyle(cssRules, document);
      console.dir(document, {depth: null});
      main.emit("DOMContentLoaded"); //dom加载完毕之后主进程会触发该事件。
      main.emit("Load"); //等css和图片加载之后就触发load事件
    });

然后传入第一个元素,开始计算样式结果是:

{
          tagName: 'body',
          attributes: {},
          children: [
            {
              tagName: 'div',
              attributes: {
                id: 'hello',
                style: 'font-size: 15px;font-weight: 700;'
              },
              children: [
                {
                  type: 'text',
                  text: '123123',
                  attributes: {},
                  children: [],
                  computedStyle: { color: 'red' }
                }
              ],
              parent: [Circular *3],
              computedStyle: {
                color: 'red',
                'font-size': '15px',
                'font-weight': '700'
              }
            },
     ....

计算好的样式存放在元素的computedStyle上了。这里继承只写了一个color,实际上还有很多css样式是可以继承的。

计算完样式需要,需要创建布局树了(将不可见的元素去掉)。

简单地说,就是将原本的dom树遍历,将不可见元素去掉

// 递归将不可见的元素去掉。
function createLayoutTree(element) {
  element.children = element.children.filter(isShow);
  element.children.forEach(createLayoutTree);
  return element;
}

function isShow(element) {
  let show = true;
  let invisibleTag = ["head", "script", "link"];
  const { tagName, attributes } = element;
  if (invisibleTag.includes(tagName)) {
    show = false;
  }

  // 接着遍历css,将不可见的元素去掉
  Object.entries(attributes).forEach(([key, value]) => {
    if (key === "style") {
      const styleAttributes = value.split(";");
      styleAttributes.forEach((item) => {
        const [property, value] = item.split(/:\s*/);
        if (!property) {
          return;
        }
        if (property === "display" && value === "none") {
          show = false;
        }
      });
    }
  });
  return show
}

如上,将link head script和不可见的元素统统去掉。

  <div id="hello" style="font-size: 15px;font-weight: 700;">123123</div>
    <div id="hello2" style="display: none;">123123</div>
 response.on("end", () => {
      const document = stack[0];
      // 计算每个dom节点的具体样式 继承 层叠
      recalculateStyle(cssRules, document);

      // 创建一个只包含可见元素的布局树
      const html = document.children[0];
      const body = html.children[1];
      console.dir(body, { depth: null });
      const layoutTree = createLayoutTree(body);
      console.dir(layoutTree, { depth: null });
      // 更新布局树,计算每个元素布局信息
      updateLayoutTree(layoutTree);

      main.emit("DOMContentLoaded"); //dom加载完毕之后主进程会触发该事件。
      main.emit("Load"); //等css和图片加载之后就触发load事件
    });
  }

结果:

<ref *1> {
  tagName: 'body',
  attributes: {},
  children: [
    {
      tagName: 'div',
      attributes: { id: 'hello', style: 'font-size: 15px;font-weight: 700;' },
      children: [
        {
          type: 'text',
          text: '123123',
          attributes: {},
          children: [],
          computedStyle: { color: 'red' }
        }
      ],
      parent: [Circular *1],
      computedStyle: { color: 'red', 'font-size': '15px', 'font-weight': '700' }
    }
  ],
 .....
}

body下面的div只剩一个id为hello的div了。

生成布局树之后,需要计算各个元素的布局。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kUTgTC6m-1648467485156)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20220226164310363.png)]

我们已经解析了html,并且重新计算了样式,布局阶段有两个步骤,创建布局树,更新布局树,我们已经创建了布局树,接着需要更新布局树。也就是通过我们刚才的种种计算,计算每个元素的样式。

// 计算布局树上每个元素的布局信息
// top 自己距离父亲的距离 , parentTop: 父亲距离body顶部的距离
function updateLayoutTree(element, top = 0, parentTop = 0) {
  const { computedStyle } = element;
  const { width, height, color } = computedStyle;
  element.layout = {
    top: top + parentTop, // layout.top就是本身到body顶部的距离
    left: 0,
    width,
    height,
    color,
  };
  // 第一个儿子跟父亲是零距离的
  let childTop = 0;
  // 计算儿子的
  element.children.forEach((child) => {
    updateLayoutTree(child, childTop, element.layout.top);
    // 第二个儿子与父亲的距离等于第一个儿子的高
    const height = child.computedStyle.height;
    if (height) {
      if (typeof height === "number") {
        childTop += height;
      } else if (height.includes("px")) {
        childTop += Number(height.split("px")[0]);
      }
    }
  });
}

主要做的事情,就是计算layout,计算top,left这些值,还有宽高啥的。这里只计算了top,left也是一样的道理,通过width计算。结果

  <div id="hello" style="font-size: 15px;font-weight: 700;">
        <div style="height: 50px;">1111</div>
        <div style="height: 50px;">2222</div>
    </div>
    <div>333333333</div>
const body = {
    tagName: 'body',
    attributes: {},
    children: [
     {
        tagName: 'div',
        attributes: { id: 'hello', style: 'font-size: 15px;font-weight: 700;' },
        children: [
          {
            tagName: 'div',
            attributes: { style: 'height: 50px;' },
            children: [
              {
               ...
                }
              }
            ],
            layout: {
              top: 0,
              left: 0,
              width: undefined,
              height: '50px',
              color: 'red'
            }
          },
          {
            tagName: 'div',
            attributes: { style: 'height: 50px;' },
            children: [ ...],
            parent: [Circular *1],
            computedStyle: { color: 'red', height: '50px' },
            layout: {
              top: 50,  // 第二个div = 第一个div高 + 父亲离顶部的距离
              left: 0,
              width: undefined,
              height: '50px',
              color: 'red'
            }
          }
        ],
        layout: {
          top: 0,
          left: 0,
          width: undefined,
          height: '100px',
          color: 'red'
        }
      },
      {
        tagName: 'div',
        attributes: {},
        children: [
          {
            type: 'text',
            text: '333333333',
            attributes: {},
            children: [],
            computedStyle: { color: undefined },
            layout: {
              top: 100, //这里也是同样道理,因为第一个div的高是100
              left: 0,
              width: undefined,
              height: undefined,
              color: undefined
            }
          }
        ],

如上,这样就可以准确计算每个元素的大小,所在的位置,更新布局树完成。

小总结:

渲染进行解析html的时候,是边接收数据便解析html的,将html解析成dom tree。将css文本解析成css stylesheet。然后根据css,遍历dom tree,更新每个元素的样式。然后再生成布局树(将不可见的元素从dom tree上去掉),接着再更新布局树(根据每个元素的样式,计算每个元素的layout,计算每个元素的位置,top ,left ,width, hegiht等等)

更新图层树(根据不同图层,生成分层树。)

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-e1WBzRJ5-1648467485156)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20220226164310363.png)]

现在到了更新图层树的操作了,也叫生成分层树。

  • 根据布局树,生成分层树。

  • 渲染引擎需要为某些节点生成单独的图层,并组合成图层树。

    • z-index
    • 绝对定位和固定定位
    • 滤镜,透明,裁剪
  • 这些图层合成最终的页面。

这个操作跟创建布局树类似(即将不可见的元素从dom tree去掉。),都是遍历dom tree,如果有可以创建新的图层的,就将他从当前图层的dom tree删除,创建新的图层。

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 isNotCreateNewLayer = true;
  const { tagName, attributes } = element;
  // 接着遍历css,如果可以创建图层,就创建图层
  Object.entries(attributes).forEach(([key, value]) => {
    if (key === "style") {
      const styleAttributes = value.split(";");
      styleAttributes.forEach((item) => {
        const [property, value] = item.split(/:\s*/);
        if (!property) {
          return;
        }
        // 有新图层的操作
        if (property === "position" &&( value === "absolute" || value === 'fixed') ) {
            updateLayoutTree(element) //新的图层,里面的元素的layout计算需要重新计算
            layers.push(element) //当前元素需要创建新的图层,所以将他push进去
            isNotCreateNewLayer = false;
        }
      });
    }
  });
  return createNewLayer;
}

如上,我们先只判断postition的结果,遍历dom tree,遇到符合条件的style,就将其从dom tree去掉,然后重新创建新的图层。

效果:

  <div id="hello" style="font-size: 15px;font-weight: 700;">
        <div style="height: 50px;">1111</div>
        <div style="height: 50px;"">2222</div>
    </div>
    <div style="z-index: 10;position:absolute;">333333333</div>
        <div id="hello2" style="position:fixed;">123123</div>

一共有三个图层。documnet一个,Postion: absolute一个,还有fixed一个。[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jc8nTVMv-1648467485157)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20220226201126256.png)]

    response.on("end", () => {
      const document = stack[0];
      // 计算每个dom节点的具体样式 继承 层叠
      recalculateStyle(cssRules, document);

      // 创建一个只包含可见元素的布局树
      const html = document.children[0];
      const body = html.children[1];
      const layoutTree = createLayoutTree(body);
      console.log("-------------------------");
      // 更新布局树,计算每个元素布局信息
      updateLayoutTree(layoutTree);

      // 根据布局树生成分层树
      const layers = [layoutTree];
      createLayerTree(layoutTree, layers);
      console.log(layers);
      main.emit("DOMContentLoaded"); //dom加载完毕之后主进程会触发该事件。
      main.emit("Load"); //等css和图片加载之后就触发load事件
    });

打印的layers是,

-------------------------
[
  {
    tagName: 'body',
    attributes: {},
    children: [ [Object], [Object], [Object] ],
    parent: {
      tagName: 'html',
      attributes: [Object],
      children: [Array],
      parent: [Object],
      computedStyle: [Object]
    },
    computedStyle: { color: undefined },       
    layout: {
      top: 0,
      left: 0,
      width: undefined,
      height: undefined,
      color: undefined
    }
  },
  {
    tagName: 'div',
    attributes: { style: 'z-index: 10;position:absolute;' },
    children: [ [Object] ],
    parent: {
      tagName: 'body',
      attributes: {},
      children: [Array],
      parent: [Object],
      computedStyle: [Object],
      layout: [Object]
    },
    computedStyle: { color: undefined, 'z-index': '10', position: 'absolute' },
    layout: {
      top: 0,
      left: 0,
      width: undefined,
      height: undefined,
      color: undefined
    }
  },
  {
    tagName: 'div',
    attributes: { id: 'hello2', style: 'position:fixed;' },
    children: [ [Object] ],
    parent: {
      tagName: 'body',
      attributes: {},
      children: [Array],
      parent: [Object],
      computedStyle: [Object],
      layout: [Object]
    },
    computedStyle: { color: 'green', position: 'fixed' },
    layout: {
      top: 0,
      left: 0,
      width: undefined,
      height: undefined,
      color: 'green'
    }
  }
]

一共三个图层,结果正确。

总结:更新图层树,就是从布局树中,将符合条件的元素取出来,从dom tree去掉,然后作为一个新的图层,最后得到了所有的图层。
绘制

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HjFbw1i6-1648467485157)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20220226201409190.png)]

更新图层树后,下一步就是绘制。[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jbeCvRii-1648467485157)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20220226201517774.png)]

这里可以看到浏览器绘制操作的时候做了什么事情。

  • 根据分层树,生成绘制步骤符合图层
  • 每个图层会拆分多个绘制指令,这些指令组合在一起称为绘制列表。
//  
response.on("end", () => {
      const document = stack[0];
      // 计算每个dom节点的具体样式 继承 层叠
      recalculateStyle(cssRules, document);

      // 创建一个只包含可见元素的布局树
      const html = document.children[0];
      const body = html.children[1];
      const layoutTree = createLayoutTree(body);
      console.log("-------------------------");
      // 更新布局树,计算每个元素布局信息
      updateLayoutTree(layoutTree);

      // 根据布局树生成分层树
      const layers = [layoutTree];
      createLayerTree(layoutTree, layers);
      console.log('layers',layers);
      // 根据分层树,生成绘制步骤, 并复合图层
      const paintSteps = composeteLayers(layers);

      console.log(paintSteps);
      main.emit("DOMContentLoaded"); //dom加载完毕之后主进程会触发该事件。
      main.emit("Load"); //等css和图片和Js加载之后就触发load事件,
    });


// 合成图层
function composeteLayers(layers) {
  return layers.map((layer) => paint(layer));
}

function paint(element, paintSteps = []) {
  const {
    top = 0,
    left = 0,
    color = "#000",
    background = "#fff",
    width = 100,
    height = 0,
  } = element.layout;
  if (element.type === 'text') {
    // 用canvas模拟绘图指令
    paintSteps.push(`ctx.font = '20px Impact'`);
    paintSteps.push(`ctx.strokeStyle = '${color}'`);
    paintSteps.push(
      `ctx.strokeText("${element.text}", ${parseFloat(left)}, ${top + 20})`
    );
  } else {
    paintSteps.push(`ctx.fillStyle = '${background}'`);
    paintSteps.push(`ctx.fillRect(${left}, ${top}, ${parseFloat(width)}, ${parseFloat(height)})`);
    paintSteps.push(`ctx.fillStyle = '${background}'`);
  }

  //绘制自己之后要绘制儿子
  element.children.forEach(child => paint(child, paintSteps))
  return paintSteps
}

主要代码如上,将我们上一步获取到的图层数组,传入,然后根据图层一步一步绘制出canvas的指令合集,然后将其组合在一起,打印结果是:

  <style>
        #hello {
            color: red;
            height: 100px;
            position: relative;
        }

        #hello2 {
            color: green;
        }
    </style>

</head>

<body>
    <div id="hello" style="font-size: 15px;font-weight: 700;">
        <div style="height: 50px;">1111</div>
        <div style="height: 50px;">2222</div>
    </div>
    <div  style="position:fixed;top: 150px">333333333</div>
        <div id="hello2" style="position:fixed;top: 100px">123123</div>

        <script>
            let i = 0
            while (i < 100000) {
                i++
            }
            console.log(i);
        </script>
</body>

如上,一共有三个图层

[
    [
      "ctx.fillStyle = '#fff'",
      'ctx.fillRect(0, 0, 100, 0)',
      "ctx.fillStyle = '#fff'",
      "ctx.fillStyle = '#fff'",
      'ctx.fillRect(0, 0, 100, 100)',
      "ctx.fillStyle = '#fff'",
      "ctx.fillStyle = '#fff'",
      'ctx.fillRect(0, 0, 100, 50)',
      "ctx.fillStyle = '#fff'",
      "ctx.font = '20px Impact'",
      "ctx.strokeStyle = 'red'",
      'ctx.strokeText("1111", 0, 20)',
      "ctx.fillStyle = '#fff'",
      'ctx.fillRect(0, 50, 100, 50)',
      "ctx.fillStyle = '#fff'",
      "ctx.font = '20px Impact'",
      "ctx.strokeStyle = 'red'",
      'ctx.strokeText("2222", 0, 70)'
    ],
    [
      "ctx.fillStyle = '#fff'",
      'ctx.fillRect(0, 150, 100, 0)',
      "ctx.fillStyle = '#fff'",
      "ctx.font = '20px Impact'",
      "ctx.strokeStyle = '#000'",
      'ctx.strokeText("333333333", 0, 170)'
    ],
    [
      "ctx.fillStyle = '#fff'",
      'ctx.fillRect(0, 100, 100, 0)',
      "ctx.fillStyle = '#fff'",
      "ctx.font = '20px Impact'",
      "ctx.strokeStyle = 'green'",
      'ctx.strokeText("123123", 0, 120)'
    ]
  ]

三个图层的绘制指令,我们将其组合在一起,然后输出试试。

通过canvas是这样的

const { createCanvas  } = require('canvas')
const fs = require('fs')
const canvas = createCanvas(350, 350)
const ctx = canvas.getContext('2d')

// 第一个图层
ctx.fillStyle = '#fff'    
ctx.fillRect(0, 0, 100, 0) 
ctx.fillStyle = '#fff'      
ctx.fillStyle = '#fff'      
ctx.fillRect(0, 0, 100, 100)
ctx.fillStyle = '#fff'     
ctx.fillStyle = '#fff'    
ctx.fillRect(0, 0, 100, 50)
ctx.fillStyle = '#fff',
ctx.font = '20px Impact'
ctx.strokeStyle = 'red'
ctx.strokeText(1111, 0, 20)
ctx.fillStyle = '#fff'
ctx.fillRect(0, 50, 100, 50)
ctx.fillStyle = '#fff'
ctx.font = '20px Impact'
ctx.strokeStyle = 'red'
ctx.strokeText(2222, 0, 70)

// 第二个图层
ctx.fillStyle = '#fff'
ctx.fillRect(0, 150, 100, 0)
ctx.fillStyle = '#fff'
ctx.font = '20px Impact'
ctx.strokeStyle = '#000'
ctx.strokeText(333333333, 0, 170)

// 第三个图层
ctx.fillStyle = '#fff'
ctx.fillRect(0, 100, 100, 0)
ctx.fillStyle = '#fff'
ctx.font = '20px Impact'
ctx.strokeStyle = 'green'
ctx.strokeText("123123", 0, 120)

fs.writeFileSync('result.png',canvas.toBuffer('image/png'))

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MXXZHVDe-1648467485157)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20220226205337152.png)]

输出在浏览器的页面是这样的。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MTVBtsGq-1648467485157)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20220226205257777.png)]

虽然有点差异,但是已经差不多了,此时绘制的工作基本就完成了。接着轮到最后步骤了,合成线程 。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-eotWkrP1-1648467485157)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20220227221402820.png)]

合成线程
  • 合成线程将图层分为图块。他会把分好的图块交给栅格化线程池,栅格化线程池会把图块转为位图
  • 栅格化线程在工作的时候,会把栅格化的工作外包交给GPU进程来完成,最终生成的位图就保存在了GPU内存中
  • 当所有的图块都光栅化之后,合成线程发送绘制图块的命令给浏览器主进程
  • 浏览器主进程会从GPU内存中,取出位图显示到页面上。
图块
  • 图块渲染也称瓦片渲染或者小方块渲染
  • 它是一种通过规则的网格细分计算机图形图像并分别渲染图块各部分的过程
  • 因为整个图层渲染比较花费性能,而分成图选加载,可以并行,加快速度。
具体实现,使用伪代码简单模拟一下
 // 接收完毕
    response.on("end", () => {
      const document = stack[0];
      // 计算每个dom节点的具体样式 继承 层叠
      recalculateStyle(cssRules, document);

      // 创建一个只包含可见元素的布局树
      const html = document.children[0];
      const body = html.children[1];
      const layoutTree = createLayoutTree(body);
      // 更新布局树,计算每个元素布局信息
      updateLayoutTree(layoutTree);

      // 根据布局树生成分层树
      const layers = [layoutTree];
      createLayerTree(layoutTree, layers);
      // 根据分层树,生成绘制步骤, 并复合图层
      const paintSteps = composeteLayers(layers);

      //交给合成线程,变成图块
      const tiles = splitTiles(paintSteps);
      //交给栅格化线程,转为位图
      rester(tiles);

      main.emit("DOMContentLoaded"); //dom加载完毕之后主进程会触发该事件。
      main.emit("Load"); //等css和图片加载之后就触发load事件
    });

调用splitTiles,转为图块,调用rester,交给栅格化线程。

// 合成线程 切成图块
function splitTiles(paintSteps) {
  // 切分图块
  return paintSteps;
}

// 栅格化线程,将图块转为位图,简称画图,多个线程可以在同一时间画多张图,而且他的工作是外包给GPU进程的。
function resterThread(tile) {
  gpu.emit("raster", tile); //GPU进程完成后,合成线程会通知主进程,可以显示了。
  main.emit("drawQuad");
}

// 栅格化线程  光栅化处理,就是类似变成马赛克,他会将图块转为位图
function rester(tiles) {
  tiles.forEach((tile) => resterThread(tile));
}

/*******GPU进程 ******/
gpu.on("raster", (tile) => {
  // 将位图放入内存
  gpu.bitMaps.push(tile);
});

// 接收到合成线程的通知,可以从GPU内存中获取位图了
main.on("drawQuad", () => {
  console.log('---------gpu.bitMaps', gpu.bitMaps);
});

这里只是简单模拟一下,绘制指令交给合成线程,生成图块交给栅格化线程,栅格化线程会将工作外包给GPU,GPU进程会生成位图保存在内存中,然后最终通知主进程,drawQuad了。可以获取位图了。打印接结果是

 [
  [
    "ctx.fillStyle = '#fff'",
    'ctx.fillRect(0, 0, 100, 0)',
    "ctx.fillStyle = '#fff'",
    "ctx.fillStyle = '#fff'",
    'ctx.fillRect(0, 0, 100, 100)',
    "ctx.fillStyle = '#fff'",
    "ctx.fillStyle = '#fff'",
    'ctx.fillRect(0, 0, 100, 50)',
    "ctx.fillStyle = '#fff'",
    "ctx.font = '20px Impact'",
    "ctx.strokeStyle = 'red'",
    'ctx.strokeText("1111", 0, 20)',
    "ctx.fillStyle = '#fff'",
    'ctx.fillRect(0, 50, 100, 50)',
    "ctx.fillStyle = '#fff'",
    "ctx.font = '20px Impact'",
    "ctx.strokeStyle = 'red'",
    'ctx.strokeText("2222", 0, 70)'
  ],
  [
    "ctx.fillStyle = '#fff'",
    'ctx.fillRect(0, 150, 100, 0)',
    "ctx.fillStyle = '#fff'",
    "ctx.font = '20px Impact'",
    "ctx.strokeStyle = '#000'",
    'ctx.strokeText("333333333", 0, 170)'
  ],
  [
    "ctx.fillStyle = '#fff'",
    'ctx.fillRect(0, 100, 100, 0)',
    "ctx.fillStyle = '#fff'",
    "ctx.font = '20px Impact'",
    "ctx.strokeStyle = 'green'",
    'ctx.strokeText("123123", 0, 120)'
  ]
]

已经获取到了,接着使用canvas模拟生成就行了。

// 接收到合成线程的通知,可以从GPU内存中获取位图了
main.on("drawQuad", () => {
  const drawSteps = gpu.bitMaps.flat().join('\r\n')
  console.log('drawSteps',drawSteps);
  eval(drawSteps)
  fs.writeFileSync('result.png',canvas.toBuffer('image/png'))
});

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ay2n39Be-1648467485157)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20220227222334918.png)]

接着我们还需要处理一下外链的css和js。

css不阻塞html解析。

js阻塞html解析,js执行依赖它上面的js和css解析执行完毕才能执行。所以。我们这里模拟一下。

<!DOCTYPE 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">
    <title>Document</title>
    <link rel="stylesheet" href="./public/1.css">
</head>

<body>
    <div id="hello" style="font-size: 15px;font-weight: 700;">
        <div style="height: 50px;">1111</div>
        <div style="height: 50px;">2222</div>
    </div>
    <div style="position:fixed;top: 150px">333333333</div>
    <div id="hello2" style="position:fixed;top: 100px">123123</div>
    <script src="./public/1.js"></script>
    <script>
        console.log('world');
    </script>
</body>

</html>

// 1.js
console.log('hello');
//1.css
#hello {
    color: red;
    height: 100px;
    position: relative;
}

#hello2 {
    color: green;
}

按照正常的逻辑,css和js的请求是同时的,但是js执行会阻塞html,并且依赖于它上部的css和Js。

改造一下我们的服务端

const Http = require("http");
const fs = require("fs");
const path = require("path");
const server = new Http.createServer();

server.on("request", (req, res) => {
  console.log(req.url);
  if (req.url === "/public/1.css") {
    fs.createReadStream("./public/1.css").pipe(res);
  } else if (req.url === "/public/1.js") {
    fs.createReadStream("./public/1.js").pipe(res);
  } else {
    res.setHeader("content-type", "text/html");
    fs.createReadStream("./1.html").pipe(res);
  }
});

server.listen(3000);

然后开始写请求代码:

网络进程需要一个请求资源的方法


// 网络进程
const EventEmitter = require("events");
const http = require("http");

class NetWork extends EventEmitter {
  // 请求资源
  fetchResource(options) {
    return new Promise((resolve) => {
      const request = http.request(options, (response) => {
        const headers = response.headers;
        const buffers = [];

        response.on("data", (buffer) => {
          buffers.push(buffer);
        });
        response.on("end", () => {
          const data = Buffer.concat(buffers);
          resolve({ headers, body: data });
        });
      });
      request.end();
    });
  }
}

const netWork = new NetWork();

module.exports = netWork;

首先是对css的处理

在解析html的时候,遇到闭合标签为Link的,就请求css,当前,一般浏览器会在解析html之前就会预加载css和js(设置prefetch的话)

// 存放正在加载的css和Js的promise
const loadingLinks = {};
const loadingScripte = {};

//  解析html的onclosetag
// 关闭tag,遇到闭合的,就将其从栈中取出,到最后的html退出,stack此时剩一个document,他是一颗树,通过children跟parent将整个Html变成dom树。
      onclosetag(name) {
        //只考虑内联css
        if (name === "style") {
          let parent = stack[stack.length - 1];
          const cssText = parent.children[0].text;
          cssRules.push(parserCss(cssText));
        }

        // 外链css,js
        if (name === "link") {
          const styleToken = stack[stack.length - 1];
          const href = styleToken.attributes.href;
          const options = { host, port, path: href };
          // 网络进程获取资源
          const promise = network.fetchResource(options).then((res) => {
            const { body, headers } = res;
            cssRules.push(parserCss(body));
            // 加载完毕后去掉
            delete loadingLinks[href];
          });
          loadingLinks[href] = promise;
        }

        if (name === "script") {
          const scritpToken = stack[stack.length - 1];
          const src = scritpToken.attributes.src;
          // 需要等到前面的css和js执行完毕才能执行
          const preCss = Object.values(loadingLinks);
          const preScripts = Object.values(loadingScripte);
          if (src) {
            // 外链js
            const options = { host, port, path: src };
            const promise = network.fetchResource(options).then((res) => {
              return Promise.all([...preCss, ...preScripts]).then(() => {
                eval(res.body);
                delete loadingScripte[src];
              });
            });
            loadingScripte[src] = promise;
          } else {
            //内嵌js
            const scriptText = scritpToken.children[0].text;
            const ts = Date.now();
            const promise = Promise.all([...preCss, ...preScripts]).then(
              (res) => {
                delete loadingScripte[ts];
                eval(scriptText);
              }
            );
            loadingScripte[ts] = promise;
          }
        }

        stack.pop();
      },

可以看到,css直接请求,然后解析放入,而Js则需要等待它前面的js和css都加载完毕,才能执行,这里使用了eval。并且由于代码是同步执行的,所以这里在解析完html为dom tree后,重新计算样式的时候,需要注意

 response.on("end", () => {
      const document = stack[0];
      // 重新计算样式 计算每个dom节点的具体样式 继承 层叠
      const preCss = Object.values(loadingLinks);
      const preScripts = Object.values(loadingScripte);
      Promise.all([...preCss, ...preScripts]).then(() => {
        recalculateStyle(cssRules.flat(), document);

        // 创建一个只包含可见元素的布局树
        const html = document.children[0];
        const body = html.children[1];
        const layoutTree = createLayoutTree(body);

        // 更新布局树,计算每个元素布局信息
        updateLayoutTree(layoutTree);

        // 根据布局树生成分层树
        const layers = [layoutTree];
        createLayerTree(layoutTree, layers);
        // 根据分层树,生成绘制步骤, 并复合图层
        const paintSteps = composeteLayers(layers);

        //交给合成线程,变成图块
        const tiles = splitTiles(paintSteps);
        //交给栅格化线程,转为位图
        rester(tiles);

        main.emit("DOMContentLoaded"); //dom加载完毕之后主进程会触发该事件。
        main.emit("Load"); //等css和图片加载之后就触发load事件
      });
    });

使用promise.all等到所有的js和css执行完毕后再去重新计算样式。结果:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MTumuZ5l-1648467485158)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20220227230445042.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Iurub8xp-1648467485158)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20220227230454996.png)]

成功打印并且绘制。至此,浏览器的渲染结束。

总结

  • 浏览器从输入url开始,主进程接受url,检查浏览器的缓存,如果请求资源在缓存中,就检查缓存是否新鲜,有expries和cache-control字段

  • 若没有缓存或者缓存过期,浏览器就需要重新发起请求,先解析url的host,path,端口,协议等等。然后组成一个http请求报文(get请求)

  • 解析url获取ip地址(浏览器缓存,本地缓存,hosts文件,路由器缓存,DNS缓存,DNS解析)

  • 打开一个socket与目标IP建立TCP连接,三次握手。TCP连接后发起http请求。

  • 服务器解析请求并且解析,根据http头协商缓存决定是否返回304或者返回资源。

  • 浏览器接受资源,判断是否关闭TCP连接。四次挥手。检查状态吗,如果可以缓存就缓存。通过不同类型解析文件。如html

  • 网络进程接收到资源通知浏览器主进程,浏览器主进程通知渲染进程,渲染进程开始接受html并且解析。

  • 将Html解析成dom tree。

  • 将css转化成css stylesheet

  • 通过stylesheet和dom tree计算样式,将样式计算到dom节点的computedStyle上。

  • 生存布局树,也就是将不可见的元素从dom tree去掉。

  • 更新布局树,根据dom tree计算每个节点的位置,比如top, left等等

  • 生成分层树,根据图层规则,生成不同图层的dom tree

  • 根据分层树绘制 绘制指令合集。

  • 将指令合集交给合成线程,合成线程将图层分为图快。

  • 合成线程将图快交给栅格化线程,栅格化线程外包给gpu进程工作。

  • gpu进程将图块生成位图,保存在内存中。

  • 渲染进程通知浏览器主进程,渲染完毕可以显示。

  • 浏览器主进程从内存取出位图展示。
    自此,解析完毕

  • 3
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

coderlin_

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值