【offer 收割机之面试必备】(建议收藏)从 URL 输入到页面展现的全过程

本文最后更新于:2022年3月22日 下午

🧨 大家好,我是 Smooth,一名大二的 SCAU 前端er
🏆 本篇文章是我在复习该知识点时,查看了多篇文章之后,结合我自己的个人思考与理解汇聚而成的精华
🙌 如文章有误,恳请评论区指正,谢谢!
❤ 写作不易,「点赞」+「收藏」+「转发」 谢谢支持!

原文章链接

正文开始前你需知道的

从输入URL到页面显示不仅仅是一道经典的面试题,这个知识点中涉及到了非常广泛的知识点,因此面试官可以通过这个问题有点到面的向候选人发起提问,来看看面试者在计算机网络和浏览器这方面知识的广度和深度。

废话不多说,下面就开始吧!


一、用户输入阶段

用户在地址栏输入内容之后,浏览器会首先判断用户输入的是合法的URL还是搜索内容,如果是搜索内容就合成URL,如果是合法的URL就开始进行加载。

在这里,要明确我们具体要查找的东西到底是什么

搜索内容

我们要搜索一些内容,例如去百度搜索引擎进行百度查找某个问题的答案,那么浏览器就会自动调用默认搜索引擎进行关键字的查找

域名解析对应的 IP

如果是合法的 URL,说明我们想通过该 URL(统一资源定位符) 查找到对应服务器上的该资源,那么要查找的实质就是 该域名解析后所对应的 IP 地址,即 域名与 IP 地址的一个映射关系

后面篇幅重点讲到的都是第二种情况,后续篇幅中我将其简称为 映射关系


二、发起URL请求阶段

下面对发起URL请求阶段进行详细展开

1. 构建请求行

浏览器进程首先会构建请求行信息,然后通过进程间通信IPC将URL请求发送给网络进程。


2. DNS 解析

  1. 先找浏览器本地的缓存

  2. 找操作系统 hosts 文件的缓存

  3. 以上两步都没找到需要的资源,就要进行网络请求啦,开始跟外界打交道,去本地 DNS 服务器(local DNS)进行查找,如果本地 DNS 服务器的缓存中已经存在该映射关系,那么直接返回,否则继续进行后续步骤(99% 的 DNS 解析到这一步就结束了,因为一般 local DNS 中存在大量映射关系缓存)。

  4. 本地 DNS 服务器作为代理服务器,向它上面的根域名服务器建立 TCP 连接后发出请求,根域名服务器返回查询域的主域名服务器(即 gTLD,像 .COM.CN 这种顶级域名)。

  5. 本地 DNS 服务器拿到后向 gTLD 发请求,gTLD 是可以找到你想查找域名的 Name Server 地址的,本地 DNS 服务器向 Name Server 地址发送请求,拿到该域名对应的 IP 和 TTL(time to live,即域名解析结果在DNS服务器中存活的时间),然后将结果先是自己做一个缓存(根据 TTL 设置映射存活时间),然后返回给浏览器,DNS 解析结束

Name Server 概念
服务商提供的服务器地址,比如你在阿里云注册的域名,那查找就是去阿里云的服务器查,因为你是去域名提供商的服务器发请求,那肯定是能拿到对应的ip地址的


3. 等待 TCP 队列

chrome 有个机制,同一个域名同时最多只能建立6个TCP连接,如果超过这个数量的连接必须要进入排队等待状态。

知识巩固
对于多路复用,http 1.1 采取建立多个 TCP 连接,http 2.0 采用建立一个 TCP 连接并行发起多个请求


4. 建立 TCP 连接

通过TCP三次握手与服务器建立连接,然后进行数据传输。

image.png

第一次握手: 建立连接。客户端发送连接请求报文段,将SYN位置为1,Sequence Number为x;然后,客户端进入SYN_SEND状态,等待服务器的确认;

第二次握手: 服务器收到SYN报文段。服务器收到客户端的SYN报文段,需要对这个SYN报文段进行确认,设置Acknowledgment Number为x+1(Sequence Number+1);同时,自己自己还要发送SYN请求信息,将SYN位置为1,Sequence Number为y;服务器端将上述所有信息放到一个报文段(即SYN+ACK报文段)中,一并发送给客户端,此时服务器进入SYN_RECV状态;

