前言:算是修言大佬【前端性能优化原理与实践】的学习笔记,请多支持修言大佬原著,如有侵权请联系删除。
前端性能优化
简介,性能优化可以体现在许多方面,往往是这里优化一些,那里优化一些实现整体的性能提升
整体可以从一条经典的面试题体现出来
从输入 URL 到页面加载完成,发生了什么?
- 浏览器对url进行DNS解析
- 建立TCP连接
- 发送HTTP请求
- 服务端拿到请求,HTTP响应返回
- 浏览器拿到响应,解析响应内容,渲染返回结果
网络层优化
DNS、TCP连接优化
前端在这两个地方能优化的地方较少
- 提前进行DNS解析,预请求
- TCP 轮训的接口改为 webSocket
通过webpack 对 Http进行性能优化
1. 将 loader 由单进程转为多进程
Happypack 会充分释放 CPU 在多核并发方面的优势,帮我们把任务分解给多个子进程去并发执行,大大提升打包效率。
2. 拆分资源
3. tree-shaking 删除冗余代码
在打包的时候把无用的代码进行删除。 通过引用关系树,如果文件没用引用,就不会打包进去。
旧版本需要引用
uglifyjs-webpack-plugin插件, webpack4后成为了默认配置。
4. 按需加载
所谓按需加载,根本上就是在正确的时机去触发相应的回调。
- 一次不加载完所有的文件内容,只加载此刻需要用到的那部分(会提前做拆分)
- 当需要更多内容时,再对用到的内容进行即时加载
5. 开启gzip
request header 里加上 accept-encoding:gzip
gzip的实现原理,是在一个文本文件中找出一些重复出现的字符串、临时替换它们,从而使整个文件变小。
CDN优化 (Content Delivery Network,即内容分发网络)
这些服务器存储着数据的副本,因此服务器可以根据哪些服务器与用户距离最近,来满足数据的请求。 CDN 提供快速服务,较少受高流量影响。
静态资源(图片) 或 js、css、html等资源(第三方库) 使用CDN
- 静态资源一般不会变化,可以不通过访问根服务器也不会有影响
- 使用cdn可以就近选择服务器,从物理距离上降低传输时间
图片优化
各种图片的特点
每种图片格式都有自己的特点,应该按需使用。
jpg jpeg
有损压缩、体积小、加载快、不支持透明
应用场景:大背景图
png-8,png-24
无损压缩、质量高、体积大、支持透明
应用场景: 复杂的、色彩层次丰富的图片, logo图
svg 矢量图形
文本文件、体积小、不失真、兼容性好
应用场景: icon
base64
文本文件、依赖编码、小图标解决方案
应用场景: 小文件icon
webP
支持透明,细节丰富,支持动态 缺点: 浏览器兼容不好
图片懒加载
背景: 图片资源较大,请求时间较长,显示的时候由于视窗大小有限不会一次性展示所有图片 解决方法: 只展示视窗内图片,视窗外图片用占位div占位保证页面布局正常。 原理: img标签在获得src时才请求加载
window.addEventListener("scroll", () => {
const viewHeight = window.innerHeight;
const imgs = document.querySelectorAll("img");
imgs.forEach((img) => {
if (img.getAttribute('data-src')) return; // 添加过的跳过
const distance = viewHeight - img.getBoundingClientRect().top;
if (distance >= 0) {
img.src = img.setAttribute("data-src", '/pageUrl') // 出现在视窗的添夹src
}
})
})
存储
1. Memory Cache
存在内存中的缓存。从优先级上来说,它是浏览器最先尝试去命中的一种缓存。从效率上来说,它是响应速度最快的一种缓存。
2. Service Worker Cache
Service Worker 是一种独立于主线程之外的 Javascript 线程。它脱离于浏览器窗体,因此无法直接访问 DOM。
3. HTTP Cache http缓存
发送请求时,会先看有没有强缓存, 如果没有强缓存,则会看看有没有协商缓存,如果都没有,则向服务端发送请求。
强缓存
- 会根据其中的 expires 和 cache-control 判断目标资源是否“命中”强缓存,若命中则直接从缓存中获取资源,不会再与服务端发生通信。
cache-control: max-age=3600
expires: Wed, 11 Sep 2019 16:12:18 GMT
Cache-Control 的 max-age 配置项相对于 expires 的优先级更高。当 Cache-Control 与 expires 同时出现时,我们以 Cache-Control 为准。
- no-store与no-cache no-cache 绕开了浏览器:我们为资源设置了 no-cache 后,每一次发起请求都不会再去询问浏览器的缓存情况,而是直接向服务端去确认该资源是否过期(即走我们下文即将讲解的协商缓存的路线)。
no-store 比较绝情,顾名思义就是不使用任何缓存策略。在 no-cache 的基础上,它连服务端的缓存确认也绕开了,只允许你直接向服务端发送请求、并下载完整的响应。
协商缓存 从 Last-Modified 到 Etag
Last-Modified 是一个时间戳,如果我们启用了协商缓存,它会在首次请求时随着 Response Headers 返回
Last-Modified: Fri, 27 Oct 2017 06:35:57 GMT
随后我们每次请求时,会带上一个叫 If-Modified-Since 的时间戳字段,它的值正是上一次 response 返回给它的 last-modified 值:
如果一致,则启用协商缓存,返回304。 如果不一致,则返回完整请求,更新last-Modified
当我们修改文件的速度过快时(比如花了 100ms 完成了改动),由于 If-Modified-Since 只能检查到以秒为最小计量单位的时间差,所以它是感知不到这个改动的——该重新请求的时候,反而没有重新请求了。
Etag Etag 是由服务器为每个资源生成的唯一的标识字符串,这个标识字符串是基于文件内容编码的,只要文件内容不同,它们对应的 Etag 就是不同的
4. Push Cache
push Cache 是指 HTTP2 在 server push 阶段存在的缓存。
- Push Cache 是缓存的最后一道防线。浏览器只有在 Memory Cache、HTTP Cache 和 Service Worker Cache 均未命中的情况下才会去询问 Push Cache。
- Push Cache 是一种存在于会话阶段的缓存,当 session 终止时,缓存也随之释放。
- 不同的页面只要共享了同一个 HTTP2 连接,那么它们就可以共享同一个 Push Cache。
渲染优化
浏览器渲染
- 解析HTML文件
- 解析CSS文件
- CSS 与 Dom树结合,生成render树
:after :before 这样的伪元素会在这个环节被构建到 DOM 树中。
- 计算图层布局, 元素的相对位置信息,大小等信息 (Layout of the render tree)
- 绘制图层 (每一个页面图层转换为像素,并对所有的媒体文件进行解码 Painting the render tree)
CSS 和 JS 加载顺序优化
CSS阻塞优化
我们开始解析 HTML 后、解析到 link 标签或者 style 标签时,CSS 才登场,CSSOM 的构建才开始。很多时候,DOM 不得不等待 CSSOM
CSS 是阻塞渲染的资源。需要将它尽早、尽快地下载到客户端,以便缩短首次渲染的时间。
解决方法
- 尽早(将 CSS 放在 head 标签里)
- 尽快(启用 CDN 实现静态资源加载速度的优化)
JS阻塞
浏览器之所以让 JS 阻塞其它的活动,是因为它不知道 JS 会做什么改变,担心如果不阻止后续的操作,会造成混乱。
js执行的三种模式
- 正常模式 阻塞浏览器,加载执行完才执行其他操作
async模式 不会阻塞浏览器, 加载结束,JS 脚本会立即执行。defer模式 不会阻塞浏览器, 加载是异步的,执行是被推迟的,等整个文档解析完成,被标记了 defer 的 JS 文件才会开始依次执行。
回流(reflow) 与 重绘(repaint)
- 回流: 当我们对 DOM 的修改引发了 DOM 几何尺寸的变化(比如修改元素的宽、高或隐藏元素等)时,浏览器需要重新计算元素的几何属性(其他元素的几何属性和位置也会因此受到影响),然后再将计算的结果绘制出来。
- 重绘:当我们对 DOM 的修改导致了样式的变化、却并未影响其几何属性(比如修改了颜色或背景色)时,浏览器不需重新计算元素的几何属性、直接为该元素绘制新的样式(跳过了上图所示的回流环节)。这个过程叫做重绘
回流必定发生重绘!
优化方法: 分离js 和 dom 操作。多次进行的DOM操作修改时,一次赋值,防止多次进行回流与重绘。
导致回流的因素
- 元素几何属性的修改 width、height、padding、margin、left、top、border 等等。
- 改变dom树的结构 节点的增减、移动等操作。 (由于是树结构,子节点不会影响父节点)。
- 获取一些特定属性的值
[offset\scroll\client][Top\Left\Height\Width]浏览器为了获取需要** 即时计算** 的属性。也会进行回流。
优化方式:
1. 多次获取改为一次获取
for(let i=0;i<10;i++) {
el.style.top = el.offsetTop + 10 + "px";
el.style.left = el.offsetLeft + 10 + "px";
}
const el = document.getElementById('el')
let offLeft = el.offsetLeft, offTop = el.offsetTop
// 在JS层面进行计算
for(let i=0;i<10;i++) {
offLeft += 10
offTop += 10
}
2. 将dom离线
3. 需要多次修改的变成单次修改 (chrome已自动做了优化合成为了一次)
// bad
const container = document.getElementById('container')
container.style.width = '100px'
container.style.height = '200px'
// good 将dom离线
const container = document.getElementById('container')
container.style.display = 'none'
container.style.width = '100px'
container.style.height = '200px'
// ... 省略一系列操作
container.style.display = 'block'
// good 需要多次修改的变成单次修改
// js
const container = document.getElementById('container')
container.classList.add('basic_style')
// css
.basic_style {
width: 100px;
height: 200px;
}
DocumentFragment 优化
DocumentFragment 接口表示一个没有父级文件的最小文档对象。它被当做一个轻量版的 Document 使用,用于存储已排好版的或尚未打理好格式的XML片段。因为 DocumentFragment 不是真实 DOM 树的一部分,它的变化不会引起 DOM 树的重新渲染的操作(reflow),且不会导致性能等问题。
最常用的方法是使用 DocumentFragment 创建并组成一个 DOM 子树,然后使用 Node 接口方法将其插入到 DOM 中。这种情况下会插入片段的所有子节点,并留下一个空的 DocumentFragment。
防抖与节流
上文中 监听 scroll 滚轮一滚会触发许多次造成性能浪费,这时候就需要防抖和节流
防抖
一定时间内重复触发的事件只执行一次
function debounce(cb, immediate = true, wait = 500) {
let timer = null;
return function(...params) {
timer && clearTimeout(timer);
immediate && timer && cb.call(this, ...args);
timer = setTimeout(() => {
cb(this, ...args)
}, wait);
}
}
节流
限制一定时间内只能触发一次事件
function throttle(cb, immediate = true, wait = 1000) {
let timer = null;
let runnow = immediate;
return function (...params) {
if (runnow) {
cb.call(this, ...params)
runnow = false;
timer = setTimeout(() => {
clearTimeout(timer);
timer = null;
}, wait);
}
if (!timer) {
timer = setTimeout(() => {
cb.call(this, ...params)
clearTimeout(timer);
timer = null;
}, wait);
}
}
}
EventLoop 事件循环
Macro-task 和 MicroTask
Macro-Task 宏任务
- settimeout
- setinterval
- script整体代码
- i/o 操作
- UI渲染
Micro-Task 微任务
- Promise
- MutationObserver
一个完整的EventLoop流程
- 初始状态,Macro-task 和Micro-task 队列为空
- 宏任务依次执行,直至当前队列执行完,再执行微任务。script代码执行中有新的宏任务创建,会被推至下一个macro队列。
- 微任务队列依次执行。如果微任务中出现新的微任务,会被放在当前微任务队列的下一个队列。
- 执行渲染操作,更新界面(敲黑板划重点)。
- 检查是否存在 Web worker 任务,如果有,则对其进行处理 。
- 循环以上步骤,直至两个队列清空
注意! 宏任务为一个一个执行,微任务为一队一队执行。 如宏任务中创建宏任务,会执行完微任务队列再执行宏任务,而微任务中创建微任务,会执行完创建的微任务再执行下一轮宏任务。
console.log(1)
setTimeout(() => {
console.log(2)
setTimeout(() => {
console.log(5)
}, 0);
Promise.resolve().then(() => {
Promise.resolve().then(() => {
console.log(4)
})
console.log(3)
})
}, 0);
Promise.resolve().then(() => {
console.log(1.5)
})
vue - 异步更新策略
背景:比如三个任务中都改了同一个字段,dom就要修改三次,但实际上我们只需要最后一次的修改。
Vue 每次想要更新一个状态的时候,会先把它这个更新操作给包装成一个异步操作派发出去。
vue把响应式的更新都放到一个nextTick中,在下一个nextTick统一执行。
export function nextTick (cb?: Function, ctx?: Object) {
let _resolve
callbacks.push(() => {
cb.call(ctx); // 省略了一些判空
})
// 检查上一个异步任务队列(即名为callbacks的任务数组)是否派发和执行完毕了。pending此处相当于一个锁
if (!pending) {
// 若上一个异步任务队列已经执行完毕,则将pending设定为true(把锁锁上)
pending = true
// 是否要求一定要派发为macro任务
if (useMacroTask) {
macroTimerFunc()
} else {
// 如果不说明一定要macro 你们就全都是micro
microTimerFunc()
}
}
// ... 省略一些初始化判断代码
}
microTimeFunc() 是这么实现的:
if (typeof Promise !== 'undefined' && isNative(Promise)) {
const p = Promise.resolve()
microTimerFunc = () => {
p.then(flushCallbacks)
}
} else {
// 如果无法派发micro,就退而求其次派发为macro
microTimerFunc = macroTimerFunc
}
mamroTimeFunc类似,只不过使用的是setImmediate、setTimeout
都添加到微任务队列后,最后会回调FlushCallBacks()
function flushCallbacks () {
pending = false
// callbacks在nextick中出现过 它是任务数组(队列)
const copies = callbacks.slice(0)
callbacks.length = 0
// 将callbacks中的任务逐个取出执行
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}
现在我们理清楚了:Vue 中每产生一个状态更新任务,它就会被塞进一个叫 callbacks 的数组(此处是任务队列的实现形式)中。这个任务队列在被丢进 micro 或 macro 队列之前,会先去检查当前是否有异步更新任务正在执行(即检查 pending 锁)。如果确认 pending 锁是开着的(false),就把它设置为锁上(true),然后对当前 callbacks 数组的任务进行派发(丢进 micro 或 macro 队列)和执行。设置 pending 锁的意义在于保证状态更新任务的有序进行,避免发生混乱。
其中
nextTick函数采用优雅降级的方法,先判断是否能使用 ES6的Promise,最后再使用setTimeout。(能使用微任务就使用微任务)