Skip to content

前端性能优化小结

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

首先我们需要通过 DNS(域名解析系统)将 URL 解析为对应的 IP 地址,然后与这个 IP 地址确定的那台服务器建立起 TCP 网络连接,随后我们向服务端抛出我们的 HTTP 请求,服务端处理完我们的请求之后,把目标数据放在 HTTP 响应里返回给客户端,拿到响应数据的浏览器就可以开始走一个渲染的流程。渲染完毕,页面便呈现给了用户,并时刻等待响应用户的操作

性能优化本质就是在这5个方面进行调优

  1. DNS 解析
  2. TCP 连接
  3. HTTP 请求抛出
  4. 服务端处理请求,HTTP 响应返回
  5. 浏览器拿到响应数据,解析响应内容,把解析的结果展示给用户

网络

  1. Gzip
  2. 懒加载 + Tree Shaking
  3. 通过webpack-bundle-analyzer之类的打包辅助插件分析比较大的包,将其通过CDN或者固定资源站点加载
  4. 小图片资源通过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则是完全不缓存直接请求资源

协商缓存

  1. 协商缓存机制下,浏览器需要向服务器去询问缓存的相关信息,进而判断是重新发起请求、下载完整的响应,还是从本地获取缓存的资源
  2. 如果服务端提示缓存资源未改动(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)

javascript
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);
        });

渲染

服务端渲染本质上是本该浏览器做的事情,分担给服务器去做,减轻了浏览器的压力,加快了页面的响应速度。除非对于性能有比较高的要求,且服务器资源充足的情况下再考虑服务端渲染

