一个老生常谈的问题,从输入url到页面渲染完成之间发生了什么?

在这个过程中包括以下2大部分:

- 1.http请求响应
- 2.渲染

1.http请求响应

先来提三个问题:
1.当输入url后,浏览器如何包装发起请求?
2.在发出请求--接到响应之间发生了什么?
3.当返回请求结果后,浏览器如何解析结果?

1.1 请求

1.1.1 GET请求包装

1.为了知道浏览器是如何包装http请求的,使用nodejs搭建服务器

const http = require('http');

const server = http.createServer((req,res) => {
    if(req.url === '/'){
        res.end('hello')
    }
});

server.listen(8005,() => {
    console.log('server listen on http://localhost:8005')
});

2.服务器搭建好了,需要知道浏览器到底包装了什么信息,直接看控制台:

Request URL: http://localhost:8005/
Request Method: GET
Status Code: 200 OK
Remote Address: [::1]:8005
Referrer Policy: no-referrer-when-downgrade
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Cache-Control: max-age=0
Connection: keep-alive
Host: localhost:8005
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.131 Safari/537.36

1.1.2 POST请求包装

这些是浏览器自动包装过后的请求,包括请求行,请求头和请求主体,浏览器默认发送的是GET请求,如果需要指定POST请求,可以写个表单来验证一下,大概意思是浏览器发起post请求,服务端接收到后返回success,浏览器端显示返回的内容

//index.html
<!DOCTYPE HTML>
<html>
<body>

<form>
  <input type="text" id="val"/>
</form>
<button id="button">submit</button>
<div id="item"></div>

<script>
    var val = document.getElementById('val');
    var button = document.getElementById('button');
    var item = document.getElementById('item');

    button.addEventListener('click',function(){
    
        var oAjax = new XMLHttpRequest();
        oAjax.open('POST', 'http://localhost:8005', false);
        oAjax.setRequestHeader("Content-type", "application/*");
        var data = {
            value:val.value
        };
        oAjax.onreadystatechange = function() {
                    if (oAjax.readyState == 4 && oAjax.status == 200) {
                        item.innerHTML = oAjax.responseText;
                    } else {
                        console.log(oAjax);
                    }
                };
        oAjax.send(JSON.stringify(data));


    })

</script>

</body>
</html>

这样写的时候,由于html文件的协议是file,所以为了解决跨域问题,需要服务端进行设置

const http = require('http');

const server = http.createServer((req,res) => {
    if(req.url === '/'){
        res.setHeader("Access-Control-Allow-Origin", "*")
        res.setHeader("Access-Control-Allow-methods", "GET, POST, OPTIONS, PUT, DELETE")
        res.setHeader("Access-Control-Allow-Headers","*")
        res.setHeader("Content-type","application/plain")
        res.end('success!!!')
    }
});

server.listen(8005,() => {
    console.log('server listen on http://localhost:8005')
});

http
这样一次post请求就成功了,来看看浏览器默认包装了什么信息

