前端资源请求速度优化

共 11989字,需浏览 24分钟

 ·

2022-01-03 11:46

点击上方 前端Q,关注公众号

回复加群,加入前端Q技术交流群


DNS解析

当浏览器从第三方服务器请求资源时,必须先将该跨域域名解析为IP地址,然后浏览器才能发出请求,此过程称为DNS解析。
DNS作为互联网的基础协议,其解析的速度似乎很容易被网站优化人员忽略,现在大多数新流量乃全已经针对DNS解析进行了优化,比如DNS缓存。
典型的一次DNS解析需要耗费20-120毫秒,所花费的时间几乎可以忽略不计,但是当网站中使用的资源依赖多个不同域的时候,时间就会成倍增加,从而增加了网站的加载时间。
比如某些图片较多的页面中,在发起图片加载请求之前预先把域名解析好将会有至少5%的图片加载速度提成。
一般来说前端与华中与DNS有关的有两点,一是减少DNS的请求次数(缓存DNS地址),二是进行DNS的预获取,DNS Prefetch。
dns缓存可以在服务器设置DNS缓存的时间,不经常变更的ip建议设置的时间长一些。尽可能使用A或者AAAA代替CNAME,使用CND加速域名。还可以自己搭建DNS服务。
DNS与解析可以在页面中通过link标签来实现。
<link rel="dns-prefetch" href="https://fonts.googleapis.com" />

DNS与解析只能解析不同域,同域是不能解析的,因为已经解析完了。dns-prefetch要慎用,不要每个页面都添加,会造成资源浪费。

默认情况下浏览器会对当前页面中所有出现的域名进行预解析,及时没有写link标签,这是隐式解析。

HTTP1.1长链接

经过DNS解析获取到IP之后就要进行TCP的链接进行数据传输。

HTTP协议的初始版本中,每进行一次HTTP通信就要断开一次TCP链接,也就是短连接。

以早期的通信情况来说,因为都是些容量很小的文本传输,所以即使这样也没有多大问题,但是随着HTTP的大量普及,文旦中包含大量富文本的情况多了起来。每次的请求都会造成无谓的TCP链接建立和断开,增加通信录的开销。

为了解决这个问题,有些浏览器在请求时,用了一个非标准的Connection字段。这个字段要求服务器不要关闭TCP链接,以便其他请求复用,服务器同样回应这个字段。

Connection: keep-alive

一个可以复用的TCP链接就建立了,直到客户端或服务器主动关闭链接,但是这并非标准字段,不同实现的行为可能不一致,还可能造成混乱。

长链接

HTTP1.1版本在1997年1月发布,最大的变化就是引入了持久链接,即TCP链接默认不关闭,可以被多个请求复用,不需要再声明Connection: keep-alive。

持久连接减少了TCP链接的重复建立和断开所造成的的额外开销,减轻了服务器端的负载。减少开销的时间让HTTP请求和响应能够更早的结束,这样Web页面的速度也就响应变快了。

客户端和服务器发现对方一段时间没有活动,就可以主动关闭链接,不过规范的做法是客户端在最后一个请求时发送Connection: close,明确要求服务器关闭链接。目前对于同一个域名,大多数浏览器允许同时建立6个持久链接。

管道机制

同一个TCP链接里面客户端可以同时发送多个请求,这样就进一步改变了HTTP协议的效率。

从前发送请求后需等待及接收响应,才能发送下一个请求,管道化技术出现后不用等待响应即可直接发送下一个请求,这样就能够做到同时并行发送多个请求,而不需要一个接一个的等待响应了。

管道化技术比持久化链接还要快,请求数越多时间差越明显。

一个TCP链接可以传送多个回应,势必就要有一种机制,区分数据包是属于哪一个回应的,这就是Content-length字段的作用,声明本次回应的数据长度。

Content-Length: 3000

上面代码告诉浏览器,本次回应的长度是3000个字节,后面的字节就属于下一个回应了。

在1.0版本中,Content-Length字段不是必须的,因为浏览器发现服务器关闭了TCP链接,就表明收到的数据包已经完成了。

分块传输

使用Content-Length字段的前提条件是,服务器发送回应之前,必须知道回应的数据长度。

对于一些耗时的动态操作来说,意味着,服务器要等到所有操作完成,才能发送数据,显然这样的效率不高,更好的方法是产生一块数据就发送一块,采用流模式取代缓存模式。

