初探浏览器工作原理

初探浏览器工作原理

1 基础概念

1.1 主要功能

浏览器的主要功能是通过向服务器请求用户选择的网络资源,然后在浏览器窗口中展示。网络资源通常是 HTML 文档,但是也可以是 PDF、图片或其它类型的内容。网络资源的位置通常由用户使用 URI(统一资源标识符)指定。

浏览器解释和显示 HTML 文档的方式遵循由网络标准组织 W3C(World Wide Web Consortium,万维网联盟)制定并维护的 HTML 和 CSS 规范。

1.2 组件结构

浏览器的主要组件包括:

  1. 用户界面(User Interface):包括地址栏、前进 / 后退按钮、书签菜单等。

  2. 浏览器引擎(Brower Engine):处理界面和渲染引擎之间的操作。

  3. 渲染引擎(Rendering Engine):对请求到的内容(例如:HTML)进行解析并在屏幕上展示出来,常见的浏览器渲染引擎有 WebKit 和 Gecko。

  4. 网络(Networking):用于 HTTP 请求等网络调用。接口针对不同的平台有不同的实现方式,不依赖于某个平台。

  5. 用户界面后端(UI Backend):用于绘制基础的控件,例如:组合框和窗口。它暴露了一个与平台无关的通用接口,底层使用操作系统的用户界面方法。

  6. JS 解释器(JavaScript Interpreter):解析并执行 JavaScript 代码。

  7. 数据存储(Data Persistence):浏览器有时需要在本地存储数据,例如 cookies 。同时也支持 localStorage、IndexedDB、WebSQL 和 FileSystem 等存储机制。

    在这里插入图片描述

2 工作流程

2.1 构建 HTTP 请求

在浏览器地址栏输入 URL (例如:www.baidu.com)并按下 Enter 键后,浏览器首先会构建请求行信息(如下所示),构建好以后,浏览器准备发起网络请求。

// 请求行:请求方法 请求URI HTTP版本
GET /index.html HTTP/1.1

2.2 查找缓存

在真正发起网络请求之前,浏览器会先在浏览器缓存中查询是否有待请求的资源文件。如果浏览器缓存中存在待请求的资源文件,浏览器则会拦截该请求,返回缓存中的资源副本,并直接结束这个请求。如果浏览器缓存中不存在,浏览器则会发起网络请求。

浏览器缓存是一种在本地保存资源副本,以供下次请求时直接使用的技术。使用浏览器缓存可以缓解服务器端的压力,也可以实现网站资源快速加载。

2.3 解析 URL

浏览器使用 HTTP 作为应用层协议,用来封装请求的文本信息;并使用 TCP 作为传输层协议将请求发送到网络中。也就是说,浏览器通过 TCP 与服务器建立连接,然后将 HTTP 请求的内容通过 TCP 传输至服务器。而建立 TCP 连接需要知道目标主机(服务器)的 IP 地址和端口号,它们可以从 URL 中获取到,因此浏览器会对 URL 进行解析。通常情况下,如果 URL 没有特别指明端口号,那么 HTTP 默认使用 80 端口。

URL 一般包括下列内容:

  • 协议头(protocol):http、https、ftp、file等。
  • 主机域名(host): 也可以是 IP 地址。
  • 端口(port):http 默认端口号是 80,https 默认端口号是 443。
  • 路径(path):目录的路径,/ 表示根目录。
  • 查询参数(query):请求携带的查询参数。
  • 锚点(fragment):# 后面的 hash 值,一般用于定位到页面某个位置。

2.4 域名解析

常见的 IP 地址是 IPv4 地址,使用 32 位二进制数,一般通过点分十进制表示(192.168.10.126)。仅通过纯数字标识地址不方便记忆,因此,域名系统(DNS,Domain Name System)使用更加方便记忆的英文单词作为域名,并负责将域名和 IP 地址一一映射。在 URL 中既可以使用域名,也可以直接使用 IP 地址。如果 URL 中输入的是域名,浏览器需要将其解析成 IP 地址。

进行 DNS 解析时也会先查找缓存,查找顺序为:

浏览器缓存 —> 本机缓存 —> hosts 文件 —> 路由器缓存 —> ISP DNS 缓存 —> DNS 递归查询 | DNS 迭代查询