第三次握手: 客户端收到服务器的SYN+ACK报文段。然后将Acknowledgment Number设置为y+1,向服务器发送ACK报文段,这个报文段发送完毕以后,客户端和服务器端都进入ESTABLISHED状态,完成TCP三次握手。

A:我要跟你建立连接,你那边接受得到我的请求吗?
B:接收到啦!没问题,但得向你确认下,证明这是你真实的要准备跟我建立的连接,而不是你很久之前发过的
A:对,没问题,这是我刚发给你的



为什么要三次握手?两次可以吗?

为了防止已失效的连接请求报文段突然又传送到了服务端,因而产生错误。

具体例子如下

已失效的连接请求报文段 的产生在这样一种情况下:client 发出的第一个连接请求报文段并没有丢失,而是在某个网络结点长时间的滞留了,以致延误到连接释放以后的某个时间才到达 server。本来这是一个早已失效的报文段。但 server 收到此失效的连接请求报文段后,就误认为是 client 再次发出的一个新的连接请求。于是就向 client 发出确认报文段,同意建立连接。假设不采用“三次握手”,那么只要server发出确认,新的连接就建立了。由于现在 client 并没有发出新的建立连接请求,因此不会理睬 server 的确认,也不会向 server 发送数据。但 serve r却以为新的运输连接已经建立,并一直等待 client 发来数据。这样,server 的很多资源就白白浪费掉了。采用“三次握手”的办法可以防止上述现象发生。例如刚才那种情况,client 不会向 server 的确认发出确认。server 由于收不到确认,就知道 client 并没有要求建立连接。”

四次握手可以吗?

可以但没必要

四次握手只是将第二次握手分解为了以下两个步骤

  1. 先发送确认位 ACK = 1,然后确认号 ack = x + 1
  2. 再发送同步位 SYN = 1,和序号 seq = y


    5. 查找缓存

    建立好 TCP 连接后,会再次进行查找缓存,即我们熟知的 强制缓存协商缓存 ,如果找到了,服务器会返回 304 状态码 和 对应的缓存资源,而不会向服务器发送请求获取对应资源。

首先进行 强制缓存 查找,才到 协商缓存

  • 浏览器首次加载资源成功时,服务器返回 200,此时浏览器不仅将资源下载下来,而且把 response 的 header(里面的 date 属性非常重要,用来计算第二次相同资源时当前时间和 date 的时间差)一并缓存;

  • 下一次加载资源时,首先要经过强缓存的处理。强缓存是利用 http 头中的 Expires 和 Cache-Control 两个字段来控制的。强缓存中,当请求再次发出时,浏览器会根据其中的 Expires 和 Cache-Control 判断目标资源是否“命中”强缓存,若命中则直接从缓存中获取资源,不会再与服务端发生通信。其中 Cache-Control 的优先级最高,如果 Cache-Control:no-cache,就直接进入到协商缓存的步骤了,如果 Cache-Control:max-age=xxx,就会先比较当前时间和上一次返回 200 时的时间差,如果没有超过 max-age,命中强缓存,不发请求直接从本地缓存读取该文件(这里需要注意,如果没有 Cache-Control,会取 Expires 的值,来对比是否过期),过期的话会进入下一个阶段,协商缓存;

  • 协商缓存阶段,则向服务器发送 header 带有 If-None-Match 和 If-Modified-Since 的请求,前者优先级大于第二个,所以先讲第一个对应关系 请求头的 If-None-Match 和 响应头的 Etag。服务器会比较这两个字段值,如果相同,命中协商缓存,返回 304;如果不一致则有改动,直接返回新的资源文件带上新的 Etag 值并返回 200。

  • 协商缓存第二个对应关系是 If-Modified-SinceLast-modified,如果客户端发送的 If-Modified-Since 的值跟服务器端获取的文件最近改动的时间,一致则命中协商缓存,返回 304;不一致则返回新的 Last-modified 和文件并返回 200;

为什么协商缓存中 Etag 优先级大于 Last-modified ?

因为后者是标识着资源的最后修改时间(不准确,因为修改了文件并不代表文件内容发生改变,可能改变后又撤销了),前者可以理解为是一个资源唯一标识符,是服务器通过内置算法根据文件内容生成的 hash 值,所以更准确,当然,因为计算所以性能消耗更大,不太推荐使用 Etag

拓展知识