因此1,1规定可以不使用content-length字段,而是用分块传输编码,只要请求或响应头信息有Transfer-Encoding字段,就表明响应将又数量未定的数据块组成。

Transfer-Encoding: chunked

每个非空数据块之前会有一个16进制的数值,表示这个块的的长度,最后是一个大小为0的块,表示本次回应的数据发送完了。

HTTP/1.1 200 OK...25This is the data in the first chunk...2...4...0...

虽然HTTP1.1允许复用TCP链接,但是同一个TCP链接里面,所有的数据通信是按次序进行的,服务器只有处理完一个回应才会进行下一个回应。

如果前面的请求慢,后面就会有需要请求排队,称为对头阻塞。

为了避免这种问题,可以减少请求数或者同事多开持续请求。

这就出现了很多的优化技巧,比如说。合并脚本和样式表,将图片嵌入css代码,域名分片等等。

其实如果HTTP协议设计的更好一些,这些额外的工作都是可以避免的。

HTTP2协议

为了解决响应阻塞问题2015年推出了HTTP2。

HTTP2主要用于解决HTTP1.1效率不高的问题,他不叫HTTP2.0是因为不打算发布子版本了,下一个版本直接就叫HTTP3。

二进制协议

HTTP1.1头信息肯定是文本,数据体可以是文本也可以是二进制,HTTP2则是一个彻底的二进制协议,头信息和数据体都是二进制,并且统称为帧,头信息帧和数据帧。

二进制协议的一个好处是可以定义额外的帧,HTTP2定了一近十种帧,为将来的高级应用打好基础,如果使用文本实现这种功能,解析数据将会变得非常麻烦,二进制解析则方便很多。

多工

HTTP2复用TCP链接,在一个链接里,客户端和浏览器都可以同时发送多个请求或回应,而且不用按照顺序一一对应,这样就避免了堵塞。

在一个TCP链接里面,服务器同时收到了A请求和B请求,先回应了A请求结果发现处理过程非常耗时,先发送A请求已经处理好的部分,再回应B请求,完成后再发送A请求剩余的部分。

这种双向的,实时通信就叫做多工。

效果地址: https:http2.akamai.com/demo

数据流

因为HTTP2的数据包是不按顺序发送的,同一个链接里面连续的数据包,可能属于不同的回应,因此必须要对数据包做标记,指出他属于哪个回应。

HTTP2将每个请求或回应的所有数据包,称为一个数据流,每个数据流都有一个独一无二的编号,数据包发送的时候,都必须标记数据流ID,用来区分它属于哪个数据流,另外还规定,客户端发出的数据流,ID一律为奇数,服务器发布的,ID为偶数。

数据流发送到一半的时候,客户端和服务器都可以发送信号取消这个数据流。

1.1版本取消数据的唯一方法就是关闭TCP链接,HTTP2可以取消某一次请求,同时保证TCP链接还开着,可以被其他请求使用。

客户端还可以指定数据流的优先级,优先级越高,服务器就会越早回应。

压缩头信息

HTTP协议不带有状态,每次请求都必须附上所有信息,所以请求的很多字段都是重复的,比如Cookie和User Agent,一模一样的内容每次请求都必须附带,这会浪费很多带宽也影响速度。

HTTP2对这一点做了优化,引入了头信息压缩机制,一方面头信息使用gzip或compress压缩后再发送。

另一方面,客户端和服务器同时维护一张头信息表,所有字段都会存入这个表,生成一个索引号,以后就不发送这个字段只发送索引号这样就提高速度了。

服务器推送

HTTP2允许服务器未经过请求主动向客户端发送资源,这就叫服务器推送。

常见场景是客户端请求一个网页,这个网页包含很多静态资源,正常情况下,客户端必须收到网页后解析html编码,发现有静态资源再发出静态资源请求,其实服务器可以预期到客户端请求网页后很可能会再请求静态资源,所有就主动把这些静态资源随着网页一起发给客户端了。

这个功能还是建议考虑自身的需要,会增加一部分成本开销。

压缩传输数据资源

通过压缩传输数据资源提升性能体验。默认HTTP进行数据传输数据是没有进行压缩的,原始数据多大传输的数据就多大。

我们都知道文件压缩之后数据体积减少是很客观的。