2.5 等待 TCP 队列

当获取到 IP 地址和端口号后,就可以准备建立 TCP 连接了。但是 Chrome 浏览器有个机制——同一域名下同时最多只能建立 6 个 TCP 连接。

如果在同一域名下同时有 10 个请求发生,那么其中 4 个请求会进入排队等待状态,直至进行中的请求完成。如果当前请求数量小于 6,则可以直接建立 TCP 连接。

2.6 建立 TCP 连接

浏览器与服务器经过三次握手后即可完成 TCP 连接的建立。

一开始,B 的服务器进程先创建传输控制块 TCB,准备接受客户进程的连接请求。然后服务器进程就处于 LISTEN(监听)状态,等待客户进程的连接请求。如有,即做出响应。

A 的 TCP 客户进程也是先创建传输控制块 TCB。然后,在准备进行 TCP 连接时,向 B 发出连接请求报文段,这时首部中的同步位 SYN = 1,同时选择一个初始序号 seq = x。TCP 规定,SYN 报文段不能携带数据,但要消耗掉一个序号。 此时,TCP 客户进程进入 SYN-SENT (同步已发送)状态。

B 收到连接请求报文段后,如果同意建立连接,则向 A 发送确认。在确认报文段中 SYN 位和 ACK 位都为 1,确认号 ack = x + 1,同时也为自己选择一个初始序号 seq = y。TCP 规定,该报文段也不能携带数据,同时也要消耗一个序号。此时,TCP服务器进程进入 SYN-RCVD(同步收到)状态。

TCP 客户进程收到 B 的确认后,还要向 B 给出确认。确认报文段的 ACK = 1,确认号 ack = y + 1,而自己的序号 seq = x + 1。TCP 规定,ACK 报文段可以携带数据,但是如果不携带数据则不消耗序号。 在这种情况下,下一个数据报文段的序号仍是 seq = x + 1。此时,TCP 连接已经建立,A 进入 ESTABLISHED(已建立连接)状态。

当 B 收到 A 的确认后,也进入 ESTABLISHED(已建立连接)状态。

建立 TCP 连接为什么需要三次握手而不是两次握手?

答:TCP 协议具有双向通信、可靠传输等特点。为了实现数据可靠传输,通信双方都必须维护一个序列号 seq 放在数据包中,以此标识对方接收到了哪些数据。三次握手的过程既是通信双方相互告知序列号起始值,并确认对方已经接收到的必要步骤。如果只是两次握手,则至多只有连接发起方的起始序列号可以被确认。

2.7 发送 HTTP 请求

一旦建立了 TCP 连接,浏览器就可以和服务器进行通信了。

首先浏览器会向服务器发送请求行(Request Line),目的是告诉服务器浏览器需要的资源。

// 请求行:请求方法 请求URI HTTP版本
GET /index.html HTTP/1.1

然后浏览器会发送请求头(Request Header),包含一些关于请求的元数据,如 User-Agent(浏览器标识)、Accept(浏览器可接受的内容类型)、Cookie 等。

// 请求头:包括一系列字段(Request Header Fields)
Host: www.example.com
User-Agent: Mozilla/5.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp;q=0.8
Accept-Language: en-GB,en;q=0.5
Accept-Encoding: gzip, deflate, br
Connection: keep-alive

如果浏览器发送的是 POST 请求或者需要提交数据,则会再发送请求体(Request Body),请求体可以包含表单数据、JSON 数据等。[这一步可选]

// 请求体
username=admin&password=1234

2.8 响应请求

服务器会根据浏览器的请求信息进行处理并将数据返回给浏览器。

首先服务器会返回响应行(Response Line),包括 HTTP 版本、状态码和状态描述。

// 响应头:HTTP版本 状态码 状态描述
HTTP/1.1 200 OK

不同范围的状态码含义

1xx——指示信息,表示请求已被接收,需要继续处理;(这类响应是临时响应,只包含响应行和某些可选的响应头信息,并以空行结束)
2xx——成功,表示请求已被成功接收、理解并处理;
3xx——重定向,要完成请求必须进行更进一步的操作;
4xx——客户端错误,请求有语法错误或请求无法实现;
5xx——服务器端错误,服务器在处理请求的过程中发生错误。

