前端性能优化小结
从输入 URL 到页面加载完成,发生了什么?
首先我们需要通过 DNS(域名解析系统)将 URL 解析为对应的 IP 地址,然后与这个 IP 地址确定的那台服务器建立起 TCP 网络连接,随后我们向服务端抛出我们的 HTTP 请求,服务端处理完我们的请求之后,把目标数据放在 HTTP 响应里返回给客户端,拿到响应数据的浏览器就可以开始走一个渲染的流程。渲染完毕,页面便呈现给了用户,并时刻等待响应用户的操作
性能优化本质就是在这5个方面进行调优
- DNS 解析
- TCP 连接
- HTTP 请求抛出
- 服务端处理请求,HTTP 响应返回
- 浏览器拿到响应数据,解析响应内容,把解析的结果展示给用户
网络
- Gzip
- 懒加载 + Tree Shaking
- 通过webpack-bundle-analyzer之类的打包辅助插件分析比较大的包,将其通过CDN或者固定资源站点加载
- 小图片资源通过Base64编码加载,比如不超过2KB的固定图片资源
TIP
DNS预解析/ TCP 协议的负载均衡 preload prefetch
存储
CDN
静态资源本身具有访问频率高、承接流量大的特点,因此静态资源加载速度始终是前端性能的一个非常关键的指标。CDN 是静态资源提速的重要手段,在许多一线的互联网公司,“静态资源走 CDN”并不是一个建议,而是一个规定。
强缓存
cache-control: max-age=31536000 expires: Wed, 11 Sep 2019 16:12:18 GMT
cache-control 的优先级更高。 max-age=31536000 表示资源会在 31536000 秒即一年后过期,需要再次请求。这个时间是相对的, 不同于expires的时间戳表示,能够实现更加精准的判断。 no-store与no-cache,为资源设置了 no-cache 后,每一次发起请求都不会再去询问浏览器的缓存情况,而是直接向服务端去确认该资源是否过期(相当于看是否要走协商缓存了)。no-store则是完全不缓存直接请求资源
协商缓存
- 协商缓存机制下,浏览器需要向服务器去询问缓存的相关信息,进而判断是重新发起请求、下载完整的响应,还是从本地获取缓存的资源
- 如果服务端提示缓存资源未改动(Not Modified),资源会被重定向到浏览器缓存,这种情况下网络请求对应的状态码是 304
Last-Modified
Last-Modified 是协商缓存的实现方式之一,主要是通过时间戳实现,服务端响应头返回Last-Modified字段
Last-Modified: Fri, 27 Oct 2017 06:35:57 GMT 随后每次请求都会带上一个If-Modified-Since的时间戳字段,这个字段的值就是上一次响应给的值 If-Modified-Since: Fri, 27 Oct 2017 06:35:57 GMT
时间戳还存在一些弊端,如果内容没有发生变化,或者修改的时间控制在了秒级,则无法感知到文件时间的变化。还有就是文件迁移时,Last-Modified的值没有发生变化,但是文件内容已经发生了变化,所以Last-Modified并不是很可靠
Etag
Etag和Last-Modified机制一致,不同的是它使用的是文件标识,可以随时感知到文件的变化,但会影响服务端的性能,Etag 的生成过程需要服务器额外付出开销。两者同时存在时Etag权重更高
ETag: W/"2a3b-1602480f459" If-None-Match: W/"2a3b-1602480f459"
本地储存
Local Storage还可以用来存储稳定的Base64小图片来提升加载速度,比如一些图标,这些图片一般不会经常变化,所以可以将其存储在本地,避免每次都去请求服务器。
IndexedDB方案(封装实现:localforage)
class HarexsIndexedDB {
constructor(dbName = "harexsDB", storeName = "harexsStore") {
this.dbName = dbName;
this.storeName = storeName;
this.db = null
this.initPromise = this.openDB();
}
openDB() {
return new Promise((resolve, reject) => {
// 第二个参数指定数据库版本, 如果版本发生变化就会触发onupgradeneeded事件
const request = indexedDB.open(this.dbName, 1);
request.onerror = (event) => {
console.error("Database error:", event.target.error);
reject(event.target.error);
};
request.onsuccess = (event) => {
this.db = event.target.result;
resolve();
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
// 数据库版本发生变化时,如果存储空间不存在则立即补上
if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName, { keyPath: "key" });
}
};
});
}
setItem(key, value) {
return this.initPromise.then(() => {
// transaction创建事务 存储空间键值、readwrite代表可读和写
const transaction = this.db.transaction(this.storeName, "readwrite");
const store = transaction.objectStore(this.storeName); // 得到事务对应的存储对象
const request = store.put({ key, value })
return new Promise((resolve, reject) => {
request.onsuccess = () => {
console.log(`Item set: ${key}`);
resolve();
};
request.onerror = (event) => {
console.error("Error setting item:", event.target.error);
reject(event.target.error);
};
});
});
}
getItem(key) {
return this.initPromise.then((resolve, rejct) => {
// 创建只读流并拿到对用存储对象
const transaction = this.db.transaction([this.storeName], "readonly");
const store = transaction.objectStore(this.storeName);
const request = store.get(key);
return new Promise((resolve, reject) => {
request.onsuccess = () => {
if (request.result) {
resolve(request.result.value);
} else {
resolve(null);
}
};
request.onerror = (event) => {
reject(event.target.error);
};
});
})
}
}
const myDB = new HarexsIndexedDB();
// 设置数据
myDB.setItem("username", "kimi").then(() => {
console.log("Item set successfully");
}).catch((error) => {
console.error("Error setting item:", error);
});
// 获取数据
myDB.getItem("username").then((value) => {
console.log("Retrieved value:", value);
}).catch((error) => {
console.error("Error getting item:", error);
});
渲染
服务端渲染本质上是本该浏览器做的事情,分担给服务器去做,减轻了浏览器的压力,加快了页面的响应速度。除非对于性能有比较高的要求,且服务器资源充足的情况下再考虑服务端渲染
浏览器渲染过程
- 解析HTML,生成DOM树
- 解析CSS,生成CSSOM树 (包括外部 CSS 文件和样式元素) (CSSOM 的解析过程与 DOM 的解析过程是并行的)
- 将DOM树和CSSOM树合并在一起生成渲染树(Render Tree)
- 根据渲染树来布局,计算每个节点的位置对应精确坐标,得到了基于渲染树的布局渲染树(Layout of the render tree)
- 将渲染树每个节点绘制到屏幕上(Painting the render tree)
每当一个新元素加入到这个 DOM 树当中,浏览器便会通过 CSS 引擎查遍 CSS 样式表,找到符合该元素的样式规则应用到这个元素上,然后再重新去绘制它
CSS优化
浏览器对于CSS的解析是从右到左的,尽量将影响范围小的选择器写在前面,影响范围大的写在后面,这样会加快CSS的解析速度
#myList li{} 从右到左,先找到所有li,再去找myList #myList .item{} 从右到左,先找到所有.item,再去找myList 如果是通过我们声明的类名查找则影响范围很小 .myList_item {} 再对其优化,尽可能减少需要匹配的范围
- {} 所以以前用这个通配符时可想而知遍历影响有多大,要遍历所有的页面元素
CSS的阻塞
当CSSOM树没完全解析完成时, 不会渲染任何已处理的内容即便DOM树已经完成
只要CSSOM树不OK,那么页面就不OK (这主要是为了避免没有 CSS 的 HTML 页面丑陋地“裸奔”在用户眼前)
CSS 是阻塞渲染的资源。需要将它尽早、尽快地下载到客户端,以便缩短首次渲染的时间。
- 将CSS放到head尽可能早的完成渲染
- 将稳定的CSS放到CDN提升加载速度
- 将非关键的CSS放到动态加载,减少对整个页面渲染的影响
JS的阻塞
JS 引擎是独立于渲染引擎存在的
由于浏览器是单线程的,所以当JS执行时,浏览器会停止渲染,将执行的渲染引擎交给JS引擎,等待JS执行完成后再继续渲染- 等 JS 引擎运行完毕,浏览器又会把控制权还给渲染引擎,继续 CSSOM 和 DOM 的构建
- 因此与其说是 JS 把 CSS 和 HTML 阻塞了,不如说是 JS 引擎抢走了渲染引擎的控制权
- 浏览器之所以让 JS 阻塞其它的活动,是因为它不知道 JS 会做什么改变,会造成混乱。但我们知道 JS 会做什么改变,假如可以确认一个 JS 文件的执行时机并不一定非要是此时此刻,我们就可以通过对它使用 defer 和 async 来避免不必要的阻塞
JS的三种加载模式
- 浏览器必须等待 index.js 加载和执行完毕
<script src="index.js"></script>
- JS 不会阻塞浏览器做任何其它的事情。它的加载是异步的,当它加载结束,JS 脚本会立即执行。
<script async src="index.js"></script>
- defer 模式下,JS 的加载是异步的,执行是被推迟的。等整个文档解析完成、DOMContentLoaded 触发之前,被放入一个队列中依次执行。
<script defer src="index.js"></script>
DOMContentLoaded代表DOM树构建完成,但图片、CSS等样式可能还没完成。onload则是完全加载完成
回流(重排)与重绘
- 回流:对 DOM 的修改引发了DOM几何尺寸的变化(宽、高、位置、隐藏显示),浏览器需要重新计算元素的几何属性再重新绘制
width、height、padding、margin、left、top、border 会改变尺寸、位置的属性 并且为了保证即时性,Js中的offsetTop、offsetLeft、 offsetWidth、offsetHeight、scrollTop、scrollLeft、scrollWidth、scrollHeight、clientTop、clientLeft、clientWidth、clientHeight、getComputedStyle 也会触发回流
- 重绘:对 DOM 的修改导致了样式的变化(颜色、背景、字体),浏览器不需要重新绘制元素的几何属性,而是为该元素绘制新的样式
- 重绘不一定导致回流,回流一定会导致重绘。 不管是哪一种,都是开销,尽可能的减少
DOM操作的优化核心,就是通过JS给DOM减压,比如给元素重复绘制一万个字符时,先通过JS将字符绘制完成,再一次性添加到页面中,而不是每绘制一个字符就添加到页面中,这样会大大减少回流和重绘的次数 这个思路在DodumentFragment中有体现,DocumentFragment会创建一个实际没被渲染的Document片段,直到DocumentFragment被实际挂载。这一点在Vue、JQ中也有体现
合并重复操作
// 缓存offsetLeft与offsetTop的值
const el = document.getElementById('el')
let offLeft = el.offsetLeft, offTop = el.offsetTop
// 在JS层面进行计算
for(let i=0;i<10;i++) {
offLeft += 10
offTop += 10
}
// 一次性将计算结果应用到DOM上
el.style.left = offLeft + "px"
el.style.top = offTop + "px"
合并样式添加
const container = document.getElementById('container')
container.style.width = '100px'
container.style.height = '200px'
container.style.border = '10px solid red'
container.style.color = 'red'
// 改为类名切换
<style>
.basic_style {
width: 100px;
height: 200px;
border: 10px solid red;
color: red;
}
</style>
const container = document.getElementById('container')
container.classList.add('basic_style')
// 或者用 cssText
container.cssText = `.....`
DOM离线化
核心就是display: none
,让浏览器暂时不渲染这个元素,等到操作完成后再显示,本质就是将多次的渲染变为一次。
let container = document.getElementById('container')
container.style.display = 'none'
container.style.width = '100px'
container.style.height = '200px'
container.style.border = '10px solid red'
container.style.color = 'red'
...(省略了许多类似的后续操作)
container.style.display = 'block'
浏览器更新策略
事件循环中的异步队列有两种:
- macro(宏任务)队列
setTimeout、setInterval、 setImmediate、script(整体代码)、 I/O 操作、UI 渲染
- micro(微任务)队列
process.nextTick、Promise、MutationObserver、async/await
事件循环过程:
- 调用栈空。micro 队列空,macro 队列里有且只有一个 script 脚本(整体代码),全局上下文(script 标签)被推入调用栈,同步代码执行
- 在执行的过程中,通过对一些接口的调用,可以产生新的 macro-task 与 micro-task,它们会分别被推入各自的任务队列里。同步代码执行完了,script 脚本会被移出 macro 队列,这个过程本质上是队列的 macro-task 的执行和出队的过程
- script 脚本(macro-task)执行后,开始micro-task队列任务。 (需要注意macro-task是一个一个执行的,而到micro-task时则是一队一队执行的,即清空当前的micro-task队列)
- micro-task队列清空后,检查调用栈是否为空,如果为空,就开始执行渲染操作,然后GUI线程接管渲染流程开始渲染
- 渲染完毕后,检查是否存在 Web worker 任务,如果有,则对其进行处理,最后JS线程继续接管,开始下一个循环。
Vue将DOM更新放在微任务的本质
- 当我们需要在异步任务中实现DOM更新时,把它包装成 micro 任务是相对明智的选择
- 而将DOM更新放到异步中实现,本质是异步更新可以帮助我们避免过度渲染,让 JS 为 DOM 分压
- 异步更新的特性在于它只看结果,因此渲染引擎不需要为过程买单
// 任务一
this.content = '第一次测试'
// 任务二
this.content = '第二次测试'
// 任务三
this.content = '第三次测试'
如果我们将上面的代码放在异步更新中,那么实际DOM更新时它只关心content的最终结果,因为我们在JS中已经将多次修改的结果应用完成了(批量更新). 如果是传统的DOM更新,那么则是要发生了三次DOM的修改操作
宏任务更新DOM和微任务更新DOM
如果我们将DOM修改放在宏任务(setTimout)中, 先将主进程的脚本代码执行后, 此时DOM修改的代码被推入到下一次才执行的宏任务中, 而当本次宏任务执行完,马上就要执行micro队列,再往下才执行render, 而这一次render却并没有应用DOM修改的,因为要到下一次循环才会执行到新的宏任务 如果我们将这一次DOM修改放到微任务中, 那么在本次宏任务执行完,马上就要执行micro队列,而微任务中DOM修改的代码,会立即被执行应用到了本次DOM更新中
Vue的状态更新
nextTick
export function nextTick (cb?: Function, ctx?: Object) {
let _resolve
callbacks.push(() => {
if (cb) {
try {
cb.call(ctx)
} catch (e) {
handleError(e, ctx, 'nextTick')
}
} else if (_resolve) {
_resolve(ctx)
}
})
// 检查上一个异步任务队列(即名为callbacks的任务数组)是否派发和执行完毕了。pending此处相当于一个锁
if (!pending) {
// 若上一个异步任务队列已经执行完毕,则将pending设定为true(把锁锁上)
pending = true
// 是否要求一定要派发为macro任务
if (useMacroTask) {
macroTimerFunc()
} else {
// 如果不说明一定要macro 你们就全都是micro
microTimerFunc()
}
}
// $flow-disable-line
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}
macroTimeFunc
// macro首选setImmediate 这个兼容性最差
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
macroTimerFunc = () => {
setImmediate(flushCallbacks)
}
} else if (typeof MessageChannel !== 'undefined' && (
isNative(MessageChannel) ||
// PhantomJS
MessageChannel.toString() === '[object MessageChannelConstructor]'
)) {
const channel = new MessageChannel()
const port = channel.port2
channel.port1.onmessage = flushCallbacks
macroTimerFunc = () => {
port.postMessage(1)
}
} else {
// 兼容性最好的派发方式是setTimeout
macroTimerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}
microTimeFunc
// 简单粗暴 不是ios全都给我去Promise 如果不兼容promise 那么你只能将就一下变成macro了
if (typeof Promise !== 'undefined' && isNative(Promise)) {
const p = Promise.resolve()
microTimerFunc = () => {
p.then(flushCallbacks)
// in problematic UIWebViews, Promise.then doesn't completely break, but
// it can get stuck in a weird state where callbacks are pushed into the
// microtask queue but the queue isn't being flushed, until the browser
// needs to do some other work, e.g. handle a timer. Therefore we can
// "force" the microtask queue to be flushed by adding an empty timer.
if (isIOS) setTimeout(noop)
}
} else {
// 如果无法派发micro,就退而求其次派发为macro
microTimerFunc = macroTimerFunc
}
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 队列)和执行。
应用
懒加载
比如大量的图片加载, 当用户界面的视口还没到达对应位置时,此时图片的渲染时不必要的。比如掘金中,会将图片先放在data-src中,并将元素背景设置为background-image:none;
。当视口到达时才进行展示
实现
这里也可以选择用IntersectionObserver实现
// 获取所有的图片标签
const imgs = document.getElementsByTagName('img')
// 获取可视区域的高度
const viewHeight = window.innerHeight || document.documentElement.clientHeight
// num用于统计当前显示到了哪一张图片,避免每次都从第一张图片开始检查是否露出
let num = 0
function lazyload(){
for(let i=num; i<imgs.length; i++) {
// 用可视区域高度减去元素顶部距离可视区域顶部的高度
let distance = viewHeight - imgs[i].getBoundingClientRect().top
// 如果可视区域高度大于等于元素顶部距离可视区域顶部的高度,说明元素露出
if(distance >= 0 ){
// 给元素写入真实的src,展示图片
imgs[i].src = imgs[i].getAttribute('data-src')
// 前i张图片已经加载完毕,下次从第i+1张开始检查是否露出
num = i + 1
}
}
}
// 监听Scroll事件
window.addEventListener('scroll', lazyload, false);
节流和防抖
1.节流Throttle: 在某段时间内,不管你触发了多少次回调,我都只认第一次,并在计时结束时给予响应(控制触发频率)
// fn是我们需要包装的事件回调, interval是时间间隔的阈值
function throttle(fn, interval) {
// last为上一次触发回调的时间
let last = 0
// 将throttle处理结果当作函数返回
return function () {
// 保留调用时的this上下文
let context = this
// 保留调用时传入的参数
let args = arguments
// 记录本次触发回调的时间
let now = +new Date()
// 判断上次触发的时间和本次触发的时间差是否小于时间间隔的阈值
if (now - last >= interval) {
// 如果时间间隔大于我们设定的时间间隔阈值,则执行回调
last = now;
fn.apply(context, args);
}
}
}
// 用throttle来包装scroll的回调
const better_scroll = throttle(() => console.log('触发了滚动事件'), 1000)
document.addEventListener('scroll', better_scroll)
- 防抖Debounce: 不管你触发了多少次回调,我都只认最后一次(控制触发次数)
// fn是我们需要包装的事件回调, delay是每次推迟执行的等待时间
function debounce(fn, delay) {
// 定时器
let timer = null
// 将debounce处理结果当作函数返回
return function () {
// 保留调用时的this上下文
let context = this
// 保留调用时传入的参数
let args = arguments
// 每次事件被触发时,都去清除之前的旧定时器
if(timer) {
clearTimeout(timer)
}
// 设立新定时器
timer = setTimeout(function () {
fn.apply(context, args)
}, delay)
}
}
// 用debounce来包装scroll的回调
const better_scroll = debounce(() => console.log('触发了滚动事件'), 1000)
document.addEventListener('scroll', better_scroll)
- 结合,无论用户触发多少次, 再一定的间隔后一定会给用户响应一次,为了避免用户无限制触发以导致产生了卡死的错觉
// fn是我们需要包装的事件回调, delay是时间间隔的阈值
function throttle(fn, delay) {
// last为上一次触发回调的时间, timer是定时器
let last = 0, timer = null
// 将throttle处理结果当作函数返回
return function () {
// 保留调用时的this上下文
let context = this
// 保留调用时传入的参数
let args = arguments
// 记录本次触发回调的时间
let now = +new Date()
// 判断上次触发的时间和本次触发的时间差是否小于时间间隔的阈值
if (now - last < delay) {
// 如果时间间隔小于我们设定的时间间隔阈值,则为本次触发操作设立一个新的定时器
clearTimeout(timer)
timer = setTimeout(function () {
last = now
fn.apply(context, args)
}, delay)
} else {
// 如果时间间隔超出了我们设定的时间间隔阈值,那就不等了,无论如何要反馈给用户一次响应
last = now
fn.apply(context, args)
}
}
}
// 用新的throttle包装scroll的回调
const better_scroll = throttle(() => console.log('触发了滚动事件'), 1000)
document.addEventListener('scroll', better_scroll)
性能检测
Lighthouse
直接通过DevTools面板提供的Lighthouse工具使用 或者Cli调用
npm install -g lighthouse lighthouse https://juejin.cn/books
Performance API
时间各项指标
window.performance.timing
关键性能指标
// 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