响应数据压缩

HTTP响应数据一般会根据数据的类型进行压缩方案的处理,比如文本最常用的方案就是Gzip的压缩方案,目前大部分的网站都采用这种压缩方式。

gzip

浏览器再请求服务器的时候会在请求头中通过Accept-Encoding字段标识可以接收gzip压缩方案,服务器在收到请求后可以获取到这种压缩方案,将资源压缩后返回给浏览器,并且在响应头中加入Content-Encoding字段,值为gzip。

如果客户端不添加Accept-Encoding头,服务器返回了Content-Encoding,客户端如果支持的话也会正常解析。

Accept-Encoding基本是浏览器自动添加的。

const zlib = require('zlib');const fs = require('fs');const rs = fs.cerateReadStream('jquery.js');const ws = fs.cerateWriteStream('jquery.js.gz');const gz = zlib.createGzip();rs.pipe(gz).pipe(ws);ws.on('error', (err) => {   console.log('失败');})ws.on('finish', () => {   console.log('完成')})

正常工作中gzip一般可以在nginx服务器中开启,不需要自己编写。还是比较简单的。

gzip一般是针对文本文件,比如js,css,对于图片来说一般是在开发阶段压缩。

请求数据压缩

HTTP2以前请求头是不可以压缩的,HTTP2引入了头信息压缩机制,一方面头信息使用gzip或express压缩后再发送。

另一方面,客户端和服务器同时维护一张头信息表,通过索引字段来传输,减少厅信息数据体积。

实际工作中会存在请求正文非常大的场景,比如发表长篇博客,上报用于调试网络数据等等,这些数据如果能在本地压缩后再提交就可以节省网络流量,减少传输时间。

DFLATE是一种使用Lempel-Ziv压缩算法的哈夫曼编码压缩格式。

ZLIB是一种使用DEFLATE的压缩格式。

GZIP是一种使用DEFLATE的压缩格式。

Content-Encoding中的deflate实际上是ZLIB。

前端发送的时候可以进行压缩:

const rawBody = 'content=test';const rawLen = rawBody.length;const bufBody = new Unit8Array(rawLen);for (let i = 0; i < rawLen; i++) {   bufBody[i] = rawBody.charCodeAt(i);}const format = 'gzip';let buf;switch (format) {   case gzip': buf = window.pako.gzip(bufBody); break;}const xhr = new XMLHttpRequest();xhr.open('POST', '/service/');xhr.setRequestHeader('Content-Encoding', format);xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded')xhr.send(buf);

服务器端进行解压

const http = require('http');const zlib = require('zlib');http.createServer((req, res) => {   let zlibStream;   const encoding = req.headers['content-encoding']   switch (encoding) {       case 'gzip' : zlibStream = zlib.createGunzip(); break;   }   res.writeHead(200, { 'Content-Type': 'text/plain' });   req.pipe(zlibStream).pipe(res);}).listen(3000)

这种压缩一半也只适用于文本,如果数据量太大压缩过程也是比较耗时的。

缓存

缓存的原理是在客户端首次请求后保存一份请求资源的响应副本存储在客户端中,当用户再次发起相同的请求后,如果判断缓存命中则拦截请求,将之前缓存的响应副本返回给用户,从而避免重新向服务器发起资源请求。

缓存的技术种类有很多,比如代理缓存,浏览器缓存,网关缓存,负载均衡器及内容分发网络等,大致可以分为两类,共享缓存和私有缓存。

共享缓存指的是缓存内容可以被多个用户使用,如公司内部架设的Web代理,私有缓存是只能单独被用户使用的缓存,如浏览器缓存。

HTTP缓存是前端开发中最常接触的缓存机制之一,他又可细分为强制缓存与协商缓存,二者最大的区别在于判断缓存命中时浏览器是否需要向服务器进行询问。

强制缓存不会去询问,协商缓存则仍旧需要询问服务器。

强制缓存

对于强制缓存而言,如果浏览器判断所请求的目标资源有效命中则可直接从强制缓存中返回请求的响应,无需与服务器进行任何通信。

也就是说强制缓存是在客户端进行的,这样速度就会很快。

强制缓存相关的两个字段是expires和cache-control。

expires是在HTTP1.0协议中声明的用来控制缓存失效日期的时间戳字段,他由服务器指定并通过响应头告诉浏览器,浏览器在收到带有该字段的响应体后进行缓存。