然后服务器会向浏览器发送响应头(Response Header),包含了关于响应的元数据,比如 Date(服务器生成返回数据的时间)、Content-Type(返回数据的类型)、Set-Cookie(服务器要在客户端保存的 Cookie)等。

// 响应头:包括一系列字段(Response Header Fields)
Date: Sat, 11 Jan 2003 02:44:04 GMT
Content-Type: text/html
Content-Length: 2279
Set-Cookie: PREF=ID=73d4aef52e57bae9:TM=1042253044:LM=1042253044:S=SMCc_HRPCQiqy
X9j; expires=Sun, 17-Jan-2038 19:14:07 GMT; domain=.google.com

发送完响应头后,服务器会继续向浏览器发送响应体(Response Body),通常响应体包含的是实际的 HTML 内容。

<html>
	<head></head>
	<body>
  	<div>Hello World!</div>
  </body>
</html>

2.9 关闭 TCP 连接

在 HTTP 0.9 和 1.0 版本中,TCP 连接在每一次请求 / 响应之后会关闭。在 HTTP 1.1 及以后的版本中,引入了长连接机制——同一个 TCP 连接可以进行多次请求 / 响应。这样可以节约除首次请求外需要建立连接的时间,提升资源加载速度。需要在浏览器的请求头和服务器的响应头中添加如下字段:

Connection: keep-alive

在HTTP 1.1中,除非任意一方在请求时明确声明不支持长连接,否则所有的连接默认都是长连接。这种长连接将一直持续到在客户端或服务端认为无需继续维持时主动中断连接,或是使用 Connection: close 字段通知对方为止。

数据传输结束后,浏览器和服务器都可以关闭 TCP 连接,会经历四次挥手的过程。

现在 A 和 B 都处于 ESTABLISHED 状态,A 的应用进程先向其 TCP 进程发出连接释放报文段,并停止再发送数据,主动关闭 TCP 连接。A 把连接释放报文段首部的终止控制位 FIN = 1,其序号 seq = u,它等于前面已传送过的数据的最后一个字节的序号加 1。这时 A 进入 FIN-WAIT-1(终止等待 1)状态,等待 B 的确认。TCP 规定,FIN 报文段即使不携带数据也要消耗掉一个序号。

B 收到连接释放报文段后立即发出确认,确认号是 ack = u + 1,而这个 ACK 报文段的序号 seq = v,等于 B 前面已传送过的数据的最后一个字节的序号加 1。然后 B 就进入 CLOSE-WAIT(等待关闭)状态。TCP 服务器进程此时会通知高层应用进程。因而从 A 到 B 这个方向上的连接就关闭了,这时的 TCP 连接处于半关闭(half-close) 状态,即 A 已经没有数据要发送了,但 B 如果发送数据,A 仍要接收。也就是说,从 B 到 A 方向的连接并未关闭,这个状态可能会持续一段时间。

A 收到来自 B 的确认后,就进入 FIN-WAIT-2(终止等待 2)状态,等待 B 发出的连接释放报文段。

如果 B 不需要再向 A 发送数据,其应用进程就会通知 TCP 进程释放连接。这时 B 发出的连接释放报文段 FIN = 1,假定 B 的序号 seq = w(在半关闭状态 B 可能又发送了一些数据)。B 还须重复上次已发送过的确认号 ack = u + 1。此时 B 就进入了 LAST-ACK(最后确认)状态,等待 A 的确认。

A 在收到 B 的连接释放报文段后,必须对此发出确认。在确认报文段中把 ACK = 1,确认号 ack = w + 1,自己的序号 seq = u + 1,然后进入 TIME-WAIT(时间等待)状态。经过时间等待计时器 (TIME-WAIT timer)设置的时间 2MSL(最长报文段寿命,一般为 2 分钟)后,A 才进入到 CLOSED 状态。当 A 撤销相应的传输控制块 TCB 后,就结束了这次 TCP 连接。

B 只要收到了 A 发出的确认,就进入 CLOSED 状态。同样,当 B 撤销相应的传输控制块 TCB 后,也结束了这次 TCP 连接。

