阅读 87

从输入URL到页面渲染

文章只是用于本人所学知识的记录

前言:先看看浏览器的主要组成及主要进程

浏览器的主要组件包括:

  1. 用户界面

包括地址栏、后退/前进按钮、书签目录等,也就是你所看到的除了用来显示你所请求页面的主窗口之外的其他部分

  1. 浏览器引擎

负责窗口管理、Tab进程管理等

  1. 渲染引擎

内核,负责HTML解析、页面渲染

  1. 网络

用来完成网络调用,例如http请求,它具有平台无关的接口,可以在不同平台上工作

  1. UI 后端

用来绘制类似组合选择框及对话框等基本组件,具有不特定于某个平台的通用接口,底层使用操作系统的用户接口 6. JS引擎

JS解释器,用来解释执行JS代码,如Chrome和Nodejs采用的V8

  1. 数据存储

属于持久层,浏览器需要在硬盘中保存类似cookie的各种数据,HTML5定义了web database技术,这是一种轻量级完整的客户端存储技术

常见浏览器的渲染引擎和JS引擎:

浏览器的主要进程:

  1. Browser进程

浏览器的主线程,主要负责浏览器的页面管理、书签、前进后退、资源下载管理等,整个浏览器应用程序只有一个,对应上述浏览器组成中的浏览器引擎。

  1. **渲染进程 **

负责页面渲染、JS执行,对应的是上述的渲染引擎和JS引擎,Chrome会为每个Tab标签创建一个渲染进程。

  1. GPU进程

负责GPU渲染,整个浏览器应用程序只有一个,Chrome刚开始发布的时候是没有GPU进程的。而GPU的使用初衷是为了实现3D CSS的效果,只是随后网页、Chrome的UI界面都选择采用GPU来绘制,这使得GPU成为浏览器普遍的需求。最后,Chrome在其多进程架构上也引入了GPU进程

  1. 网络进程

主要负责页面的网络资源加载

  1. 插件进程

浏览器安装的插件(扩展程序),每个插件会创建一个进程

渲染进程

一个渲染进程主要包括如下线程:

  • GUI线程(主要负责解析HTML、CSS和渲染页面)

  • JS引擎线程(负责解析和执行JS代码)

  • 事件线程(控制事件循环)

  • 定时器线程(处理定时器相关逻辑)

  • 异步请求线程(发起Ajax时会生成该线程)

  1. GUI渲染线程与JS引擎线程是互斥的,当JS引擎执行时GUI线程会被挂起,页面的更新操作会等到JS引擎空闲时执行,这里涉及事件循环相关知识
  1. 一个渲染进程同时只有一个JS解析线程在运行
  2. JS引擎线程不停的处理事件线程推送到事件队列中的任务
  3. 定时器和异步请求最终生成的回调事件也有事件线程来控制和管理
那么来扩展一个问题:js执行会阻塞DOM树的解析和渲染,那么css加载会阻塞DOM树的解析和渲染吗?

答案是:
css加载不会阻塞DOM树的解析
css加载会阻塞DOM树的渲染
css加载不会阻塞DOM树的解析但是会阻塞后面js语句的执行

解释:
DOM解析和CSS加载是两个进程,所以CSS加载不会阻塞DOM的解析。
然而,由于Render Tree是依赖于DOM Tree和CSSOM Tree的,所以他必须等待到CSSOM Tree构建完成,也就是CSS资源加载完成(或者CSS资源加载失败)后,才能开始渲染。因此,CSS加载是会阻塞Dom的渲染的。
由于js可能会操作之前的Dom节点和css样式,因此浏览器会维持html中css和js的顺序。因此,样式表会在后面的js执行前先加载执行完毕。所以css会阻塞后面js的执行。
复制代码

再来看看互联网协议

互联网的核心是一系列协议,总称为"互联网协议"(Internet Protocol Suite)。它们对电脑如何连接和组网,做出了详尽的规定。理解了这些协议,就理解了互联网的原理。

五层模型

这里借鉴了阮一峰的博客,互联网模型一般是指OSI七层参考模型和TCP/IP四层参考模型

阮一峰把互联网分成五层

每一层都是为了完成一种功能。为了实现这些功能,就需要大家都遵守共同的规则。就叫做"协议",这些协议的总称,就叫做"互联网协议"

实体层