之后浏览器再发送相同的请求便会对比expires与本地当前的时间戳,如果当前请求的本地时间戳小于expires的值,则说明缓存还未过期,可以直接使用。

否则缓存过期重新向服务器发送请求获取响应体。

res.writeHEAD(200, {   Expires: new Date('2021-6-18 12: 51: 00').toUTCString(),})

expires存在一个很大的漏洞就是对本地时间戳过分依赖,如果客户端本地的时间与服务器时间不同步,或者客户端时间被修改,那么缓存过期的判断可能就无法和预期相符。

为了解决这个问题,HTTP1.1新增了cache-control字段来对expires的功能进行扩展和完善。

cache-control的值为maxage=xxx来控制响应资源的有效期,xxx是一个以秒为单位的时间长度,表示该资源在被请求到的一段时间内有效,以此便可避免服务端和客户端时间戳不同步而造成的问题。

res.writeHEAD(200, {   'Cache-Control': 'maxage=1000',})

除了maxage还可以设置其他参数,比如no-cache和no-store。

no-cache表示强制进行协商缓存,对于每次发起的请求不会再去判断强制缓存是否过期,而是直接进行协商缓存。协商缓存后面会说到。

no-store表示禁止使用任何缓存策略,客户端的每次请求都直接从服务器获取。也就是无缓存。

no-cache和no-store是互斥的,不能同时设置。

res.writeHEAD(200, {   'Cache-Control': 'no-cache',})

还有private和public,他们也是cache-control的一组互斥属性值,他们用来明确响应资源是否可被代理服务器进行缓存。

publicb表示响应资源即可被浏览器缓存又可以被代理服务器缓存。private限制了响应资源只能被浏览器缓存。

对于应用程序中不会改变的文件比如图片,js,css, 字体库等通常可以使用公共缓存。

res.writeHEAD(200, {   'Cache-Control': 'public, max-age=31600',})

除了max-age还有s-maxage,max-age表示服务器告知浏览器的响应资源的过期时长。一般使用它就足够了了。

但如果是大型项目架构通常会涉及代理服务器缓存,这就需要考虑缓存在代理服务器上的有效性问题,这便是s-maxage存在的意义。

他表示缓存在代理服务器上的过期时长,需要配合public来使用。

cache-control能作为expires的完全替代方案,目前expires只作为兼容使用。

协商缓存

协商缓存就是在使用本地缓存之前,需要向服务器发起一次GET请求,与之协商当前浏览器保存的本地缓存是否已经过期。

协商缓存主要解决的问题就是在强制缓存下资源不更新的问题。

客户端在获取到本地缓存后需要向服务器发送一次GET请求,这个请求的请求头中包含if-modified-since字段,值是响应头中的last-modified字段,也就是这个资源的最后修改时间。

也就是说客户端请求资源的时候服务器会返回响应内容及内容的修改时间,修改时间存在last-modified字段中。

客户端在请求的时候如果客户端存储了last-modified就将它的值放在if-modified-since字段中发送到服务器。

服务器接收到请求后通过比对前端传过来的时间和资源的修改时间,如果二者相同则说明缓存未过期,就告诉浏览器直接使用缓存中的文件,如果过期了就返回对应文件并且将新的修改日期重新返回。

客户端继续缓存新的修改时间。