2.10 HTTP 请求整体流程图

### 2.11 页面渲染
2.11.1 基本流程

渲染引擎首先从网络层获取从服务器请求到的 HTML 内容,通常是 8KB 的数据块。

页面渲染的基本流程如下图所示:

在这里插入图片描述

第一步:渲染引擎会解析 HTML 文档,并将其中的标签元素转换成 DOM 树(DOM Tree)的节点。同时渲染引擎也会将 CSS 内联样式和外部 CSS 文件解析为样式规则树(Style Rules)。

第二步:将解析出的 DOM 树和样式规则树进行关联生成渲染树(Render Tree)。渲染树由具有视觉属性(如:颜色、尺寸等)的矩形组成,这些矩形按照正确的顺序显示在屏幕上。

第三步:进入布局(Layout)阶段,渲染引擎会给每一个渲染树节点分配在屏幕上显示的确切坐标。

第四步:进入绘制(Painting)阶段,渲染树会被遍历,每一个节点会被用户界面后端(UI Backend Layer)绘制出来,呈现页面效果。

为了更好的用户体验,渲染引擎采用渐进式渲染,也即边解析、边渲染,它不会等到整个 HTML 都解析完成后才进行渲染。

2.11.2 不同渲染引擎的渲染流程

WebKit 渲染引擎的渲染流程和上述基本流程保持一致。

在这里插入图片描述

Gecko 渲染引擎的渲染流程大体上也和基本流程一样,只是术语上略有不同。Gecko 将渲染树称为帧树(Frame Tree)。WebKit 将渲染树节点坐标分配的过程称为 “布局 (Layout)”,而在 Gecko 中则称为 “重排 (Reflow)”。还有一个流程上的小差异是 Gecko 在 HTML 解析后多加了一层,称为 “内容槽 (Content Sink)”。

在这里插入图片描述

渲染引擎采用单线程。除网络操作外,所有的操作都是在单线程中进行的。在 Firefox 和 Safari 浏览器中,该线程是浏览器的主线程;在 Chrome 浏览器中,它是标签页进程的主线程。

网络操作可由多个并行线程执行。并行连接数通常是 2 ~ 6 个。

2.11.3 渲染流程详解
1) 解析 HTML,构建 DOM 树
1.1) 解析 HTML,构建 DOM 树

假设有如下 HTML 页面:

<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <link href="style.css" rel="stylesheet">
    <title>Critical Path</title>
  </head>
  <body>
    <p>Hello <span>web performance</span> students!</p>
    <div><img src="awesome-photo.jpg"></div>
  </body>
</html>

浏览器的处理过程如下所示:

img

第一步:转换(Conversion),浏览器将获取的 HTML 内容(Bytes)基于其编码转换为单个字符;

第二步:分词(Tokenization),浏览器按照 HTML 标准规范将这些字符转换为不同的标记 token;

第三步:词法分析(Lexical Analysis),将分词得到的 token 转换为节点对象;

第四步:构建 DOM 树(Tree Construction),由于 HTML 标记定义了不同标签之间的关系,这些节点对象组合在一起就形成了树形结构。

1.2) 外链资源的下载

浏览器在解析 HTML 时,会遇到一些静态资源(CSS 样式、JS 脚本、图片等)链接,此时会开启网络线程进行下载。

处理 CSS 样式资源时

  • CSS 下载时异步,不会阻塞浏览器构建 DOM 树;

  • 会阻塞构建渲染树(浏览器会等到 CSS 下载解析完成后才会继续构建渲染树);

  • Media Query(媒体查询)声明的 CSS 不会阻塞渲染。

处理 JS 脚本资源时

  • 阻塞浏览器解析 HTML(浏览器会等到 JS 脚本下载执行完成后才会继续解析 HTML);
  • 可以通过设置 defer 属性延迟执行脚本,也可以通过设置 async 属性异步执行脚本。

处理图片资源时

  • 直接异步下载,不会阻塞解析,下载完成后直接用图片替换 src 所在的位置。
2) 解析 CSS,构建 CSS 规则树

CSS 规则树的构建过程和 DOM 树的构建过程类似,简述为:

Bytes --> Characters --> Tokens --> Nodes --> CSSOM