什么是 from disk cachefrom memory cache,什么时候会触发?

  • 强缓存会触发,大致解释如下:
  1. 先查找内存,如果内存中存在,从内存中加载;

  2. 如果内存中未查找到,选择硬盘获取,如果硬盘中有,从硬盘中加载;

  3. 如果硬盘中未查找到,那就进行网络请求;

  4. 加载到的资源缓存到硬盘和内存;

什么是启发式缓存,在什么条件下触发?

启发式缓存:

如果响应中未显示 ExpiresCache-Control:max-ageCache-Control:s-maxage,并且响应中不包含其他有关缓存的限制,缓存可以使用启发式方法计算新鲜度寿命。通常会根据响应头中的2个时间字段 Date 减去 Last-Modified 值的 10% 作为缓存时间。

1
2
3
4
// Date 减去 Last-Modified 值的 10% 作为缓存时间。

// Date:创建报文的日期时间, Last-Modified 服务器声明文档最后被修改时间
response_is_fresh = max(0,(Date - Last-Modified)) % 10

看到这里是不是感觉有点累了?坚持住!后面还有更丰富的知识等着你去探索!


6. 发起HTTP请求

浏览器首先会向服务器发送请求行,请求行中包含了请求方法、请求 URI 和 HTTP 版本,还会发送请求头,告诉服务器一些浏览器的相关信息,比如浏览器内核、请求域名、Cookie 等信息。


7. 服务器处理请求

服务器首先返回相应行,包括协议版本和状态码,然后会返回响应头包含返回的数据类型,服务器要在客户端保存的 Cookie 等,一般来说是返回 HTMLCSSJSImage 文件


8. 断开TCP连接

数据传输完成后,通过四次挥手来断开连接。

当客户端和服务器通过三次握手建立了TCP连接以后,当数据传送完毕,肯定是要断开TCP连接的啊。那对于TCP的断开连接,这里就有了神秘的“四次挥手”。

image.png

第一次挥手: 主机1(可以使客户端,也可以是服务器端),设置Sequence Number,向主机2发送一个FIN报文段;此时,主机1进入FIN_WAIT_1状态;这表示主机1没有数据要发送给主机2了;

第二次挥手: 主机2收到了主机1发送的FIN报文段,向主机1回一个ACK报文段,Acknowledgment Number为Sequence Number加1;主机1进入FIN_WAIT_2状态;主机2告诉主机1,我“同意”你的关闭请求;

第三次挥手: 主机2向主机1发送FIN报文段,请求关闭连接,同时主机2进入LAST_ACK状态;

第四次挥手: 主机1收到主机2发送的FIN报文段,向主机2发送ACK报文段,然后主机1进入TIME_WAIT状态;主机2收到主机1的ACK报文段以后,就关闭连接;此时,主机1等待2MSL后依然没有收到回复,则证明Server端已正常关闭,那好,主机1也可以关闭连接了。

A:我要断开连接啦
B:好的,你先断开,等我把我这边的数据传完给你我再断
过了一会儿,等B传完后
B:我传完啦,我也可以断开跟你的连接了,听到了吗
A:知道你也断开连接啦,你先断,我过 2MSL 再断,不然怕你听不见我跟你说的话



为什么要四次挥手?

TCP 协议是一种面向连接的、可靠的、基于字节流的运输层通信协议。TCP 是全双工模式,这就意味着,当主机1发出FIN 报文段时,只是表示主机1已经没有数据要发送了,主机1告诉主机2,它的数据已经全部发送完毕了;但是,这个时候主机1还是可以接受来自主机2的数据;当主机2返回 ACK 报文段时,表示它已经知道主机1没有数据发送了,但是主机2还是可以发送数据到主机1的;当主机2也发送了 FIN 报文段时,这个时候就表示主机2也没有数据要发送了,就会告诉主机1,我也没有数据要发送了,之后彼此就会愉快的中断这次TCP连接。

为什么要等待 2MSL?

MSL:报文段最大生存时间,它是任何报文段被丢弃前在网络内的最长时间。
原因有二:

  • 保证TCP协议的全双工连接能够可靠关闭
  • 保证这次连接的重复数据段从网络中消失

