前端性能优化
序
大家是从什么时候接触性能优化的呢?
第一时间想到的又是什么呢?雅虎军规 ? 高性能javascript ?
性能优化没有标准答案,我们只能不断地把从搜索引擎和书中的知识付诸实践,这个过程是漫长且艰辛的
本文为总结记录学习修言大佬小册,感兴趣的同学可以购买支持正版
一切从一道面试题开始
从输入 URL 到页面加载完成,发生了什么?
概括来说,分为5步
- DNS把url解析成IP
- 客户端通过Ip和服务端建立tcp连接
- 客户端发起http请求
- 服务端处理http请求并返回响应
- 客户端拿到响应并渲染页面
每一步都可以说的很细,这5个过程就是提高前端性能的根本的切入点
关于第一第二步,我们前端能做的非常有限,理解为主,
DNS 解析花时间,能不能尽量减少解析次数或者把解析前置?能——浏览器 DNS 缓存和 DNS prefetch
TCP 每次的三次握手都急死人,有没有解决方案?有——长连接、预连接、接入 SPDY 协议
为了知其所以然(应对深究的面试官),我们了解下DNS的解析过程:
- 浏览器先检查自身缓存中有没有被解析过的这个域名对应的ip地址,如果有,解析结束
- 如果浏览器缓存没有命中,浏览器会检查操作系统缓存中有没有对应的已解析过的结果。而操作系统也有一个域名解析的过程。在windows中可通过c盘里一个叫hosts的文件来设置,如果你在这里指定了一个域名对应的ip地址,那浏览器会首先使用这个ip地址。但是这种操作系统级别的域名解析规程也被很多黑客利用,通过修改你的hosts文件里的内容把特定的域名解析到他指定的ip地址上,造成所谓的域名劫持。所以在windows7中将hosts文件设置成了readonly,防止被恶意篡改。
- 如果至此还没有命中域名,才会真正的请求本地域名服务器(LDNS)来解析这个域名,这台服务器一般在你的城市的某个角落,距离你不会很远,并且这台服务器的性能都很好,一般都会缓存域名解析结果,大约80%的域名解析到这里就完成了。
- 如果LDNS仍然没有命中,就直接跳到Root Server 域名服务器请求解析
- 根域名服务器返回给LDNS一个所查询域的主域名服务器(gTLD Server,国际顶尖域名服务器,如.com .cn .org等)地址
- 此时LDNS再发送请求给上一步返回的gTLD
- 接受请求的gTLD查找并返回这个域名对应的Name Server的地址,这个Name Server就是网站注册的域名服务器
- Name Server根据映射关系表找到目标ip,返回给LDNS
- LDNS缓存这个域名和对应的ip
- LDNS把解析的结果返回给用户,用户根据TTL值缓存到本地系统缓存中,域名解析过程至此结束
HTTP 连接这一层面的优化才是我们网络优化的核心,
- 减少http请求次数
- 减少单次请求花费的时间
减少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 文件夹里会出现这样两个文件:
- vendor-manifest.json: 用于描述每个第三方库对应的具体路径
- 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']
})
],
}
这样就可以并发处理多个任务
可视化打包后各个包的体积
大家可以点进去看一下,它将创建一个交互式treemap可视化你的包的内容,这样你就可以知道哪些包是引起你打包体积过大的罪魁祸首
删除冗余代码
tree shaking
tree shaking 是一个术语,通常用于描述移除 JavaScript 上下文中的未引用代码(dead-code)。它依赖于 ES2015 模块系统中的静态结构特性,例如
import
和export
。这个术语和概念实际上是兴起于 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的代码
存储篇
通过网络获取内容既速度缓慢又开销巨大。较大的响应需要在客户端与服务器之间进行多次往返通信,这会延迟浏览器获得和处理内容的时间,还会增加访问者的流量费用。因此,缓存并重复利用之前获取的资源的能力成为性能优化的一个关键方面。
浏览器缓存机制
按照获取资源时请求的优先级排序:
- Memory Cache
- Service Worker Cache
- HTTP Cache
- 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 的生命周期包括 install、active、working三个阶段。一旦 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 头中的 Expires 和 Cache-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 的补充和强化存在。
Push Cache
Push Cache 是指 HTTP2 在 server push 阶段存在的缓存。
- Push Cache 是缓存的最后一道防线。浏览器只有在 Memory Cache、 Service Worker Cache和 HTTP Cache 均未命中的情况下才会去询问 Push Cache。
- Push Cache 是一种存在于会话阶段的缓存,当 session 终止时,缓存也随之释放。
- 不同的页面只要共享了同一个 HTTP2 连接,那么它们就可以共享同一个 Push Cache。
本地存储
Cookie
Cookie 的本职工作并非本地存储,而是“维持状态”。
在 Web 开发的早期,人们亟需解决的一个问题就是状态管理的问题:HTTP 协议是一个无状态协议,服务器接收客户端的请求,返回一个响应,故事到此就结束了,服务器并没有记录下关于客户端的任何信息。那么下次请求的时候,如何让服务器知道“我是我”呢?
在这样的背景下,Cookie 应运而生。
同一个域名下的所有请求,都会携带 Cookie。
Cookie 以键值对形式存储
服务器通过响应头的set-cookie: 字段来设置客户端cookie
客户端会在请求头的Cookie字段里携带cookie
客户端可以通过document.cookie来获取/设置cookie
Cookie 是有体积上限的,它最大只能有 4KB。
当 Cookie 超过 4KB 时,它将面临被裁切的命运。这样看来,Cookie 只能用来存取少量的信息。
为了弥补 Cookie 的局限性,让“专业的人做专业的事情”,Web Storage 出现了。
Web Storage
Web Storage 是 HTML5 专门为浏览器存储而提供的数据存储机制。它又分为 Local Storage 与 Session Storage。
Local Storage 与 Session Storage 的区别
两者的区别在于生命周期与作用域的不同。
- 生命周期:Local Storage 是持久化的本地存储,存储在其中的数据是永远不会过期的,使其消失的唯一办法是手动删除;而 Session Storage 是临时性的本地存储,它是会话级别的存储,当会话结束(页面被关闭)时,存储内容也随之被释放。
- 作用域:Local Storage、Session Storage 和 Cookie 都遵循同源策略。但 Session Storage 特别的一点在于,即便是相同域名下的两个页面,只要它们不在同一个浏览器窗口中打开,那么它们的 Session Storage 内容便无法共享。
Web Storage 的特性
- 存储容量大: Web Storage 根据浏览器的不同,存储容量可以达到 5-10M 之间。
- 仅位于浏览器端,不与服务端发生通信。
- Local Storage 与 Session Storage 在 API 方面无异, 它的原型对象上都有5个静态方法: getItemsetItemremoveItemclearkey
使用场景
- Local Storage 用于存储一些内容稳定的资源。比如图片内容丰富的电商网站会用它来存储 Base64 格式的图片字符串, 有的网站还会用它存储一些不经常更新的 CSS、JS 等静态资源。
- Session Storage 更适合用来存储生命周期和它同步的会话级别的信息。这些信息只适用于当前会话,当你开启新的会话时,它也需要相应的更新或释放。比如微博的 Session Storage 就主要是存储你本次会话的浏览足迹
- Web Storage 是对 Cookie 的拓展,它只能用于存储少量的简单数据。当遇到大规模的、结构复杂的数据时,Web Storage 也爱莫能助, 这时候就需要 IndexedDB出场了
IndexedDB
IndexedDB 是一个运行在浏览器上的非关系型数据库
理论上来说,IndexedDB 是没有存储上限的(一般来说不会小于 250M)。它不仅可以存储字符串,还可以存储二进制数据。
下面来熟悉一下它的使用流程
-
创建/打开一个数据库
if (!window.indexedDB) { window.alert("Your browser doesn't support a stable version of IndexedDB. Such and such feature will not be available.") } // 后面的回调中,我们可以通过event.target.result拿到数据库实例 let db // open 第一个参数为数据库名,第二个参数为版本号 // 没有就创建,有就打开 const request = window.indexedDB.open("xiaoceDB", 1) // 使用IndexedDB失败时的监听函数 request.onerror = function(event) { console.log('无法使用IndexedDB') } // 成功 request.onsuccess = function(event){ // 此处就可以获取到db实例 db = event.target.result console.log("你打开了IndexedDB") }
-
建表(表对应indexDB数据库的store)
// onupgradeneeded事件会在初始化数据库/版本发生更新时被调用, // 我们在它的监听函数中创建object store request.onupgradeneeded = function(event){ let objectStore // 如果同名表未被创建过,则新建test表 if (!db.objectStoreNames.contains('test')) { objectStore = db.createObjectStore('test', { keyPath: 'id' }) } }
-
构建一个事务来执行一些数据库操作,像增加或提取数据等。
// 创建事务,指定表格名称和读写权限 const transaction = db.transaction(["test"],"readwrite") transaction.oncomplete = function(event) { // 在所有数据添加完毕后的处理 }; transaction.onerror = function(event) { // 不要忘记错误处理! }; // 拿到Object Store对象 const objectStore = transaction.objectStore("test") // 向表格写入数据 const request = objectStore.add({id: 1, name: 'xiuyan'}) request.onsuccess = function(event) { // 插入成功 };
使用场景
通过上面的示例大家可以看出,在 IndexedDB 中,我们可以创建多个数据库,一个数据库中创建多张表,一张表中存储多条数据——这足以 hold 住复杂的结构性数据。IndexedDB 可以看做是 LocalStorage 的一个升级,当数据的复杂度和规模上升到了 LocalStorage 无法解决的程度,我们毫无疑问可以请出 IndexedDB 来帮忙。
CDN的缓存与回源机制解析
CDN (Content Delivery Network,即内容分发网络)指的是一组分布在各个地区的服务器。这些服务器存储着数据的副本,因此服务器可以根据哪些服务器与用户距离最近,来满足数据的请求。 CDN 提供快速服务,较少受高流量影响。
本地存储带来的性能提升,只能在“获取到资源并把它们存起来”这件事情发生,也就是说,首次请求资源的时候,这些招数都是救不了我们的。要提升首次请求的响应能力,我们还需要借助 CDN 的能力。
CDN 的核心点有两个,一个是缓存,一个是回源。
“缓存”就是说我们把资源 copy 一份到 CDN 服务器上这个过程
“回源”就是说 CDN 发现自己没有这个资源(一般是缓存的数据过期了),转头向根服务器(或者它的上层服务器)去要这个资源的过程。
CDN工作原理
CDN 往往被用来存放静态资源。上文中我们所提到的“根服务器”本质上是业务服务器,它的核心任务在于生成动态页面或返回非纯静态页面,这两种过程都是需要计算的。业务服务器仿佛一个车间,车间里运转的机器轰鸣着为我们产出所需的资源;相比之下,CDN 服务器则像一个仓库,它只充当资源的“栖息地”和“搬运工”。
静态资源: 就是像 JS、CSS、图片等不需要业务服务器进行计算即得的资源。
动态资源: 顾名思义是需要后端实时动态生成的资源,较为常见的就是 JSP、ASP 或者依赖服务端渲染得到的 HTML 页面。
非纯静态资源: 它是指需要服务器在页面之外作额外计算的 HTML 页面。具体来说,当我打开某一网站之前,该网站需要通过权限认证等一系列手段确认我的身份、进而决定是否要把 HTML 页面呈现给我。这种情况下 HTML 确实是静态的,但它和业务服务器的操作耦合,我们把它丢到CDN 上显然是不合适的。
CDN 的实际应用
- 静态资源本身具有访问频率高、承接流量大的特点,因此静态资源加载速度始终是前端性能的一个非常关键的指标
- CDN 是静态资源提速的重要手段,在许多一线的互联网公司,“静态资源走 CDN”并不是一个建议,而是一个规定。
- 比如以淘宝为代表的阿里系产品,就遵循着这个“规定”。
渲染篇
服务端渲染
服务端渲染的模式下,当用户第一次请求页面时,由服务器把需要的组件或页面渲染成 HTML 字符串,然后把它返回给客户端。客户端拿到手的,是可以直接渲染然后呈现给用户的 HTML 内容,不需要为了生成 DOM 内容自己再去跑一遍 JS 代码。
使用服务端渲染的网站,可以说是“所见即所得”,页面上呈现的内容,我们在 html 源文件里也能找到。
Tip: 客户端渲染: 页面上呈现的内容,你在 html 源文件里里找不到
解决的问题
- 客户端根据j s动态加载的页面无法被搜索引擎检索到,用户搜索关键字搜不到网页,用的人就少,所以不得不启用服务端渲染
- 用户点击我们的链接,客户端渲染要下载html, js ,还要跑一遍j s 用户才能看到首页;而服务端渲染只需要下载html用户就能看到首页,解决了首屏加载慢的问题
应用场景
服务端渲染本质上是本该浏览器做的事情,分担给服务器去做。这样当资源抵达浏览器时,它呈现的速度就快了
但是这样会增加服务器的压力
在实践中,服务器稀少而宝贵,但首屏渲染体验和 SEO 的优化方案却很多——我们最好先把能用的低成本“大招”都用完。
浏览器的运行机制
浏览器内核
渲染引擎和JS引擎
随着 JS 引擎越来越独立,内核也成了渲染引擎的代称
渲染引擎又包括了 HTML 解释器、CSS 解释器、布局、网络、存储、图形、音视频、图片解码器等等零部件
常见浏览器内核
Trident(IE) Gecko(火狐) Blink(Chrome、Opera) Webkit(Safari)
Blink 是 webkit 衍生而来的一个分支
以 webkit 内核为例,剖析浏览器的渲染过程
- 解析 HTML, 浏览器执行了所有的加载解析逻辑,在解析 HTML 的过程中发出了页面渲染所需的各种外部资源请求
- 计算样式 ,浏览器将识别并加载所有的 CSS 样式信息与 DOM 树合并,最终生成页面 render 树
- 计算图层布局
- 绘制图层, 浏览器会根据我们的 DOM 代码结果,把每一个页面图层转换为像素,并对所有的媒体文件进行解码
- 整合图层,得到页面,浏览器会合并合各个图层,将数据由 CPU 输出给 GPU 最终绘制在屏幕上
初次渲染完成后,每当一个新元素加入到Dom树中,浏览器会通过c s s引擎查c s s样式表, 找到符合该元素的样式规则应用到这个元素上,然后再重新去绘制它.
在找元素样式这个过程我们就可以进行代码层次的优化:
首先c s s引擎查找样式表的规则是,对每条样式从右到左的顺序去匹配
而并不是按我们的写法从左到右
所以这种写法耗时更长
#my li {}
c s s引擎先匹配所有li, 再去匹配li的父级是#my,
更坑的是使用通配符选择器重置样式
* {}
c s s引擎会匹配所有元素,耗费的时间更长。
- 推荐使用语义化的类名代替父子选择器
.my_li {}
- 尽量不使用通配符
拒绝嵌套 拒绝嵌套 拒绝嵌套,重要的话说三遍
告别阻塞
HTML、CSS 和 JS,都具有阻塞渲染的特性。
HTML 阻塞,天经地义——没有 HTML,何来 DOM?没有 DOM,渲染和优化,都是空谈。
CSS 阻塞
前面已经说了,需要CSSOM和DOM一起构建Render Tree, 所以即便DOM解析完了,只要CSSOM没解析完,页面就不会渲染,所以
CSS 是阻塞渲染的资源。需要将它尽早、尽快地下载到客户端,以便缩短首次渲染的时间。
也就有了
尽早: 把c s s 放在head标签里
尽快: 启用 CDN 实现静态资源加载速度的优化
JS 阻塞
正常模式引入j s文件 和内联j s代码都会阻塞dom树的解析,浏览器会暂时将控制权转交给JS引擎,j s执行完毕后, 浏览器会将控制权还给渲染引擎继续dom树的解析
外部引入js的加载时机是可控的
有三种模式可选
-
正常模式
<script src="index.js"></script>
这种情况下 JS 会阻塞浏览器,浏览器必须等待 index.js 加载和执行完毕才能去做其它事情。
-
async 模式:
<script async src="index.js"></script>
async 模式下,JS 不会阻塞浏览器做任何其它的事情。它的加载是异步的,当它加载结束,JS 脚本会立即执行。
-
defer 模式:
<script defer src="index.js"></script>
defer 模式下,JS 的加载是异步的,执行是被推迟的。等整个文档解析完成、DOMContentLoaded 事件即将被触发时,被标记了 defer 的 JS 文件才会开始依次执行。
从应用的角度来说,一般当我们的脚本与 DOM 元素和其它脚本之间的依赖关系不强时,我们会选用 async;当脚本依赖于 DOM 元素和其它脚本的执行结果时,我们会选用 defer。
DOM优化
为什么dom慢
把 DOM 和 JavaScript 各自想象成一个岛屿,它们之间用收费桥梁连接。——《高性能 JavaScript》
当我们用 JS 去操作 DOM 时,本质上是 JS 引擎和渲染引擎之间进行了“跨界交流”。这个“跨界交流”的实现并不简单,它依赖了桥接接口作为“桥梁”。
过“桥”要收费——这个开销本身就是不可忽略的。我们每操作一次 DOM(不管是为了修改还是仅仅为了访问其值 ),都要过一次“桥”。过“桥”的次数一多,就会产生比较明显的性能问题。因此“减少 DOM 操作”的建议,并非空穴来风。
当我们对 DOM 的修改会引发它外观(样式)上的改变时,就会触发回流或重绘。
- 回流:当我们对 DOM 的修改引发了 DOM 几何尺寸的变化(比如修改元素的宽、高或隐藏元素等)时,浏览器需要重新计算元素的几何属性(其他元素的几何属性和位置也会因此受到影响),然后再将计算的结果绘制出来。这个过程就是回流(也叫重排)。
- 重绘:当我们对 DOM 的修改导致了样式的变化、却并未影响其几何属性(比如修改了颜色或背景色)时,浏览器不需重新计算元素的几何属性、直接为该元素绘制新的样式(跳过了回流环节)。这个过程叫做重绘。
由此我们可以看出,重绘不一定导致回流,回流一定会导致重绘
我们在开发中,要从代码层面出发,尽可能把回流和重绘的次数最小化
触发回流的操作:offsetTop、offsetLeft、 offsetWidth、offsetHeight、scrollTop、scrollLeft、scrollWidth、scrollHeight、clientTop、clientLeft、clientWidth、clientHeight
谷歌浏览器的优化: 会把一段时间内的回流/重绘操作扔进一个flush队列里,不得已(上面的触发回流的操作)或者达到了一定的时间间隔再一次性出列
操作建议
- 把获取dom和修改dom拆开,避免多次修改同一个dom的值时获取多次dom,
- 使用document.createFragment创建代码片段来代替真实dom容器操作dom
Event Loop
浏览器中的事件循环:
前置知识, 异步队列:
macroTask: 宏任务队列,setTimeout、setInterval、 setImmediate、script(整体代码)、 I/O 操作、UI 渲染等
microTask: 微任务队列,process.nextTick、Promise、MutationObserver 等
- js执行栈为空,macroTask里有script, microTask为空
- macroTask的script出队列 压入 js执行栈 内, 执行script同步代码
- 执行script同步代码的过程中会产生macroTask和microTask
- 宏任务script同步代码执行完毕,执行宏任务执行过程中产生的所有微任务,在下一个
宏任务
执行前,GUI渲染线程
开始工作,对页面进行渲染 - js执行栈为空,开始下一个宏任务,周而复始
node中的事件循环
Node会先执行所有类型为 timers 的 MacroTask,然后执行所有的 MicroTask(NextTick例外)
进入 poll 阶段,执行几乎所有 MacroTask,然后执行所有的 MicroTask
再执行所有类型为 check 的 MacroTask,然后执行所有的 MicroTask
再执行所有类型为 close callbacks 的 MacroTask,然后执行所有的 MicroTask
至此,完成一个 Tick,回到 timers 阶段
……
如此反复,无穷无尽……
事件防抖与节流
// 节流, duration内无论触发多少次,只执行一次
// 使用一个变量作为节流的开关
const throttle = function (fn, duration = 300) {
if (!fn || !(fn instanceof Function)) {
throw('必须传一个函数作为参数')
}
let flag = true;
return function(...args) {
if (flag) {
flag = false;
console.log(1111)
fn.apply(this, args)
} else {
return;
}
setTimeout(
() => {
flag = true;
}, duration
)
}
}
const throttleFn = throttle(() => console.log('触发了滚动事件'));
document.addEventListener('scroll', throttleFn)
// 使用时间戳来判断节流的时间
const throttle1 = function (fn, duration = 300) {
if (!fn || !(fn instanceof Function)) {
throw('必须传一个函数作为参数')
}
let now = null;
return function(...args) {
const cur = new Date().getTime();
if (cur - now > duration) {
fn.apply(this, args)
now = cur;
}
}
}
// 防抖 事件停止触发后的 duraion后执行回调
const debounce = function (fn, duration = 300) {
if (!fn || !(fn instanceof Function)) {
throw('必须传一个函数作为参数')
}
let timeout = null;
return function(...args) {
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(() => {
fn.apply(this, args)
}, duration)
}
}
document.addEventListener('scroll', debounce(() => console.log('触发了滚动事件')))
可编程的性能上报方案(W3C 性能API)
W3C 规范为我们提供了 Performance 相关的接口。它允许我们获取到用户访问一个页面的每个阶段的精确时间,从而对性能进行分析。我们可以将其理解为 Performance 面板的进一步细化与可编程化。
// 性能API
window.performance
// 控制台可以看到以下属性
eventCounts: EventCounts {size: 36}
memory: MemoryInfo {totalJSHeapSize: 35927203, usedJSHeapSize: 33471483, jsHeapSizeLimit: 4294705152}
navigation: PerformanceNavigation {type: 0, redirectCount: 0}
onresourcetimingbufferfull: null
timeOrigin: 1605666468542.5789
timing: PerformanceTiming
connectEnd: 1605666468548
connectStart: 1605666468548
domComplete: 1605666468969
domContentLoadedEventEnd: 1605666468778
domContentLoadedEventStart: 1605666468778
domInteractive: 1605666468716
domLoading: 1605666468666
domainLookupEnd: 1605666468548
domainLookupStart: 1605666468548
fetchStart: 1605666468548
loadEventEnd: 1605666468969
loadEventStart: 1605666468969
navigationStart: 1605666468542
redirectEnd: 0
redirectStart: 0
requestStart: 1605666468563
responseEnd: 1605666468655
responseStart: 1605666468654
secureConnectionStart: 0
unloadEventEnd: 1605666468664
unloadEventStart: 1605666468663
__proto__: PerformanceTiming
__proto__: Performance
const timing = window.performance.timing
// DNS查询耗时
timing.domainLookupEnd - timing.domainLookupStart
// TCP连接耗时
timing.connectEnd - timing.connectStart
// 内容加载耗时
timing.responseEnd - timing.requestStart
// 关键性能指标
// firstbyte:首包时间
timing.responseStart – timing.domainLookupStart
// fpt:First Paint Time, 首次渲染时间 / 白屏时间
timing.responseEnd – timing.fetchStart
// tti:Time to Interact,首次可交互时间
timing.domInteractive – timing.fetchStart
// ready:HTML 加载完成时间,即 DOM 就位的时间
timing.domContentLoaded – timing.fetchStart
// load:页面完全加载时间
timing.loadEventStart – timing.fetchStart