就是把电脑连接起来的物理手段(光缆、电缆、双绞线、无线电波等)。它主要规定了网络的一些电气特性,作用是负责传送0和1的电信号

链接层(以太网协议)

以太网协议,依靠MAC地址发送数据,连入网络的所有设备,都必须具有"网卡"接口。数据包必须是从一块网卡,传送到另一块网卡。网卡的地址,就是数据包的发送地址和接收地址,这叫做MAC地址

以太网规定,一组电信号构成一个数据包,叫做"帧"(Frame)。每一帧分成两个部分:标头(Head)和数据(Data)。

"标头"包含数据包的一些说明项,比如发送者、接受者、数据类型等等;"数据"则是数据包的具体内容。

"标头"的长度,固定为18字节。"数据"的长度,最短为46字节,最长为1500字节。因此,整个"帧"最短为64字节,最长为1518字节。如果数据很长,就必须分割成多个帧进行发送。

网络层(IP协议)

规定网络地址的协议,叫做IP协议。它所定义的地址,就被称为IP地址

根据IP协议发送的数据,就叫做IP数据包,IP数据包直接放进以太网数据包的"数据"部分

IP数据包也分为"标头"和"数据"两个部分

"标头"部分主要包括版本、长度、IP地址等信息,"数据"部分则是IP数据包的具体内容。它放进以太网数据包后,以太网数据包就变成了下面这样。

传输层

有了MAC地址和IP地址,我们已经可以在互联网上任意两台主机上建立通信

接下来的问题是,同一台主机上有许多程序都需要用到网络,比如,你一边浏览网页,一边与朋友在线聊天。当一个数据包从互联网上发来的时候,你怎么知道,它是表示网页的内容,还是表示在线聊天的内容?

也就是说,我们还需要一个参数,表示这个数据包到底供哪个程序(进程)使用。这个参数就叫做"端口"(port)

"传输层"的功能,就是建立"端口到端口"的通信。相比之下,"网络层"的功能是建立"主机到主机"的通信。只要确定主机和端口,我们就能实现程序之间的交流。

UDP协议

现在,我们必须在数据包中加入端口信息,这就需要新的协议。最简单的实现叫做UDP协议,它的格式几乎就是在数据前面,加上端口号。

UDP数据包,也是由"标头"和"数据"两部分组成。

"标头"部分主要定义了发出端口和接收端口,"数据"部分就是具体的内容。然后,把整个UDP数据包放入IP数据包的"数据"部分,所以整个以太网数据包现在变成了下面这样:

UDP协议的优点是比较简单,容易实现,但是缺点是可靠性较差,一旦数据包发出,无法知道对方是否收到。

TCP协议

这个协议非常复杂,但可以近似认为,它就是有确认机制的UDP协议,每发出一个数据包都要求确认。如果有一个数据包遗失,就收不到确认,发出方就知道有必要重发这个数据包了

因此,TCP协议能够确保数据不会遗失。它的缺点是过程复杂、实现困难、消耗较多的资源(下面有说到TCP三次握手)。

TCP数据包和UDP数据包一样,都是内嵌在IP数据包的"数据"部分。

应用层

"应用层"的作用,就是规定应用程序的数据格式。

举例来说,TCP协议可以为各种各样的程序传递数据,比如Email、WWW、FTP等等。那么,必须有不同协议规定电子邮件、网页、FTP数据的格式,这些应用程序协议就构成了"应用层"。

应用层的数据就放在TCP数据包的"数据"部分。因此,现在的以太网的数据包就变成下面这样

网络通信就是交换数据包。电脑A向电脑B发送一个数据包,后者收到了,回复一个数据包,从而实现两台电脑之间的通信。


扯了这么多,然后进入正题。。。

浏览器从输入URL到页面呈现出来,总体来说分为以下几个过程(涉及的知识太过庞大,只侧重前端讲解):

  1. DNS获取ip
  2. 建立TCP
  3. 发起HTTP
  4. 响应
  5. 浏览器渲染

DNS解析

DNS 是一个网络服务器(DNS服务器),我们的域名解析简单来说就是在 DNS 上记录一条信息记录,DNS协议可以帮助我们,将网址转换成IP地址。已知DNS服务器为8.8.8.8,于是我们向这个地址发送一个DNS数据包(53端口)。