第一点:如果主机1直接 CLOSED 了,那么由于 IP 协议的不可靠性或者是其它网络原因,导致主机2没有收到主机1最后回复的 ACK。那么主机2就会在超时之后继续发送 FIN,此时由于主机1已经 CLOSED 了,就找不到与重发的 FIN 对应的连接。所以,主机1不是直接进入 CLOSED,而是要保持 TIME_WAIT,当再次收到FIN的时候,能够保证对方收到 ACK,最后正确的关闭连接。

第二点:如果主机1直接 CLOSED,然后又再向主机2发起一个新连接,我们不能保证这个新连接与刚关闭的连接的端口号是不同的。也就是说有可能新连接和老连接的端口号是相同的。一般来说不会发生什么问题,但是还是有特殊情况出现:假设新连接和已经关闭的老连接端口号是一样的,如果前一次连接的某些数据仍然滞留在网络中,这些延迟数据在建立新连接之后才到达主机2,由于新连接和老连接的端口号是一样的,TCP 协议就认为那个延迟的数据是属于新连接的,这样就和真正的新连接的数据包发生混淆了。所以 TCP 连接还要在 TIME_WAIT 状态等待2倍 MSL,这样可以保证本次连接的所有数据都从网络中消失。

关于三握四挥的内容,在谢希仁的《计算机网络》一书中讲的非常透彻,有时间的可以进行查阅



三、准备渲染进程阶段

  1. 网络进程将获取的数据进行解析,根据响应头中的 Content-type 来判断响应数据的类型,如果是字节流类型,就将该请求交给下载管理器去下载,如果是 text/html 类型,就通知浏览器进程获取到的是 HTML,准备渲染进程。
  2. 一般情况下浏览器的一个 tab 页面对应一个渲染进程,如果从当前页面打开的新页面并且属于同一站点,这种情况会复用渲染进程,其他情况则需要创建新的渲染进程。

有需要的小伙伴可以了解一下 安全沙箱站点隔离 的概念

四、提交文档阶段

  1. 渲染进程准备好之后,浏览器会发出提交文档的消息给渲染进程,渲染进程收到消息后,会和网络进程建立数据传输的管道(IPC),文档数据传输完成后,渲染进程会返回确认提交的消息给浏览器进程
  2. 浏览器收到确认提交的消息后,会更新浏览器的页面状态,包括了安全状态,地址栏的URL,前进后退的历史状态,并更新 web 页面为空白

五、页面渲染阶段

简单来说,可以总结为如下阶段

返回 html 之后,会解析 html,然后 cssom + domTree = html,然后布局和绘制

  • 构建 DOM 树(DOM tree):从上到下解析 HTML 文档生成 DOM 节点树(DOM tree),也叫内容树(content tree);
  • 构建 CSSOM(CSS Object Model)树:加载解析样式生成 CSSOM 树;
  • 执行 JavaScript:加载并执行 JavaScript 代码(包括内联代码或外联JavaScript文件);
  • 构建渲染树(render tree):根据 DOM 树和 CSSOM 树,生成渲染树(render tree);
  • 渲染树:按顺序展示在屏幕上的一系列矩形,这些矩形带有字体,颜色和尺寸等视觉属性。
  • 布局(layout):根据渲染树将节点树的每一个节点布局在屏幕上的正确位置;
  • 绘制(painting):遍历渲染树绘制所有节点,为每一个节点适用对应的样式,这一过程是通过UI后端模块完成;

详细展开

  1. 文档提交之后,渲染进程将开始页面解析并加载子资源。

  2. 构建 DOM 树:HTML 经过解析后输出的是一个以 document 为顶层节点的树状结构的 DOM。

  3. 样式计算:将从 link 标签引入的外部样式,style 标签里的样式和元素身上的样式转换成浏览器能够理解的样式表,然后将样式表中的属性值进行标准化,例如 color:red 转换为 color 的 rgb 形式,然后根据 CSS 的继承和层叠规则计算出 DOM 树种每个节点的具体样式。

  4. 布局阶段:会生成一棵只包含可见元素的布局树,然后根据布局树的每个节点计算出其具体位置和大小。

  5. 分层:对页面种的复杂效果例如 3D 转换,页面滚动或者z轴排序等生成图层树。

  6. 绘制:为每个图层生成绘制列表,并将其提交到合成线程中。

  7. 光栅化:优先选择可视窗口内的图块来生成位图数据。

  8. 合成:所有图块都被光栅话之后开始显示页面。

总结