const http = require('http');const fs = require('fs');const url = require(''url');http.creatServer((req, res) => {   const { pathname } = url.parse(req.url);   // 获取文件日期   fs.stat(`www/${pathname}`, (err, stat) => {     if (err) {       res.writeHeader(404);       res.write('Not Found');       res.end();     } else {       if (req.headers['if-modified-since']) {         const oDate = new Date(req.headers['if-modified-since']);         const time_client = Math.floor(oDate.getTime() / 1000);         const time_server = Math.floor(stat.mtime.getTime() / 1000);         if (time_server > time_client) { // 服务器的文件时间大于客户端           sendFileToClient();         } else {           res.writeHeader(304);           res.write('Not Modified');           res.end();         }       } else {         sendFileToClient();       }       function sendFileToClient() {         let rs = fs.createReadStream(`www/${pathname}`);         res.setHeader('Last-Modifyed', state.mtime.toGMTString());         rs.pipe(res);         rs.on('error', err => {           res.writeHeader(404);           res.write('Not Found');           res.end();         })       }     }   })}).listen(8080);

上面的这种缓存方式存在两个问题,首先他只是根据资源最后的修改时间戳进行判断,如果文件没有变更只是保存了一下修改时间也会变化。

其次标识时间是秒,如果修改特别快在毫秒内完成(程序修改会有这样的速度),那么就无法识别缓存过期。

主要原因就是服务器无法仅依据资源修改的时间戳识别出真正的更新,进而导致缓存不准确。

为了解决这个问题从HTTP1.1规范开始新增了一个ETag的头信息, 实体标签。

其内容主要是服务器为不同资源进行哈希运算生成的一个字符串,该字符串类似于文件指纹,只要文件内容编码存在差异,对应的ETag标签值就会不同,因此可以使用ETag对文件资源进行更精准的变化感知。

const etag = require('etag')res.setHeader('etag', etag(data));

基于ETag发送的请求会在请求头中以If-None-Match传递给服务器。

在协商缓存中ETag并非last-modified的替代方案而是一种补充方案,因为他也存在一些问题。

首先,服务器对生成文件资源的ETag需要付出额外的计算开销,如果资源体积较大,数量较多且修改较频繁,那么生成ETag的过程会影响服务器的性能。

其次,ETag的值分为强验证和弱验证,强验证根据资源内容进行生成,能够保证每个字节都相同。

弱验证则根据资源的部分属性值来生成,生成速度快但无法确保每次字节都相同。并且在服务器集群场景下,也会因为不够准确而降低协商缓存的有效性和校验的成功性。

恰当的方式是根据具体的资源使用场景选择恰当的缓存校验方式。

缓存策略

HTTP的缓存技术主要是为了提升网站的性能,如果不考虑客户端缓存容量和服务器计算能力的理想情况,我们当然希望客户端浏览器上的缓存触发率尽可能高,留存时间尽可能长,同时还要ETag实现当资源更新时进行高效的重新验证。

但实际情况往往是容量和计算能力都有限,因此就需要指定合适的缓存策略,利用有效的资源达到最优的性能效果。

明确需求边界,力求在边界内做到最好。

在使用缓存技术优化性能的过程中,有一个问题是不可逾越的,我们既希望缓存能在客户端尽可能长久的保存,又希望他能在资源发生修改时进行及时更新。这是两个互斥的需求。

如何兼顾二者呢?

可以将网站所需要的资源按照不同的类型去拆解,为不同类型的资源制定相应的缓存策略。

首先html文件是包含其他文件的主文件,为保证当其发生改变能及时更新,应该将其设置为协商缓存。

cache-control: no-cache

图片文件的修改基本都是替换,同时考虑图片文件的数量及大小可能对客户端缓存空间造成不小的开销,所以可以采用强制缓存且过期时间不宜过长。

cache-control: max-age=86400

css样式表属于文本文件,可能存在的内容不定期修改,还想使用强制缓存来提高重用效率,故可以考虑在样式表文件的命名中增加指纹或版本号(一般为hash值),这样发生修改后不同的文件便会有不同的文件指纹,也就是请求的url不同。

所以css的缓存时间可以设置长一些, 一年。

cache-control: max-age=31536000

js脚本文件可以类似样式表的设置,采用指纹和较长的过期时间,如果js中包含了用户的私人信息而不想让中间代理缓存,可添加private属性。

cache-control: private, max-age=31536000

缓存策略就是为不同的资源进行组合使用强制缓存,协商缓存及文件指纹或版本号,这样可以做到一举多得,及时修改更新,较长缓存过期时间及控制所能进行缓存的位置。

缓存设置需要注意不存在适用于所有场景下的最佳缓存策略,凡是恰当的缓存策略都需要根据具体的场景考虑制定。

缓存决策要考虑下面几种情况。

1)、拆分源码,分包加载

对于大型项目来说,代码里是非常庞大的,如果发生修改的部分集中在几个重要的模块中,那么进行全量的代码更新显然比较冗杂,因此可以考虑在代码构建过程中按照模块拆分将其打包成多个单独的文件。

这样在每次修改后更新提取时,仅需拉取发生改变的模块代码包,从而大大降低了需要下载的内容大小。

2)、预估资源的缓存时效