浏览器渲染过程

  1. 解析HTML,生成DOM树
  2. 解析CSS,生成CSSOM树 (包括外部 CSS 文件和样式元素) (CSSOM 的解析过程与 DOM 的解析过程是并行的)
  3. 将DOM树和CSSOM树合并在一起生成渲染树(Render Tree)
  4. 根据渲染树来布局,计算每个节点的位置对应精确坐标,得到了基于渲染树的布局渲染树(Layout of the render tree)
  5. 将渲染树每个节点绘制到屏幕上(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 是阻塞渲染的资源。需要将它尽早、尽快地下载到客户端,以便缩短首次渲染的时间。

  1. 将CSS放到head尽可能早的完成渲染
  2. 将稳定的CSS放到CDN提升加载速度
  3. 将非关键的CSS放到动态加载,减少对整个页面渲染的影响

JS的阻塞

  1. JS 引擎是独立于渲染引擎存在的 由于浏览器是单线程的,所以当JS执行时,浏览器会停止渲染,将执行的渲染引擎交给JS引擎,等待JS执行完成后再继续渲染
  2. 等 JS 引擎运行完毕,浏览器又会把控制权还给渲染引擎,继续 CSSOM 和 DOM 的构建
  3. 因此与其说是 JS 把 CSS 和 HTML 阻塞了,不如说是 JS 引擎抢走了渲染引擎的控制权
  4. 浏览器之所以让 JS 阻塞其它的活动,是因为它不知道 JS 会做什么改变,会造成混乱。但我们知道 JS 会做什么改变,假如可以确认一个 JS 文件的执行时机并不一定非要是此时此刻,我们就可以通过对它使用 defer 和 async 来避免不必要的阻塞

JS的三种加载模式

  1. 浏览器必须等待 index.js 加载和执行完毕
javascript
<script src="index.js"></script>
  1. JS 不会阻塞浏览器做任何其它的事情。它的加载是异步的,当它加载结束,JS 脚本会立即执行。
javascript
<script async src="index.js"></script>
  1. defer 模式下,JS 的加载是异步的,执行是被推迟的。等整个文档解析完成、DOMContentLoaded 触发之前,被放入一个队列中依次执行。
javascript
<script defer src="index.js"></script>

DOMContentLoaded代表DOM树构建完成,但图片、CSS等样式可能还没完成。onload则是完全加载完成

回流(重排)与重绘

  1. 回流:对 DOM 的修改引发了DOM几何尺寸的变化(宽、高、位置、隐藏显示),浏览器需要重新计算元素的几何属性再重新绘制

width、height、padding、margin、left、top、border 会改变尺寸、位置的属性 并且为了保证即时性,Js中的offsetTop、offsetLeft、 offsetWidth、offsetHeight、scrollTop、scrollLeft、scrollWidth、scrollHeight、clientTop、clientLeft、clientWidth、clientHeight、getComputedStyle 也会触发回流

  1. 重绘:对 DOM 的修改导致了样式的变化(颜色、背景、字体),浏览器不需要重新绘制元素的几何属性,而是为该元素绘制新的样式
  2. 重绘不一定导致回流,回流一定会导致重绘。 不管是哪一种,都是开销,尽可能的减少

DOM操作的优化核心,就是通过JS给DOM减压,比如给元素重复绘制一万个字符时,先通过JS将字符绘制完成,再一次性添加到页面中,而不是每绘制一个字符就添加到页面中,这样会大大减少回流和重绘的次数 这个思路在DodumentFragment中有体现,DocumentFragment会创建一个实际没被渲染的Document片段,直到DocumentFragment被实际挂载。这一点在Vue、JQ中也有体现

合并重复操作

javascript
// 缓存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"

合并样式添加

javascript
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,让浏览器暂时不渲染这个元素,等到操作完成后再显示,本质就是将多次的渲染变为一次。

javascript
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'

浏览器更新策略

事件循环中的异步队列有两种:

  1. macro(宏任务)队列

setTimeout、setInterval、 setImmediate、script(整体代码)、 I/O 操作、UI 渲染

  1. micro(微任务)队列

process.nextTick、Promise、MutationObserver、async/await

事件循环过程:

  1. 调用栈空。micro 队列空,macro 队列里有且只有一个 script 脚本(整体代码),全局上下文(script 标签)被推入调用栈,同步代码执行
  2. 在执行的过程中,通过对一些接口的调用,可以产生新的 macro-task 与 micro-task,它们会分别被推入各自的任务队列里。同步代码执行完了,script 脚本会被移出 macro 队列,这个过程本质上是队列的 macro-task 的执行和出队的过程
  3. script 脚本(macro-task)执行后,开始micro-task队列任务。 (需要注意macro-task是一个一个执行的,而到micro-task时则是一队一队执行的,即清空当前的micro-task队列)
  4. micro-task队列清空后,检查调用栈是否为空,如果为空,就开始执行渲染操作,然后GUI线程接管渲染流程开始渲染
  5. 渲染完毕后,检查是否存在 Web worker 任务,如果有,则对其进行处理,最后JS线程继续接管,开始下一个循环。

Vue将DOM更新放在微任务的本质

  1. 当我们需要在异步任务中实现DOM更新时,把它包装成 micro 任务是相对明智的选择
  2. 而将DOM更新放到异步中实现,本质是异步更新可以帮助我们避免过度渲染,让 JS 为 DOM 分压
  3. 异步更新的特性在于它只看结果,因此渲染引擎不需要为过程买单
javascript
// 任务一
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

javascript
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

javascript
// 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

javascript
// 简单粗暴 不是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

javascript
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]()
  }
}

总结

  1. Vue 中每产生一个状态更新任务,它就会被塞进一个叫 callbacks 的数组(此处是任务队列的实现形式)
  2. 在被丢进 micro 或 macro 队列之前,会先去检查当前是否有异步更新任务正在执行(即检查 pending 锁)
  3. 如果确认 pending 锁是开着的(false),就把它设置为锁上(true),然后对当前 callbacks 数组的任务进行派发(丢进 micro 或 macro 队列)和执行。

应用

懒加载

比如大量的图片加载, 当用户界面的视口还没到达对应位置时,此时图片的渲染时不必要的。比如掘金中,会将图片先放在data-src中,并将元素背景设置为background-image:none;。当视口到达时才进行展示

实现

这里也可以选择用IntersectionObserver实现

javascript
// 获取所有的图片标签
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: 在某段时间内,不管你触发了多少次回调,我都只认第一次,并在计时结束时给予响应(控制触发频率)

javascript
// 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)
  1. 防抖Debounce: 不管你触发了多少次回调,我都只认最后一次(控制触发次数)
javascript
// 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)
  1. 结合,无论用户触发多少次, 再一定的间隔后一定会给用户响应一次,为了避免用户无限制触发以导致产生了卡死的错觉
javascript
// 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

关键性能指标

javascript
// 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