可以总结为如下:1.网络线程获取 html 数据后,通过 IPC 将数据传给渲染器进程的主线程,主线程将 html 解析构建 dom 树,然后进行样式计算。根据 dom 树和生成好的样式生成 layout tree ,通过遍历 layout tree 生成绘制顺序表,接着遍历 layout tree 生成 layer tree。2.主线程将 layer tree 和绘制顺序信息一起传给合成器线程,合成器线程按照规则进行分图层,并把图层分为更小的图块 tiles 传给栅格线程进行栅格化。3.栅格化完成后合成器线程会收到栅格化线程传过来的 draw quads 图块信息,根据这些信息,合成器线上合成了一个合成器帧。然后将该合成器帧通过 IPC 传给浏览器进程,浏览器进程再传到 gpu 进行渲染,最后就展示在屏幕上了。

关于页面渲染阻塞

由于浏览器解析并渲染 DOM 元素占用的是主线程,对于图片、CSS 等文件在下载时不会阻塞浏览器渲染,但 JS 就不一样了,浏览器遇到 <script> 标签引入的 JS 时,会停止渲染,等 JS 文件下载并执行完后,才将主线程控制器归还给浏览器进行继续渲染

导致的问题

由上面描述可知,浏览器渲染(布局和绘制) 和 JS 文件的处理都要占用主线程,但如果 JS 文件特别大,下载和执行占用的时间非常长,我们都知道一个知识点,当页面以每秒60帧的频率刷新,才不会让用户感受到页面卡顿。在浏览器要绘制下一帧页面时,无法及时将浏览器主线程控制权归还,那么就会导致无法及时绘制下一帧的问题,这时对于我们用户来说的直观感受就是,出现页面卡顿

每一帧绘制结束后,还有剩余时间

image.png

当 JS 过大

image.png


有什么解决方案呢?

可以先自己思考,过一段时间后再去查看后面的 RQ1 获得解决方案

关于浏览器渲染过程,推荐观看 此视频


问题汇总

A & Q1:浏览器渲染卡顿的解决方法

1. 可以通过 requestAnimationFrame() 来解决

这是浏览器的官方 API,此方法会在每一帧被调用,通过 API 的回调,我们可以把 JS 运行任务分成一些更小的任务快(分到每一帧),在每一帧时间用完前暂停 JS 执行,归还主线程,这样的话在下一帧开始时,主线程就可以按时执行布局和绘制

React 最新的渲染引擎 React Fiber 就是用到了这个 API 做了很多优化(时间分片)

image.png

image.png

2. 为 <script> 标签加上 asyncdefer 属性

  • async:异步执行,当浏览器在渲染时,可以同时下载<script> 对应的 JS 文件,当下载结束后,才停止浏览器渲染,执行完 JS 后,再继续渲染

  • defer:延迟执行,当浏览器全部页面布局绘制完成,才下载并执行 JS 文件

3. 这里就要提到 CSS 中的一个动画属性 transform

由于栅格化的整个流程是不占用主线程的,只在下面的 合成器线程 + 栅格线程中执行,意味着它无需和 JS 抢夺主线程,我们如果反复进行重排和重绘,可能会导致掉帧,这是因为有可能 JS 执行阻塞了主线程,而经 transform 实现的动画不会经过布局和绘制,而是直接运行在合成器线程和栅格线程,所以不会受到主线程中 JS 执行的影响,所以节省了很多时间,减轻了主线程的压力。

image.png


A & Q2:cssom 渲染会不会阻塞 dom 渲染,会不会阻塞 dom 树建立?

会,不会。

当一个样式表被后台下载时,JavaScript 仍然可以执行,因为主线程没有被加载的样式表所阻挡。如果我们的 JavaScript 程序访问 DOM 元素的 CSS 属性(通过 CSSOM API),
我们会得到一个合适的值(根据 CSSOM 的当前状态)。但是一旦样式表被下载和解析,导致 CSSOM 更新,我们的 JavaScript 现在有一个过时的元素的 CSS 值,
因为新的 CSSOM 更新可能已经改变了该 DOM 元素的 CSS 属性。由于这个原因,在下载样式表的时候执行 JavaScript 是不安全的,而 JS 通常会有些操作影响 dom 渲染

就像 async 或 defer 属性使 script 元素不阻塞解析一样,外部的样式表也可以通过 media 属性使其不阻塞渲染。使用 media 属性值,浏览器可以智能地决定何时去加载样式表