然后,DNS服务器做出响应,告诉我们IP地址

在DNS解析之前,网络进程接收到url请求后先检查本地缓存是否缓存了该请求资源,如果有则将该资源返回给浏览器进程,这里涉及到HTTP协议的缓存策略问题

解析步骤(只做了解)

  • 查找浏览器缓存——近期浏览过的网站,浏览器会缓存 DNS 记录一段时间 (如果没有则往下) ;
  • 查找系统缓存——从 C 盘的 hosts 文件查找是否有存储的 DNS 信息,查找是否有目标域名和对应的 IP 地址 (如果没有则往下);
  • 查找路由器缓存 (如果没有则往下);
  • 查找 ISP DNS 缓存——从网络服务商(比如电信)的 DNS 缓存信息中查找(如果没有则往下);
  • 经由以上方式都没找到对应 IP 的话,就会向根域名服务器查找目标 URL 对应的 IP,根域名服务器会向下级服务器转达请求,层层下发,直到找到对应的 IP 为止

递归查询的过程:

首先在本地域名服务器中查询IP地址,如果没有找到的情况下,本地域名服务器会向根域名服务器发送一个请求,如果根域名服务器也不存在该域名时,本地域名会向com顶级域名服务器发送一个请求,依次类推下去。直到最后本地域名服务器得到google的IP地址并把它缓存到本地,供下次查询使用。

从上述过程中,可以看出网址的解析是一个从右向左的过程: com -> google.com -> www.google.com。但是你是否发现少了点什么,根域名服务器的解析过程呢?事实上,真正的网址是www.google.com.,并不是我多打了一个.,这个.对应的就是根域名服务器,默认情况下所有的网址的最后一位都是.,既然是默认情况下,为了方便用户,通常都会省略,浏览器在请求DNS的时候会自动加上,所有网址真正的解析过程为: . -> .com -> google.com. -> www.google.com.

DNS的优化与应用

DNS缓存

了解了DNS的过程,可以为我们带来哪些?上文中请求到google的IP地址时,经历了8个步骤,这个过程中存在多个请求(同时存在UDP和TCP请求)。如果每次都经过这么多步骤,是否太耗时间?

DNS存在着多级缓存,从离浏览器的距离排序的话,有以下几种: 浏览器缓存,系统缓存,路由器缓存,IPS服务器缓存,根域名服务器缓存,顶级域名服务器缓存,主域名服务器缓存。

DNS负载均衡

DNS可以返回一个合适的服务器IP给用户,例如可以根据每台机器的负载量,该机器离用户地理位置的距离等等,这种过程就是DNS负载均衡,又叫做DNS重定向。大家耳熟能详的CDN(Content Delivery Network)就是利用DNS的重定向技术,DNS服务器会返回一个跟用户最接近的点的IP地址给用户

dns-prefetch

浏览器会对某个域名预先进行 DNS 解析并缓存。这样,当浏览器在请求同域名资源的时候,能省去从域名服务器查询 IP 的过程,从而减少时间损耗

下图是淘宝网设置的 DNS 预解析

现在浏览器拿到了IP地址,浏览器将IP数据包嵌入以太网数据包

建立TCP链接

TCP报文格式

TCP报文是TCP层传输的数据单元,详细讲解点

三次握手建立TCP连接

TCP三次握手用以同步客户端和服务端的序列号和确认号,并交换TCP窗口大小信息

  • 第一次握手:客户端向服务端发送连接请求报文段,SYN和Seq,等待服务端确认(请求连接)
  • 第二次握手:服务端收到报文段确认后,向客户端发送报文段。SYN、ACK和Seq(好的,同意连接)
  • 第三次握手:客户端收到服务器的报文段,向服务器发送ACK和Seq。(好的,我知道你同意我连接了)

为什么要第三次挥手?

如果没有最后一个数据包确认(第三次握手),A先发出一个建立连接的请求数据包,由于网络原因绕远路了。A经过设定的超时时间后还未收到B的确认数据包。

于是发出第二个建立连接的请求数据包,这次网路通畅,B的确认数据包也很快就到达A。于是A与B开始传输数据;

过了一会A第一次发出的建立连接的请求数据包到达了B,B以为是再次建立连接,所以又发出一个确认数据包。由于A已经收到了一个确认数据包,所以会忽略B发来的第二个确认数据包,但是B发出确认数据包之后就要一直等待A的回复,而A永远也不会回复。