例如上述 HTML 的 CSS 样式如下所示:

body { font-size: 16px }
p { font-weight: bold }
span { color: red }
p span { display: none }
img { float: right }

那么最终构建出的 CSS Rules 树如下所示:

img

3) 构建渲染树

当 DOM 树和 CSS Rules 树后,浏览器会依据它们构建渲染树。

一般情况下,渲染树和 DOM 树相对应,但并不是完全的一一对应。浏览器不会将不可见的 DOM 元素插入渲染树中,例如 head 标签元素或者设置为 display: none 的标签元素。

在构建渲染树的过程中,也会根据每个渲染对象的 CSS 属性进行样式计算。

4) 布局

在构建渲染树时,仅对每个渲染对象的样式进行了计算,并没有计算它们的位置和大小,计算这些值的过程称为布局(Layout)。

布局是一个递归的过程,从根渲染对象(对应于 HTML 的 <html> 元素)开始,它的坐标是(0,0),范围是视口(即浏览器窗口的可见部分)。然后递归遍历渲染树,计算每个渲染对象的坐标和大小。

当元素的内容、结构、坐标或尺寸发生变化时,浏览器需要重新计算样式和渲染树,这个过程称为重新布局或回流(Reflow)。

引起回流的情况主要有以下几种:

  • 页面渲染初始化

  • DOM 结构改变(添加或删除可见的 DOM 元素,元素内容发生变化)

  • 渲染树发生变化(元素的位置、尺寸(包括外边距、边框大小、宽高等)发生变化)

  • 浏览器窗口 resize

  • 获取一些特定属性的值时(属性的值需要通过即时计算得到)

    offset(Top、Left、Width、Height)

    scroll(Top、Left、Width、Height)

    client(Top、Left、Width、Height)

    调用 getComputedStyle() 方法

5) 绘制

在绘制阶段,浏览器的 UI 后端(UI Backend)组件会遍历渲染树,调用每个渲染对象的 paint() 方法将内容显示在屏幕上。

当元素的外观发生变化时,浏览器会给元素应用新样式并将其重新绘制显示在屏幕上,这个过程称为重绘(Repaint)。

引起重绘的情况主要有以下几种:

  • 元素颜色发生变化(背景色、边框色、文字颜色等)
  • 文本方向发生变化
  • 阴影发生变化

注意:触发回流一定会触发重绘,触发重绘不一定会触发回流。

2.11.4 JS 引擎解析流程

前面的内容有提到浏览器在遇到 JS 脚本资源时,会等待其下载与执行。脚本下载完毕后会由浏览器的 JS 引擎进行解析处理。

1) 解释阶段

注意:JavaScript 是解释型语言,由解释器实时运行。

浏览器的 JS 引擎对 JS 脚本的处理流程如下:

  1. 读取代码,进行词法分析(Lexical Analysis),然后将代码分解为词元(token);
  2. 对词元进行语法分析(parsing),将代码整理成语法树(Syntax Tree);
  3. 使用翻译器(Translator),将代码转换为字节码(Bytecode);
  4. 使用字节码解释器(Bytecode Interpreter),将字节码转换为机器码(Machine Code)。

为了提高运行速度,现代浏览器一般采用即时编译(JIT - Just In Time complier),即字节码只在运行时编译,用到哪一行就编译哪一行,并把编译结果缓存。对于 Chrome 浏览器的 V8 引擎来说,省略了字节码的翻译步骤,直接转为机器码。

2) 预处理阶段

JS 引擎对脚本代码进行解释时,也会对其进行一些预处理操作(例如:变量提升、分号补全等),确保 JS 可以正确执行。

分号补全

JS 执行是需要分号的,如果代码中省略了分号,那么 JS 解释器会根据 Semicolon Insertion 规则在适当位置补充分号。

let a = 111
console.log(a)
// -------------
let a = 111;
console.log(a);

变量提升

在编译阶段,JS 引擎会把变量的声明部分和函数的声明部分放入内存中。变量被提升后,会给变量设置默认值为 undefined。

var a = 1;
function b() {
  var c = 2;
  console.log(c);
}

b();
// -----------------
function b() {
  var c;
  c = 2;
  console.log(c);
}
var a;