A & Q3:页面渲染优化的方法

  • HTML文档结构层次尽量少,最好不深于六层;
  • 脚本尽量后放,放在前即可;
  • 少量首屏样式内联放在标签内;
  • 样式结构层次尽量简单;
  • 在脚本中尽量减少DOM操作,尽量缓存访问DOM的样式信息,避免过度触发回流;
  • 减少通过JavaScript代码修改元素样式,尽量使用修改class名方式操作样式或动画;
  • 动画尽量使用在绝对定位或固定定位的元素上;
  • 隐藏在屏幕外,或在页面滚动时,尽量停止动画;
  • 尽量缓存DOM查找,查找器尽量简洁;
  • 涉及多域名的网站,可以开启域名预解析

A & Q4:强缓存和协商缓存发生在那个阶段?

强缓存和协商缓存发生在发起 URL 请求阶段,在这个阶段构建请求行之后会查找缓存。

A & Q5:DNS解析中端口需要DNS解析吗?

不需要,因为 HTTP 默认的是80端口,HTTPS 默认的是443端口,如果要指定端口可以直接在 URL 里面添加。

A & Q6:上述哪些阶段可以优化?

1. 优化 DNS 查询:DNS 预解析

前端的 DNS 优化,可以在 html 页面头部写入 DNS 缓存地址,比如

1
2
<meta http-equiv="x-dns-prefetch-control" content="on" />
<link rel="dns-prefetch" href="http://bdimg.share.baidu.com" />

2. 优化TCP连接:可以通过请求头 keep-alive 来优化。

1
keep-alive: connection

在本次 TCP 请求结束后,不主动断开,下次还需要 TCP 连接时就省了,直接在这个 TCP 管道进行传输即可

即节省了进行三次握手的建立 TCP 连接过程

3. 优化 HTTP 响应报文:通过 CDN 和 Gzip 压缩。



A & Q7:常见 http 请求报文头有哪些?

  • User-Agent 浏览器类型
  • Content-Type 报文类型
  • Connection 完成传输是否关闭 TCP 连接
  • Host 访问主机域名
  • Content-Length 内容长度
  • Accept 允许接收的数据类型
  • Accept-Language 允许接收的语言
  • Cookie 用户标识符
  • cache-control 强缓存限制条件,存活时间
  • expires 强缓存限制条件,被上面的替代了
  • etag 协商缓存限制条件,过期的时刻
  • last-modified 协商缓存限制条件,最后修改时间
  • date 时间

等等,还有很多,就不一一列举了


最后

我是 Smoothzjc,致力于产出更多且不仅限于前端方面的优质文章

大家也可以关注我的公众号 @ Smooth前端成长记录,及时通过移动端获取到最新文章消息!

写作不易,「点赞」+「收藏」+「转发」 谢谢支持❤

往期推荐

《都2022年了还不考虑来学React Hook吗?6k字带你从入门到吃透》

《一份不可多得的 Webpack 学习指南(1万字长文带你入门 Webpack 并掌握常用的进阶配置)》

《【offer 收割机之 CSS 回顾系列】请你解释一下什么是 BFC ?他的应用场景有哪些?》

《Github + hexo 实现自己的个人博客、配置主题(超详细)》

《10分钟让你彻底理解如何配置子域名来部署多个项目》

《一文理解配置伪静态解决 部署项目刷新页面404问题

《带你3分钟掌握常见的水平垂直居中面试题》

《【建议收藏】长达万字的git常用指令总结!!!适合小白及在工作中想要对git基本指令有所了解的人群》

《浅谈javascript的原型和原型链(新手懵懂想学会原型链?看这篇文章就足够啦!!!)》


作者:Smooth

文章链接:http://example.com/2022/03/17/%E3%80%90offer%20%E6%94%B6%E5%89%B2%E6%9C%BA%E4%B9%8B%E9%9D%A2%E8%AF%95%E5%BF%85%E5%A4%87%E3%80%91%EF%BC%88%E5%BB%BA%E8%AE%AE%E6%94%B6%E8%97%8F%EF%BC%89%E4%BB%8E%20URL%20%E8%BE%93%E5%85%A5%E5%88%B0%E9%A1%B5%E9%9D%A2%E5%B1%95%E7%8E%B0%E7%9A%84%E5%85%A8%E8%BF%87%E7%A8%8B/

版权说明:本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!