由此造成服务器资源浪费,这种情况多了B计算机可能就停止响应了。

TCP数据包设置好端口再嵌入IP数据包,由此成功建立了TCP连接,可以发送HTTP请求报文;服务器端此时也进入ESTABLISHED状态

发起HTTP请求

HTTP:超文本传输协议

HTTP请求报文是由三部分组成: 请求行, 请求头部和请求数据

HTTP请求报文

类似于下面这样

构建HTTP请求报文后,嵌在TCP数据包之中

然后,整个通信的数据包构造就成了这样

网络进程就将此包发送给服务器

响应

服务器处理请求并返回 HTTP 报文,每台服务器上都会安装处理请求的应用——Web server, web server 担任管控的角色,对于不同用户发送的请求,会结合配置文件,把不同请求委托给服务器上处理相应请求的程序进行处理,然后返回后台程序处理产生的结果作为响应

响应报文

响应报文是由三部分组成: 响应行, 响应头部和响应主体

类似于这样

响应行包含了协议版本,状态码,状态码描述

状态码

  • 1xx:指示信息--表示请求已接收,继续处理中。
  • 2xx:成功--请求正常处理完毕。
  • 3xx:重定向--要完成请求必须进行更进一步的操作。

301:永久重定向,请求的资源已被永久的移动到新的URI,响应头的Location字段里是新的URI,浏览器会自动定向到新的URI
302:暂时重定向,与301类似处理
304:不会返回任何资源,所请求的资源没有更改,可以使用缓存

  • 4xx:客户端错误--请求有语法错误或请求无法实现。

400:客户端请求报文语法错误,服务器无法理解
401:请求要求用户的身份认证
403:服务器理解请求客户端的请求,但是拒绝执行此请求
404:无法找到资源

  • 5xx:服务器端错误--服务器未能实现合法的请求。

500:服务器内部错误

四次挥手断开 TCP 连接

服务器返回 HTTP 报文后,当头部字段Conection不是Keep-Alive时,即不为TCP长连接时,通过四次挥手断开TCP连接

  • 第一次挥手:客户端发送数据包给服务器,其中标志位FIN=1,序号位seq=u,并停止发送数据(嘿,数据发完了我要关了,你准备好)

  • 第二次挥手:服务器收到数据包后,由于还需传输数据,无法立即关闭连接,先返回一个标志位ACK=1,序号seq=v,确认号ack=u+1的数据包 (好的,我知道你准备关闭了,我准备好关闭我告诉你)

  • 第三次挥手:服务器准备好断开连接后,返回一个数据包,其中标志位FIN=1,标志位ACK=1,序号seq=w,确认号ack=u+1(我准备好了)

  • 第四次挥手:客户端收到数据包后,返回一个标志位ACK=1,序号seq=u+1,确认号ack=w+1的数据包(好的,我知道你准备关闭了,那就关闭吧,由此正式确认关闭服务器端到客户端方向上的连接)

为什么“握手”是三次,“挥手”却要四次?

TCP建立连接时之所以只需要"三次握手",是因为在第二次"握手"过程中,服务器端发送给客户端的TCP报文是以SYN与ACK作为标志位的。SYN是请求连接标志,表示服务器端同意建立连接;ACK是确认报文,表示告诉客户端,服务器端收到了它的请求报文。即SYN建立连接报文与ACK确认接收报文是在同一次"握手"当中传输的,所以"三次握手"不多也不少,正好让双方明确彼此信息互通。

TCP释放连接时之所以需要“四次挥手”,是因为FIN释放连接报文与ACK确认接收报文是分别由第二次和第三次"握手"传输的。

为何建立连接时一起传输,释放连接时却要分开传输?

  • 建立连接时,被动方服务器端“握手”阶段并不需要任何准备,可以直接返回SYN和ACK报文,开始建立连接
  • 释放连接时,被动方服务器,突然收到主动方客户端释放连接的请求时并不能立即释放连接,因为还有必要的数据需要处理,所以服务器先返回ACK确认收到报文,经过CLOSE-WAIT阶段准备好释放连接之后,才能返回FIN释放连接报文

浏览器渲染

浏览器拿到响应文本后,接下来就是浏览器的渲染引擎开始工作