根据不同资源的不同需求特点来规划响应的缓存更新失效,为强制缓存指定合适的max-age,为协商缓存提供验证更新的ETag实体标签。

3)、控制中间代理的缓存

凡是涉及用户隐私信息的尽量避免中间代理的缓存,如果对所有用户响应相同的资源,则可以考虑让中间代理也进行缓存。

4)、避免网址的冗余

缓存是根据请求资源的URL进行的,不同的资源会有不同的URL,所以尽量不要将相同的资源设置为不同的URL。这会导致缓存失效。

5)、规划缓存的层次结构

不仅请求的资源类型,文件资源的层次结构也会对制定缓存策略有一定的影响。

CDN缓存

CND全程是Content Delivery Network,内容分发网络,他是构建在现有网络基础上的虚拟智能网络,依靠部署在各地的边缘服务器,通过中心平台的负载均衡,调度及内容分发等功能模块,使用户在请求所需访问的内容时能够就近获取,以此来降低网络阻塞,提高资源对用户的响应速度。

如果没有CDN,假设我们的服务器在北京,那么海南的用户访问我们的网站的时候需要不远万里链接北京的服务器获取资源,这样的速度是比较慢的。

CDN的工作原理就是就近响应,如果我们将资源放置在CDN上,当海南的用户访问网站时,资源请求首先进行DNS解析,这个时候DNS会询问CDN服务器有没有就近的服务器,如果有就链接就近服务的IP地址获取资源。

由于DNS服务器将CDN的域名解析权交给了CNAME指向的专用DNS服务器,所以用户输入域名的解析最终是在CDN专用的DNS服务器上完成的。

解析出的IP地址并非确定的CDN缓存服务器地址,而是CDN负载均衡器的地址。

浏览器会重新向该负载均衡器发起请求,经过对用户IP地址的距离,所请求资源内容的位置及各个服务器状态的综合计算,返回给用户确定的缓存服务器IP地址。

如果这个过程发生所需资源未找到的情况,那么此时便会依次向上一级缓存服务器继续请求查询,直至追溯到网站所在的跟服务器并将资源拉取到本地进行缓存。

虽然这个过程看起来稍微复杂一些,但是用户是无感知的,并且能带来比较明显的资源加载速度的提升,因此对目前所有一线互联网产品来说,使用CDN已经不再是一条建议而是规定。

CDN主要针对的是静态资源并非适用网站所有的资源类型。所谓静态资源就是不需要业务服务器参与计算的资源,比如第三方的库,js脚本文件,css样式文件,图片等。

如果是动态资源比如依赖服务端渲染的html就不适合放在CDN上。

CDN网络的核心功能包括两点,缓存与回源,缓存指的是将所需的静态资源文件复制一份到CDN缓存服务器上,回源指的是如果未在CDN缓存服务器上查找到目标资源,或者CDN缓存服务器上的资源已经过期,则重新追溯到网站根服务器获取相关资源的过程。

CDN的优化有很多方面,比如CDN自身的性能优化,静态资源边缘化,域名合并优化和多级缓存架构优化。这些可能需要前后端一起配合完成。

一般情况CDN会和主站域名区分,这样的好处是避免静态资源请求携带不必要的cookie信息,还有就是考虑浏览器对同一域名下并发请求的限制。

cookie的访问遵循同源策略,同一域名下的所有请求都会携带全部cookie信息,虽然cookie存储空间并不大,但是如果所有资源都放在主站域名下所有的请求全部携带数据量也是很大的。

所以将CDN服务器的域名和主站域名进行区分是非常有价值的。

其次因为浏览器对于同域名下的并发请求存在限制,通常Chrome的并发限制是6。可以通过增加类似域名的方式来提高并发请求数。

当然这种方式对缓存命中是不友好的,如果并发请求了相同的资源使用了不同的域名,那么之前的缓存就失去了意义。

  • 本文作者:隐冬

  • 本文链接:https://zhiqianduan.com/javascript/资源请求速度优化.html


往期推荐


2021 TWeb 腾讯前端技术大会精彩回顾(附PPT)
面试题:说说事件循环机制(满分答案来了)
专心工作只想搞钱的前端女程序员的2020

最后


  • 欢迎加我微信,拉你进技术群,长期交流学习...

  • 欢迎关注「前端Q」,认真学前端,做个专业的技术人...

点个在看支持我吧
浏览 50
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报