欢迎光临
我的个人博客网站

前端性能优化


大家是从什么时候接触性能优化的呢?

第一时间想到的又是什么呢?雅虎军规 ? 高性能javascript ?

性能优化没有标准答案,我们只能不断地把从搜索引擎和书中的知识付诸实践,这个过程是漫长且艰辛的

本文为总结记录学习修言大佬小册,感兴趣的同学可以购买支持正版

一切从一道面试题开始

从输入 URL 到页面加载完成,发生了什么?

概括来说,分为5步

  1. DNS把url解析成IP
  2. 客户端通过Ip和服务端建立tcp连接
  3. 客户端发起http请求
  4. 服务端处理http请求并返回响应
  5. 客户端拿到响应并渲染页面

每一步都可以说的很细,这5个过程就是提高前端性能的根本的切入点

关于第一第二步,我们前端能做的非常有限,理解为主,

DNS 解析花时间,能不能尽量减少解析次数或者把解析前置?能——浏览器 DNS 缓存和 DNS prefetch

TCP 每次的三次握手都急死人,有没有解决方案?有——长连接、预连接、接入 SPDY 协议

为了知其所以然(应对深究的面试官),我们了解下DNS的解析过程:

  1. 浏览器先检查自身缓存中有没有被解析过的这个域名对应的ip地址,如果有,解析结束
  2. 如果浏览器缓存没有命中,浏览器会检查操作系统缓存中有没有对应的已解析过的结果。而操作系统也有一个域名解析的过程。在windows中可通过c盘里一个叫hosts的文件来设置,如果你在这里指定了一个域名对应的ip地址,那浏览器会首先使用这个ip地址。但是这种操作系统级别的域名解析规程也被很多黑客利用,通过修改你的hosts文件里的内容把特定的域名解析到他指定的ip地址上,造成所谓的域名劫持。所以在windows7中将hosts文件设置成了readonly,防止被恶意篡改。
  3. 如果至此还没有命中域名,才会真正的请求本地域名服务器(LDNS)来解析这个域名,这台服务器一般在你的城市的某个角落,距离你不会很远,并且这台服务器的性能都很好,一般都会缓存域名解析结果,大约80%的域名解析到这里就完成了。
  4. 如果LDNS仍然没有命中,就直接跳到Root Server 域名服务器请求解析
  5. 根域名服务器返回给LDNS一个所查询域的主域名服务器(gTLD Server,国际顶尖域名服务器,如.com .cn .org等)地址
  6. 此时LDNS再发送请求给上一步返回的gTLD
  7. 接受请求的gTLD查找并返回这个域名对应的Name Server的地址,这个Name Server就是网站注册的域名服务器
  8. Name Server根据映射关系表找到目标ip,返回给LDNS
  9. LDNS缓存这个域名和对应的ip
  10. LDNS把解析的结果返回给用户,用户根据TTL值缓存到本地系统缓存中,域名解析过程至此结束

HTTP 连接这一层面的优化才是我们网络优化的核心,

  1. 减少http请求次数
  2. 减少单次请求花费的时间

减少h t tp请求次数就必定会增加单次请求的开销,两点怎么权衡?

网络篇

减少单次请求花费的时间

webpack 性能优化

最常见操作就是资源的压缩和合并,该操作最常见的工具就是webpack, 所以问题就指向了webpack的性能瓶颈

构建过程时间太长

打包体积太大

从 webpack v4.0.0 开始,可以不用引入一个配置文件。然而,webpack 仍然还是高度可配置的

不要让loader做太多事

以 babel-loader 为例:

下面直接贴webpack官网的描述

babel-loader 很慢!

确保转译尽可能少的文件。你可能使用 /.js$/ 来匹配,这样也许会去转译 node_modules 目录或者其他不需要的源代码。

要排除 node_modules,参考文档中的 loaders 配置的 exclude 选项。

你也可以通过使用 cacheDirectory 选项,将 babel-loader 提速至少两倍。 这会将转译的结果缓存到文件系统中。

babel 对一些公共方法使用了非常小的辅助代码,比如 _extend。 默认情况下会被添加到每一个需要它的文件中

你可以引入 babel runtime 作为一个独立模块,来避免重复引入。

下面的配置禁用了 babel 自动对每个文件的 runtime 注入,而是引入 babel-plugin-transform-runtime 并且使所有辅助代码从这里引用。