How browsers work详细的讲解了浏览器的工作流程,译文

渲染基本可以分为:解析html以构建dom树->构建render树->布局render树->绘制render树

webkit主流程:

Mozilla的Geoko 渲染引擎主流程:

构建dom树

我们用下面的html文件举例

<html>
	<head></head>
    <body>lagou</body>
</html>
复制代码

字节流解码

浏览器通过 HTTP 协议接收到的文档内容是字节数据,下图是抓包工具截获的报文截图,报文内容为左侧高亮显示的区域(为了查看方便,该工具将字节数据以十六进制方式显示)。当浏览器得到字节数据后,通过编码嗅探算法来确定字符编码,然后根据字符编码将字节流数据进行解码,生成截图右侧的字符数据,也就是我们编写的代码

输入流预处理

通过上一步解码得到的字符流数据在进入解析环节之前还需要进行一些预处理操作。比如将换行符转换成统一的格式,最终生成规范化的字符流数据

令牌化

解析包含两步,第一步是将字符数据转化成令牌(Token),第二步是解析 HTML 生成 DOM 树。

转化成令牌最终生成类似下面的令牌结构

开始标签:html
  	开始标签:head 结束标签:head
  	开始标签:body 字符串:lagou 结束标签:body
结束标签:html
复制代码

构建 DOM 树

解析来到第二步

浏览器在创建解析器的同时会创建一个 Document 对象。在树构建阶段,Document 会作为根节点被不断地修改和扩充。转化的令牌会被送到树构建器进行处理。HTML 5 标准中定义了每类令牌对应的 DOM 元素,当树构建器接收到某个令牌时就会创建该令牌对应的 DOM 元素并将该元素插入到 DOM 树中。

生成下面的 DOM 树结构

              Document
             /        \
DocumentType           HTMLHtmlElement
                      /               \
       HTMLHeadElement                 HTMLBodyElement
                                              |
                                          TextNode

复制代码

可以通过浏览器调试工具的Network面板中的DOMContentLoaded查看最后生成DOM树所需的时间:

构建 CSSOM(css对象模型)树

CSS 解析的过程与 HTML 解析过程步骤一致,最终也会生成树状结构。

与 DOM 树不同的是,CSSOM 树的节点具有继承特性,也就是会先继承父节点样式作为当前样式,然后再进行补充或覆盖,完整的 CSSOM 树还应当包括浏览器提供的默认样式(也称为“User Agent 样式”)

CSS“媒体类型”和“媒体查询”

CSS 是阻塞渲染的资源,所以需要减少css的体积,缩短渲染时间:

<link href="style.css" rel="stylesheet">
//适用于所有情况,始终会阻塞渲染
<link href="print.css" rel="stylesheet" media="print">
//只在打印内容时适用,因此在网页首次加载时,该样式表不需要阻塞渲染
<link href="other.css" rel="stylesheet" media="(min-width: 40em)">
//符合条件时,浏览器将阻塞渲染
复制代码

补充:遇到 script 标签时的处理

  • 内联代码,那么解析过程会暂停,执行权限会转给 JavaScript 脚本引擎,待 JavaScript 脚本执行完成之后再交由渲染引擎继续解析

  • 外联js,会暂停解析过程,同时通知网络线程加载文件,文件加载后会切换至 JavaScript 引擎来执行对应代码,代码执行完成之后切换至渲染引擎继续渲染页面

  • 外联 + async 属性,立即请求文件,但不阻塞渲染引擎,而是文件加载完毕后阻塞渲染引擎并立即执行

  • 外联 + defer 属性,立即请求文件,但不阻塞渲染引擎,等到解析完 HTML 之后再按顺序执行,能保证渲染引擎的优先执行

  • 外联 + type = “module”。让浏览器按照 ECMA Script 6 标准将文件当作模块进行解析,默认阻塞效果同 defer,也可以配合 async 在请求完成后立即执行

绿色的线表示执行解析 HTML ,蓝色的线表示请求文件,红色的线表示执行文件

需要注意的是,当渲染引擎解析 HTML 遇到 script 标签引入文件时,会立即进行一次渲染。所以这也就是为什么构建工具会把编译好的引用 JavaScript 代码的 script 标签放入到 body 标签底部,因为当渲染引擎执行到 body 底部时会先将已解析的内容渲染出来,然后再去请求相应的 JavaScript 文件。如果是内联脚本,渲染引擎则不会渲染。