a = 1;
b();
3) 执行阶段

JS 引擎解释完成后,就会开始执行 JS 脚本。整个流程中主要包含以下几个概念:

执行上下文栈

在 ECMAScript 中的代码有三种类型:分别是 global,function 和 eval。每一种代码的执行都需要依赖于自身的上下文。

一个执行上下文可以激活另外的上下文,类似于一个函数可以调用其它函数。逻辑上说,这种实现方式是栈,可以称之为执行上下文栈(Execution Context Stack)。

大致执行流程如下所示:

  • 浏览器首次载入脚本时,会创建全局执行上下文,并将其压入执行上下文栈中;
  • 然后执行到 global / function / eval 类型的代码时,创建相应的执行上下文,并将其压入执行上下文栈的顶部;
  • 一旦进行中的上下文执行结束,就从栈顶弹出,并将上下文控制权交给当前的栈顶上下文;
  • 依次执行,直至全局执行上下文位于栈顶;
  • 当浏览器页面被关闭时,全局执行上下文会从栈顶弹出,结束执行。

img

激活其它上下文的某个上下文被称为调用者(caller),被激活的上下文被称为被调用者(callee)。被调用者同时也可能是调用者。

当一个 caller 激活了一个 callee,那么这个 caller 就会暂停它自身的执行,然后将控制权交给这个 callee。于是这个 callee 被放入堆栈,称为进行中的上下文(running / active execution context)。当这个 callee 的上下文结束之后,会把控制权再次交给它的 caller,然后 caller 会在刚才暂停的地方继续执行。在这个caller 结束之后,会继续触发其它的上下文。一个 callee 可以用返回(return)或者抛出异常(exception)来结束自身的上下文。

执行上下文

执行上下文(Execution Context)可以理解为 JS 代码的执行环境,其中包含了当前代码的变量、函数以及 this 等信息。

执行上下文分为三类:

  • 全局执行上下文:执行一段 JS 代码前,会先为它创建全局执行上下文,并将 this 指向全局对象(在浏览器中是 window,node 环境中是 global)。全局执行上下文只会被创建一次,随着程序的开始而创建,随着程序的关闭而销毁。
  • 函数执行上下文:执行遇到函数时,就会为这个函数创建一个函数执行上下文。
  • Eval 函数执行上下文:指的是运行在 eval 函数中的代码,很少用而且不建议使用。

每一个执行上下文都包含三个重要的属性:变量对象(VO - Variable Object)、作用域链(Scope chain)和 this。

img

  • VOAO

    变量对象(VO - Variable Object)是与执行上下文相关的数据作用域,用于存储被定义在上下文中的变量(variables)和函数声明(function declarations)。

    活动对象(AO - Activation Object)是当函数被调用者激活时创建的对象。它包含普通参数与特殊参数(arguments)对象。

    活动对象在函数上下文中作为变量对象使用。

  • 作用域链

    作用域链(Scope chain)是一个对象列表,用来检索上下文代码中出现的标识符(identifiers)。原理和原型链类似,如果这个变量在自己的作用域中没有,那么它会寻找父级的,直到全局作用域。

    img

  • this

    this 是执行上下文的一个特殊属性。大多数情况下,this 的值在运行时确定,而不是在定义时确定。它的值是动态的,取决于调用方式。

    以下是一些常见的情况:

    1. 在全局执行上下文中,this 的值通常是全局对象(例如浏览器环境中的 window 对象);

    2. 在函数执行上下文中,this 的值取决于函数的调用方式。有以下几种情况:

      • 当函数作为普通函数调用时,this 的值通常是全局对象(非严格模式下)或 undefined(严格模式下)。
      • 当函数作为对象的方法调用时,this 的值是调用该方法的对象。
      • 当函数作为构造函数调用时,this 的值是新创建的对象。
      • 使用 callapplybind 方法调用函数时,可以显式地指定 this 的值。
    3. 在箭头函数执行上下文中,this 的值是在定义箭头函数时确定的,而不是在运行时确定的。它通常是包含箭头函数的最近的非箭头函数的 this 值。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

Vesuvius688

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

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

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

打赏作者

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

抵扣说明:

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

余额充值