rules: [   // 'transform-runtime' 插件告诉 babel 要引用 runtime 来代替注入。   {     test: /.js$/,     exclude: /(node_modules|bower_components)/,     use: {       loader: 'babel-loader?cacheDirectory=true',       options: {         presets: ['@babel/preset-env'],         plugins: ['@babel/transform-runtime']       }     }   } ]  

不要放过第三方库

打包第三方依赖推荐 DllPlugin

DllPlugin 是基于 Windows 动态链接库(dll)的思想被创作出来的。这个插件会把第三方库单独打包到一个文件中,这个文件就是一个单纯的依赖库。这个依赖库不会跟着你的业务代码一起被重新打包,只有当依赖自身发生版本变化时才会重新打包

Dll.config.js

// 以一个基于 React 的简单项目为例,我们的 dll 的配置文件可以编写如下 const path = require('path') const webpack = require('webpack')  module.exports = {     entry: {       // 依赖的库数组       vendor: [         'prop-types',         'babel-polyfill',         'react',         'react-dom',         'react-router-dom',       ]     },     output: {       path: path.join(__dirname, 'dist'),       filename: '[name].js',       library: '[name]_[hash]',     },     plugins: [       new webpack.DllPlugin({         // DllPlugin的name属性需要和libary保持一致         name: '[name]_[hash]',         path: path.join(__dirname, 'dist', '[name]-manifest.json'),         // context需要和webpack.config.js保持一致         context: __dirname,       }),     ], } 

运行这个配置文件,我们的 dist 文件夹里会出现这样两个文件:

  1. vendor-manifest.json: 用于描述每个第三方库对应的具体路径
  2. vendor.js: 我们第三方库打包的结果

Webpack.config.js

const path = require('path'); const webpack = require('webpack') module.exports = {   mode: 'production',   // 编译入口   entry: {     main: './src/index.js'   },   // 目标文件   output: {     path: path.join(__dirname, 'dist/'),     filename: '[name].js'   },   // dll相关配置   plugins: [     new webpack.DllReferencePlugin({       context: __dirname,       // manifest就是我们第一步中打包出来的json文件       manifest: require('./dist/vendor-manifest.json'),     })   ] } 

Happypack——将 loader 由单进程转为多进程

webpack由于是node编写的,node是单线程的,就算此刻存在多个任务,你也只能排队一个接一个地等待处理, 为了充分利用多核cpu的资源,根据cpu的核数,我们可以fork多个进程, Happypack就是为此而生

const HappyPack = require('happypack') // 手动创建进程池 const happyThreadPool =  HappyPack.ThreadPool({ size: os.cpus().length })  module.exports = {   module: {     rules: [       ...       {         test: /.js$/,         // 问号后面的查询参数指定了处理这类文件的HappyPack实例的名字         loader: 'happypack/loader?id=happyBabel',         ...       },     ],   },   plugins: [     ...     new HappyPack({       // 这个HappyPack的“名字”就叫做happyBabel,和楼上的查询参数遥相呼应       id: 'happyBabel',       // 指定进程池       threadPool: happyThreadPool,       loaders: ['babel-loader?cacheDirectory']     })   ], } 

这样就可以并发处理多个任务

可视化打包后各个包的体积

webpack-bundle-analyzer

大家可以点进去看一下,它将创建一个交互式treemap可视化你的包的内容,这样你就可以知道哪些包是引起你打包体积过大的罪魁祸首

删除冗余代码

tree shaking

tree shaking 是一个术语,通常用于描述移除 JavaScript 上下文中的未引用代码(dead-code)。它依赖于 ES2015 模块系统中的静态结构特性,例如 importexport。这个术语和概念实际上是兴起于 ES2015 模块打包工具 rollup

新的 webpack 4 正式版本,扩展了这个检测能力,通过 package.json"sideEffects" 属性作为标记,向 compiler 提供提示,表明项目中的哪些文件是 “pure(纯的 ES2015 模块)”,由此可以安全地删除文件中未使用的部分。

如果所有代码都不包含副作用,我们就可以简单地将该属性标记为 false,来告知 webpack,它可以安全地删除未用到的 export 导出。

{   "name": "your-project",   "sideEffects": false } 

如果你的代码确实有一些副作用,那么可以改为提供一个数组:

数组方式支持相关文件的相对路径、绝对路径和 glob 模式。它在内部使用 micromatch

注意,任何导入的文件都会受到 tree shaking 的影响。这意味着,如果在项目中使用类似 css-loader 并导入 CSS 文件,则需要将其添加到 side effect 列表中,以免在生产模式中无意中将它删除:

{   "name": "your-project",   "sideEffects": [     "./src/some-side-effectful-file.js"     "*.css"   ] } 

Tree-Shaking 的针对性很强,它更适合用来处理模块级别的冗余代码

UglifyJsPlugin

// 在压缩过程中对碎片化的冗余代码(如 console 语句、注释等)进行自动化删除 const webpack = require('webpack'); module.exports = {  plugins: [    new webpack.optimize.UglifyJsPlugin({    		// 允许并发      parallel: true,      // 开启缓存      cache: true,      compress: {        // 删除所有的console语句            drop_console: true,        // 把使用多次的静态值自动定义为变量        reduce_vars: true,      },      output: {        // 不保留注释        comment: false,        // 使输出的代码尽可能紧凑        beautify: false      }   }),  ] } 

按需加载

比如路由的懒加载,tab组件的懒加载

Gzip

gzip的基础是DEFLATE,DEFLATE是LZ77与哈夫曼编码的一个组合体。DEFLATE最初是作为LZW以及其它受专利保护的数据压缩算法的替代版本而设计的,当时那些专利限制了compress以及其它一些流行的归档工具的应用

开启gzip压缩,只需要在请求头中加上

accept-encoding:gzip

压缩后通常能帮我们减少响应 70% 左右的大小

Gzip 压缩背后的原理,是在一个文本文件中找出一些重复出现的字符串、临时替换它们,从而使整个文件变小。根据这个原理,文件中代码的重复率越高,那么压缩的效率就越高,使用 Gzip 的收益也就越大。反之亦然

图片优化

图片优化就是在图片体积和图片质量之间做权衡

前置知识: 在计算机中,像素用二进制数来表示, n位二进制可以表示2^n种颜色,

所以二进制位数越多,可表示的颜色种类就越多,成像效果越细腻,图片体积越大

我们需要在不同的业务场景选择合适的图片类型

JPEG/JPG

体积小有损压缩不支持透明加载快

当我们把图片体积压缩至原有体积的 50% 以下时,JPG 仍然可以保持住 60% 的品质。此外,JPG 格式以 24 位存储单个图,可以呈现多达2^24 = 16,777,216 种颜色,足以应对大多数场景下对色彩的要求

使用场景

JPG 图片经常作为大的背景图、轮播图或 Banner 图出现,使用 JPG 呈现大图,既可以保住图片的质量,又不会带来令人头疼的图片体积,是当下比较推崇的一种方案。

PNG-8/PNG-24

体积大无损压缩支持透明质量高

8和24代表2进制位数

Png-8: 2^8 = 256中颜色

Png-24: 2^24 = 16,777,216 种颜色

追求极致的显示效果,不在意图片大小的可以选择png-24

实践中,如果png-8没有带来视觉可辨别的色彩缺陷,考虑到体积,一般使用png-8

使用场景

考虑到 PNG 在处理线条和颜色对比度方面的优势,我们主要用它来呈现小的 Logo、颜色简单且对比强烈的图片或背景等。

性能方面堪称业界楷模的淘宝首页页面上的 Logo,无论大小,都是 PNG 格式

SVG

体积小不失真文本文件兼容性好

SVG(可缩放矢量图形)是一种基于 XML 语法的图像格式。它和本文提及的其它图片种类有着本质的不同:SVG 对图像的处理不是基于像素点,而是是基于对图像的形状描述。

优势

  • SVG 与 PNG 和 JPG 相比,文件体积更小,可压缩性更强
  • 作为矢量图,它最显著的优势在于图片可无限放大而不失真, 这使得 SVG 即使是被放到视网膜屏幕上,也可以一如既往地展现出较好的成像品质——1 张 SVG 足以适配 n 种分辨率
  • SVG 是文本文件。我们既可以像写代码一样定义 SVG,把它写在 HTML 里、成为 DOM 的一部分,也可以把对图形的描述写入以 .svg 为后缀的独立文件, 这使得 SVG 文件可以被非常多的工具读取和修改,具有较强的灵活性

劣势

  • 渲染成本高
  • 相比其他图片格式,学习成本高,因为它是可编程的

使用场景

小图标

Base64

文本文件依赖编码小图标解决方案

Base64 并非一种图片格式,而是一种编码方式。Base64 和雪碧图一样,是作为小图标解决方案而存在的。在了解 Base64 之前,我们先来了解一下雪碧图

雪碧图

图像精灵(sprite,意为精灵),被运用于众多使用大量小图标的网页应用之上。它可取图像的一部分来使用,使得使用一个图像文件替代多个小文件成为可能。相较于一个小图标一个图像文件,单独一张图片所需的 HTTP 请求更少,对内存和带宽更加友好。

前端使用background-position 来获取不同位置的图标

Base64 图片的出现,也是为了减少加载网页图片时对服务器的请求次数,从而提升网页性能。Base64 是作为雪碧图的补充而存在的。

使用场景

小logo, 小icon

  • 图片的实际尺寸很小(大家可以观察一下掘金页面的 Base64 图,几乎没有超过 2kb 的)
  • 图片无法以雪碧图的形式与其它小图结合(合成雪碧图仍是主要的减少 HTTP 请求的途径,Base64 是雪碧图的补充)
  • 图片的更新频率非常低(不需我们重复编码和修改文件内容,维护成本较低)

劣势

Base64 编码后,图片大小会膨胀为原文件的 4/3(这是由 Base64 的编码原理决定的),

如果我们把大图也编码到 HTML 或 CSS 文件中,后者的体积会明显增加,即便我们减少了 HTTP 请求,也无法弥补这庞大的体积带来的性能开销,得不偿失

base64编码工具推荐

webpack 的 url-loader , limit参数表示 指定文件的最大大小,以字节为单位,只有8192byte一下大小的图片才会进行base64编码

module.exports = {   module: {     rules: [       {         test: /.(png|jpg|gif)$/i,         use: [           {             loader: 'url-loader',             options: {               limit: 8192,             },           },         ],       },     ],   }, }; 

WebP

年轻的全能型选手

它于 2010 年被提出, 是 Google 专为 Web 开发的一种旨在加快图片加载速度的图片格式,它支持有损压缩和无损压缩

与 PNG 相比,WebP 无损图像的尺寸缩小了 26%。在等效的 SSIM 质量指数下,WebP 有损图像比同类 JPEG 图像小 25-34%。

  • 最大的缺陷就是兼容性
  • WebP 还会增加服务器的负担——和编码 JPG 文件相比,编码同样质量的 WebP 文件会占用更多的计算资源

应用场景

要使用webP我们就必须为不兼容的浏览器进行降级处理

我们可以看看淘宝是怎么做的

在谷歌浏览器打开淘宝,打开控制台,搜索.webp

其中一个img的src是这样的

img.alicdn.com/imgextra/i3/6000000002336/O1CN019A9rll1T7vqVs4Wur_!!6000000002336-0-octopus.jpg_400x400q90.jpg_.webp 

在safari中打开淘宝,打开控制台,查看同一张图片的src

img.alicdn.com/imgextra/i3/6000000002336/O1CN019A9rll1T7vqVs4Wur_!!6000000002336-0-octopus.jpg_400x400q90.jpg 

淘宝是会根据浏览器的型号来判断是否支持webp,不支持就把.webp后缀切换成.jpg

更灵活的方案

把判断逻辑交给后端,服务器根据 请求头的 Accept 字段 来判断是否支持webp, 否则返回原图(jpg),

这样做的好处是当webp的兼容性发生变化时,前端不用修改判断是否支持webp的代码

存储篇

通过网络获取内容既速度缓慢又开销巨大。较大的响应需要在客户端与服务器之间进行多次往返通信,这会延迟浏览器获得和处理内容的时间,还会增加访问者的流量费用。因此,缓存并重复利用之前获取的资源的能力成为性能优化的一个关键方面。

浏览器缓存机制

按照获取资源时请求的优先级排序:

  1. Memory Cache
  2. Service Worker Cache
  3. HTTP Cache
  4. Push Cache

Memory Cache(内存缓存)

是指存在内存中的缓存。从优先级上来说,它是浏览器最先尝试去命中的一种缓存。从效率上来说,它是响应速度最快的一种缓存。

内存缓存是快的,也是“短命”的。它和渲染进程“生死相依”,当进程结束后,也就是 tab 关闭以后,内存里的数据也将不复存在

资源存不存内存,浏览器秉承的是“节约原则”。我们发现,Base64 格式的图片,几乎永远可以被塞进 memory cache,这可以视作浏览器为节省渲染开销的“自保行为”;此外,体积不大的 JS、CSS 文件,也有较大地被写入内存的几率——相比之下,较大的 JS、CSS 文件就没有这个待遇了,内存资源是有限的,它们往往被直接甩进磁盘

Service Worker Cache

Service Worker 是一种独立于主线程之外的 Javascript 线程。它脱离于浏览器窗体,因此无法直接访问 DOM。这样独立的个性使得 Service Worker 的“个人行为”无法干扰页面的性能,这个“幕后工作者”可以帮我们实现离线缓存消息推送网络代理等功能。我们借助 Service worker 实现的离线缓存就称为 Service Worker Cache。

Service Worker 的生命周期包括 installactiveworking三个阶段。一旦 Service Worker 被 install,它将始终存在,只会在 active 与 working 之间切换,除非我们主动终止它。这是它可以用来实现离线存储的重要先决条件。

如何使用 service worker(必须以 https 协议为前提)

新建test.js文件

// Service Worker会监听 install事件,我们在其对应的回调里可以实现初始化的逻辑   self.addEventListener('install', event => {   event.waitUntil(     // 考虑到缓存也需要更新,open内传入的参数为缓存的版本号     // caches 为浏览器的API     // CacheStorage {} 		//   __proto__: CacheStorage 		//   delete: ƒ delete() 		//  	has: ƒ has()     //   keys: ƒ keys()     //    match: ƒ match()     //    open: ƒ open()     //    constructor: ƒ CacheStorage()     //   Symbol(Symbol.toStringTag): "CacheStorage"     //   __proto__: Object     caches.open('test-v1').then(cache => {       return cache.addAll([         // 此处传入指定的需缓存的文件名         '/test.html',         '/test.css',         '/test.js'       ])     })   ) })  // Service Worker会监听所有的网络请求,网络请求的产生触发的是fetch事件,我们可以在其对应的监听函数中实现对请求的拦截,进而判断是否有对应到该请求的缓存,实现从Service Worker中取到缓存的目的 self.addEventListener('fetch', event => {   event.respondWith(     // 尝试匹配该请求对应的缓存值     caches.match(event.request).then(res => {       // 如果匹配到了,调用Server Worker缓存       if (res) {         return res;       }       // 如果没匹配到,向服务端发起这个资源请求       return fetch(event.request).then(response => {         if (!response || response.status !== 200) {           return response;         }         // 请求成功的话,将请求缓存起来。         caches.open('test-v1').then(function(cache) {           cache.put(event.request, response);         });         return response.clone();       });     })   ); }); 

在项目代码入口j s文件中加入

window.navigator.serviceWorker.register('/test.js').then(    function () {       console.log('注册成功')     }).catch(err => {       console.error("注册失败")     }) 

HTTP Cache(重点)

HTTP 缓存是我们日常开发中最为熟悉的一种缓存机制。它又分为强缓存协商缓存。优先级较高的是强缓存,在命中强缓存失败的情况下,才会走协商缓存。

强缓存

强缓存是利用 http 头中的 ExpiresCache-Control两个字段来控制的。强缓存中,当请求再次发出时,浏览器会根据其中的 expires 和 cache-control 判断目标资源是否“命中”强缓存,若命中则直接从缓存中获取资源,不会再与服务端发生通信。

命中强缓存的情况下,返回的 HTTP 状态码为200 (from disk cache)

Cache-Control 的 max-age 配置项相对于 expires 的优先级更高。当 Cache-Control 与 expires 同时出现时,我们以 Cache-Control 为准。

Cache-Control
cache-control: max-age=31536000 

max-age表示的有效时长, 单位是秒,表示该资源31536000秒内是有效的

public 与 private

public 与 private 是针对资源是否能够被代理服务缓存而存在的一组对立概念。

如果我们为资源设置了 public,那么它既可以被浏览器缓存,也可以被代理服务器缓存;如果我们设置了 private,则该资源只能被浏览器缓存。private 为默认值。但多数情况下,public 并不需要我们手动设置

no-store与no-cache

no-cache 绕开了浏览器:我们为资源设置了 no-cache 后,每一次发起请求都不会再去询问浏览器的缓存情况,而是直接向服务端去确认该资源是否过期(不走强缓存)。

no-store 比较绝情,顾名思义就是不使用任何缓存策略。在 no-cache 的基础上,它连服务端的缓存确认也绕开了,只允许你直接向服务端发送请求、并下载完整的响应。

Expires
expires: Wed, 11 Sep 2019 16:12:18 GMT 

expires 设置的是资源到期时间(服务器时间)

接下来如果我们试图再次向服务器请求资源,浏览器就会先对比本地时间和 expires 的时间戳,如果本地时间小于 expires 设定的过期时间,那么就直接去缓存中取这个资源。由于时间戳是服务器来定义的,而本地时间的取值却来自客户端,因此 expires 的工作机制对客户端时间与服务器时间之间的一致性提出了极高的要求,若服务器与客户端存在时差,将带来意料之外的结果。

协商缓存

协商缓存机制下,浏览器需要向服务器去询问缓存的相关信息,进而判断是重新发起请求、下载完整的响应,还是从本地获取缓存的资源。

如果服务端提示缓存资源未改动(Not Modified),资源会被重定向到浏览器缓存,这种情况下网络请求对应的状态码是 304

Etag 在感知文件变化上比 Last-Modified 更加准确,优先级也更高。当 Etag 和 Last-Modified 同时存在时,以 Etag 为准.

Last-Modified

首次请求时,响应头里会携带Last-Modified字段,像这样

Last-Modified: Fri, 27 Oct 2017 06:35:57 GMT 

再次请求相同的资源,请求头里会带上If-Modified-Since字段,

值是上一次请求响应头的Last-Modified的值

If-Modified-Since: Fri, 27 Oct 2017 06:35:57 GMT 

服务器接收到这个时间戳后,会比对该时间戳和资源在服务器上的最后修改时间是否一致,从而判断资源是否发生了变化。如果发生了变化,就会返回一个完整的响应内容,并在 响应头 中添加新的 Last-Modified 值;否则,返回如上图的 304 响应,响应头 不会再添加 Last-Modified 字段。

弊端

  • 我们编辑了文件,但文件的内容没有改变。服务端并不清楚我们是否真正改变了文件,它仍然通过最后编辑时间进行判断。因此这个资源在再次被请求时,会被当做新资源,进而引发一次完整的响应——不该重新请求的时候,也会重新请求
  • 当我们修改文件的速度过快时(比如花了 100ms 完成了改动),由于 If-Modified-Since 只能检查到以秒为最小计量单位的时间差,所以它是感知不到这个改动的——该重新请求的时候,反而没有重新请求了

为了解决这样的问题,Etag 作为 Last-Modified 的补充出现了

Etag

Etag 是由服务器为每个资源生成的唯一的标识字符串,这个标识字符串是基于文件内容编码的,只要文件内容不同,它们对应的 Etag 就是不同的,反之亦然。因此 Etag 能够精准地感知文件的变化。

首次请求时,响应头里会携带ETag字段,像这样

ETag: W/"2a3b-1602480f459" 

再次请求相同的资源,请求头里会带上If-Modified-Since字段,

值是上一次请求响应头的ETag的值

If-None-Match: W/"2a3b-1602480f459" 

服务器会拿If-None-Match的值和服务器资源的当前标识对比,相同返回304, 不同返回新的资源并带上新的Etag

Etag 的生成过程需要服务器额外付出开销,会影响服务端的性能,这是它的弊端。因此启用 Etag 需要我们审时度势。正如我们刚刚所提到的——Etag 并不能替代 Last-Modified,它只能作为 Last-Modified 的补充和强化存在。

本地存储

Cookie

Cookie 的本职工作并非本地存储,而是“维持状态”。

在 Web 开发的早期,人们亟需解决的一个问题就是状态管理的问题:HTTP 协议是一个无状态协议,服务器接收客户端的请求,返回一个响应,故事到此就结束了,服务器并没有记录下关于客户端的任何信息。那么下次请求的时候,如何让服务器知道“我是我”呢?

在这样的背景下,Cookie 应运而生。
学习中,持续更新。。。

赞(0) 打赏
未经允许不得转载:张拓的天空 » 前端性能优化
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

专业的IT技术经验分享 更专业 更方便

联系我们本站主机

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