构建render树

DOM 树包含的结构内容与 CSSOM 树包含的样式规则都是独立的,为了更方便渲染,先需要将它们合并成一棵渲染树

这个过程会从 DOM 树的根节点开始遍历,然后在 CSSOM 树上自下而上(浏览器进行CSS选择器匹配时,是从右向左进行)找到每个节点对应的样式

遍历过程中会自动忽略那些不需要渲染的节点(比如脚本标记、元标记等)以及不可见的节点(比如设置了“display:none”样式)。同时也会将一些需要显示的伪类元素加到渲染树中。

对于上面的 HTML 和 CSS 代码,最终生成的渲染树就只有一个 body 节点

布局render树

布局就是计算元素的大小及位置,转换样式表中的属性值,使其标准化。比如将em转换为px,布局完成后会输出对应的“盒模型”,它会精确地捕获每个元素的确切位置和大小,将所有相对值都转换为屏幕上的绝对像素

绘制render树

绘制就是将渲染树中的每个节点转换成屏幕上的实际像素的过程。得到布局树这份“施工图”之后,渲染引擎并不能立即绘制,因为还不知道绘制顺序,如果没有弄清楚绘制顺序,那么很可能会导致页面被错误地渲染。

例如,对于使用 z-index 属性的元素(如遮罩层)如果未按照正确的顺序绘制,则将导致渲染结果和预期不符(失去遮罩作用)。

所以绘制过程中的第一步就是遍历布局树,生成绘制记录,然后渲染引擎会根据绘制记录去绘制相应的内容

如何高效操作DOM

减少线程切换

上面已经提到,浏览器渲染引擎 和 JavaScript 引擎,它们都是单线程运行,且为了避免两个引擎同时修改页面而造成渲染结果不一致,这两个引擎具有互斥性

也就是说在某个时刻只有一个引擎在运行,另一个引擎会被阻塞。操作系统在进行线程切换的时候需要保存上一个线程执行时的状态信息并读取下一个线程的状态信息,俗称上下文切换。而这个操作相对而言是比较耗时的。

比如向页面添加n个元素:

const times = 10000;
console.time('createElement')
for (let i = 0; i < times; i++) {
  const div = document.createElement('div')
  document.body.appendChild(div)
}
console.timeEnd('createElement')// 54.964111328125ms

console.time('innerHTML')
let html=''
for (let i = 0; i < times; i++) {
  html+='<div></div>'
}
document.body.innerHTML += html // 31.919921875ms
console.timeEnd('innerHTML')
复制代码

每次 DOM 操作就会引发线程的上下文切换——从 JavaScript 引擎切换到渲染引擎执行对应操作,然后再切换回 JavaScript 引擎继续执行,这就带来了性能损耗,所以在循环外操作元素

或使用createDocumentFragment()

var frag = document.createDocumentFragment();
for (var i = 0; i < 1000; i++){
  var el = document.createElement('p');
  el.innerHTML = i; 
  frag.appendChild(el); //首先将新节点先添加到DocumentFragment 节点
}
document.body.appendChild(frag);//然后用appendChild插入文档中
复制代码

DocumentFragment(文档碎片节点)是一个插入结点时的过渡,我们把要插入的结点先放到这个文档碎片里面,然后再一次性插入文档中,这样就减少了页面渲染DOM元素的次数,它还有利于实现文档的剪切、复制和粘贴操作,具体用法点我

减少重排

另一个更加耗时的因素是元素及样式变化引起的再次渲染,在渲染过程中最耗时的两个步骤为重排(Reflow)与重绘(Repaint)。

如果在操作 DOM 时涉及到元素、样式的修改,就会引起渲染引擎重新计算样式生成 CSSOM 树,同时还有可能触发对元素的重新排布(简称“重排”)和重新绘制(简称“重绘”)。

可能会影响到其他元素排布的操作就会引起重排,继而引发重绘,比如:

  • 修改元素边距、大小
  • 添加、删除元素
  • 改变窗口大小

与之相反的操作则只会引起重绘,比如:

  • 设置背景图片

  • 修改字体颜色

  • 改变 visibility 属性值

重排渲染耗时明显高于重绘,重排会导致重绘。


到此,浏览器上已经有了肉眼可见的页面