Request URL: http://localhost:8005/
Request Method: POST
Status Code: 200 OK
Remote Address: [::1]:8005
//自动使用https协议
Referrer Policy: no-referrer-when-downgrade
Content-type: application/*
Origin: null
User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.131 Safari/537.36

这些信息有的是我们自己在后端写的,有的是浏览器自动添加的

1.2 过程

1.2.1 整体流程

前面已经知道了浏览器在发起GET或者POST请求的时候会自动的添加的字段,那浏览器在发送请求后到接收到服务端传来的数据前这段时间发生了什么?
网上看到大家的回答大部分都是:

  • 1.接收 URL,并拆分成协议,网络地址,资源路径
  • 2.与缓存进行比对,如果请求的对象在缓存中,则直接进行第9步
  • 3.检查域名是否在本地的 host 的文件中,在则直接返回 IP 地址,不在则向 DNS 服务器请求,直到查询到 IP 地址
  • 4.浏览器向服务器发起一个 TCP 连接
  • 5.浏览器通过 TCP 连接向服务器发起 HTTP 请求,HTTP 三次握手,HTTPS 握手过程则复杂得多
  • 6.浏览器接受 HTTP 响应,这时候它能关闭 TCP 连接也能为另一个连接保留。
  • 7.检查 HTTP header 里的状态码,并做出不同的处理方式。比如:错误(4XX、5XX),重定向(3XX),授权请求(2XX)
  • 8.如果是可以缓存的,这个响应则会被存储起来
  • 9.浏览器进行解码响应,并决定如何处理该响应(比如HTML页面,图像,声音等等)
  • 10.浏览器渲染响应,或者为不能识别的类型提供下载的提示框

1.2.2 域名解析流程

这样的回答确实把相关的流程说了一遍,但是DNS是如何把域名解析成IP的?这个过程可以被观察到么?三次握手又是什么意思?
为了看到域名解析的过程,我们可以使用Nslookup,它是由微软发布用于对DNS服务器进行检测和排错的命令行工具
比如可以看一下,https://www.baidu.com它的IP是什么,nslookup https://www.baidu.com
我在查看的时候一直报延时错误,只好从网上引用一张图来说明一下了
nslookup
其中server代表本地地址ip,下面那个address是百度的ip
通过这样的方式就能看到具体域名解析的过程

1.2.3 三次握手流程

接下来是三次握手,当域名转化成IP后,浏览器沿着ip找到服务器,进行三次握手:

  • 第一次握手:客户端的应用进程主动打开,并向客户端发出请求报文段。其首部中:SYN=1,seq=x。
  • 第二次握手:服务器应用进程被动打开。若同意客户端的请求,则发回确认报文,其首部中:SYN=1,ACK=1,ack=x+1,seq=y
  • 第三次握手:客户端收到确认报文之后,通知上层应用进程连接已建立,并向服务器发出确认报文,其首部:ACK=1,ack=y+1。当服务器收到客户端的确认报文之后,也通知其上层应用进程连接已建立

hand
看到这里,有个问题,前两次握手已经把客户端和服务端联系在一起了,那为什么还要第三次握手?

如果是两次握手,当A想要建立连接时发送一个SYN,然后等待ACK,结果这个SYN因为网络问题没有及时到达B,所以A在一段时间内没收到ACK后,在发送一个SYN,B也成功收到,然后A也收到ACK,这时A发送的第一个SYN终于到了B,对于B来说这是一个新连接请求,然后B又为这个连接申请资源,返回ACK,然而这个SYN是个无效的请求,A收到这个SYN的ACK后也并不会理会它,而B却不知道,B会一直为这个连接维持着资源,造成资源的浪费,但如果是三次握手,如果第三次握手迟迟不来,服务器便会认为这个SYN是无效的,释放相关资源

1.3 响应

成功发起请求并完整走完了上述流程,浏览器能获得服务器发来的数据,那这些数据被放在哪里,它是如何被浏览器处理的?
其实这个问题很简单,在前面成功发起http请求后,服务端会有一个响应,这里面规定了各种文件格式

Access-Control-Allow-Headers: *
Access-Control-Allow-methods: GET, POST, OPTIONS, PUT, DELETE
Access-Control-Allow-Origin: *
Connection: keep-alive
Content-Length: 10
Content-type: application/plain
Date: Wed, 08 May 2019 07:12:14 GMT

2.渲染

2.1 整体流程

数据请求回来以后,浏览器是如何把数据转化成页面的呢?这个过程就涉及到了DOM树,CSSOM树,render树的生成和页面的绘制,先来贴图看看整体流程:
a
在构建DOM树的时候,遇到 js 和 CSS元素,HTML解析器就换将控制权转让给JS解析器或者是CSS解析器。开始构建CSSOM,在构建CSSOM树的时候,解析是从右向左进行的,DOM树构建完之后和CSSOM合成一棵render tree
有了Render Tree,浏览器已经能知道网页中有哪些节点、各个节点的CSS定义以及他们的从属关系。下一步操作称之为Layout,顾名思义就是计算出每个节点在屏幕中的位置
Layout后,浏览器已经知道了哪些节点要显示(which nodes are visible)、每个节点的CSS属性是什么(their computed styles)、每个节点在屏幕中的位置是哪里(geometry)。就进入了最后一步:Painting,按照算出来的规则,通过显卡,把内容画到屏幕上,HTML默认是流式布局的,CSS和js会打破这种布局,改变DOM的外观样式以及大小和位置,当尺寸改变时会reflow,也就是重新绘制,比如table布局整体尺寸改变,页面就需要重绘,但当非尺寸改变时,会进行replaint

通过这个分析知道了DOM树的生成过程中可能会被CSS和JS的加载执行阻塞,所以平时写CSS时,尽量用id和class,千万不要过渡层叠,尽量减少会造成reflow的操作,把JS代码放到页面底部,且JavaScript 应尽量少影响 DOM 的构建

2.2 底层源码

这样说一遍,还是在很表面的层次在说渲染这件事,那有没有更深层次的理解呢?可以通过看浏览器源码来进行分析:
大致分为三个步骤:

  • 1.HTMLDocumentParser负责解析html文本为tokens
  • 2.HTMLTreeBuilder对这些tokens分类处理
  • 3.HTMLConstructionSite调用不同的函数构建DOM树

parser
接下来使用这个html文档来说明DOM树的构建过程:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
</head>
<body>
    <div>
        <h1 class="title">demo</h1>
        <input value="hello">
    </div>
</body>
</html>

2.2.1生成tokens

首先是>>>HTMLDocumentParser负责解析html文本为tokens

void DocumentLoader::commitData(const char* bytes, size_t length) {
  ensureWriter(m_response.mimeType());
  if (length)
    m_dataReceived = true;
  m_writer->addData(bytes, length);//内部调用HTMLDocumentParser
}

构建出来的token是包含页面元素的信息表:

tagName: html  |type: DOCTYPE   |attr:              |text: "
tagName:       |type: Character |attr:              |text: \n"
tagName: html  |type: startTag  |attr:              |text: "
tagName:       |type: Character |attr:              |text: \n"
tagName: head  |type: startTag  |attr:              |text: "
tagName:       |type: Character |attr:              |text: \n    "
tagName: meta  |type: startTag  |attr:charset=utf-8 |text: "
tagName:       |type: Character |attr:              |text: \n"
tagName: head  |type: EndTag    |attr:              |text: "
tagName:       |type: Character |attr:              |text: \n"
tagName: body  |type: startTag  |attr:              |text: "
tagName:       |type: Character |attr:              |text: \n    "
tagName: div   |type: startTag  |attr:              |text: "
tagName:       |type: Character |attr:              |text: \n        "
tagName: h1    |type: startTag  |attr:class=title   |text: "
tagName:       |type: Character |attr:              |text: demo"
tagName: h1    |type: EndTag    |attr:              |text: "
tagName:       |type: Character |attr:              |text: \n        "
tagName: input |type: startTag  |attr:value=hello   |text: "
tagName:       |type: Character |attr:              |text: \n    "
tagName: div   |type: EndTag    |attr:              |text: "
tagName:       |type: Character |attr:              |text:     \n"
tagName: body  |type: EndTag    |attr:              |text: "
tagName:       |type: Character |attr:              |text: \n"
tagName: html  |type: EndTag    |attr:              |text: "
tagName:       |type: Character |attr:              |text: \n"
tagName:       |type: EndOfFile |attr:              |text: "

2.2.2tokens分类

接着是>>>>>HTMLTreeBuilder对这些tokens分类处理

void HTMLTreeBuilder::processToken(AtomicHTMLToken* token) {
  if (token->type() == HTMLToken::Character) {
    processCharacter(token);
    return;
  }
 
  switch (token->type()) {
    case HTMLToken::DOCTYPE:
      processDoctypeToken(token);
      break;
    case HTMLToken::StartTag:
      processStartTag(token);
      break;
    case HTMLToken::EndTag:
      processEndTag(token);
      break;
    //othercode
  }
}

2.2.3 构建DOM树

最后,最关键的就是HTMLConstructionSite调用不同的函数构建DOM树,它根据不同的节点类型进行不同的处理

1.DOCTYPE的处理
 // tagName不是html,那么文档类型将会是怪异模式
  if (name != "html" ) {
    setCompatibilityMode(Document::QuirksMode);
    return;
  }
 // html4写法,文档类型是有限怪异模式
  if (!systemId.isEmpty() &&
       publicId.startsWith("-//W3C//DTD HTML 4.01 Transitional//",
                           TextCaseASCIIInsensitive))) {
    setCompatibilityMode(Document::LimitedQuirksMode);
    return;
  }
  // h5的写法,标准模式
  setCompatibilityMode(Document::NoQuirksMode);

不同的模式会造成什么影响?

  // There are three possible compatibility modes:
  // Quirks - quirks mode emulates WinIE and NS4. CSS parsing is also relaxed in
  // this mode, e.g., unit types can be omitted from numbers.
  // Limited Quirks - This mode is identical to no-quirks mode except for its
  // treatment of line-height in the inline box model.
  // No Quirks - no quirks apply. Web pages will obey the specifications to the
  // letter.
  //怪异模式会模拟IE,同时CSS解析会比较宽松,例如数字单位可以省略,
  //有限怪异模式和标准模式的唯一区别在于在于对inline元素的行高处理不一样
  //标准模式将会让页面遵守文档规定
2.开标签的处理

首先是<html>标签,处理这个标签的任务应该是实例化一个HTMLHtmlElement元素,然后把它的父元素指向document

HTMLConstructionSite::HTMLConstructionSite(
    Document& document)
    : m_document(&document),
      m_attachmentRoot(document)) {
}
void HTMLConstructionSite::insertHTMLHtmlStartTagBeforeHTML(AtomicHTMLToken* token) {
  HTMLHtmlElement* element = HTMLHtmlElement::create(*m_document);//创建一个html结点
  attachLater(m_attachmentRoot, element);//加到一个任务队列里面
  m_openElements.pushHTMLHtmlElement(HTMLStackItem::create(element, token));//压到一个栈里面,这个栈存放了未遇到闭标签的所有开标签
  executeQueuedTasks();//执行队列里面的任务
}
//建立一个task
void HTMLConstructionSite::attachLater(ContainerNode* parent,Node* child, bool selfClosing) {
  HTMLConstructionSiteTask task(HTMLConstructionSiteTask::Insert);
  task.parent = parent;
  task.child = child;
  task.selfClosing = selfClosing;
 
  // Add as a sibling of the parent if we have reached the maximum depth
  // allowed.
  if (m_openElements.stackDepth() > maximumHTMLParserDOMTreeDepth &&
      task.parent->parentNode())
    task.parent = task.parent->parentNode();
 
  queueTask(task);
}
//executeQueuedTasks根据task的类型执行不同的操作
void ContainerNode::parserAppendChild(Node* newChild) {
  if (!checkParserAcceptChild(*newChild))
    return;
    AdoptAndAppendChild()(*this, *newChild, nullptr);
  }
  notifyNodeInserted(*newChild, ChildrenChangeSourceParser);
}
//建立起html结点的父子兄弟关系
void ContainerNode::appendChildCommon(Node& child) {
  child.setParentOrShadowHostNode(this);//设置子元素的父结点,也就是会把html结点的父结点指向document
  if (m_lastChild) {
  //子元素的previousSibling指向老的lastChild,老的lastChild的nexSibling指向它
    child.setPreviousSibling(m_lastChild);
    m_lastChild->setNextSibling(&child);
  } else {
      //如果没有lastChild,会将这个子元素作为firstChild
    setFirstChild(&child);
  }
  //子元素设置为当前ContainerNode(即document)的lastChild
  setLastChild(&child);
}

每当遇到一个开标签时,就把它压起来,下一次再遇到一个开标签时,它的父元素就是上一个开标签,借助一个栈建立起了父子关系

3.闭标签的处理

第一个闭标签是head标签,它会把开的head标签pop出来,栈里面就剩下html元素了,所以当再遇到body时,html元素就是body的父元素了

m_tree.openElements()->popUntilPopped(token->name());

至此,一个url到页面的过程差不多就完成了,写这篇参考了很多文章,链接贴在下面,大家可以去看看:
1.简述TCP连接的建立与释放(三次握手、四次挥手):https://www.cnblogs.com/zhuwq...
2.从输入 URL 到页面加载完成发生了什么事:https://segmentfault.com/a/11...
3.十分钟读懂浏览器渲染流程:https://segmentfault.com/a/11...
4.从Chrome源码看浏览器如何构建DOM树 :https://zhuanlan.zhihu.com/p/...


旧城
94 声望6 粉丝

每行代码都产生价值。