前端性能优化(网络请求阶段、资源加载阶段、交互阶段)

2,140 阅读8分钟

性能优化分析

关于性能优化作为前端开发人员或多或少都可以说出一些来,但是你会发现总是这么没有条理。那么我们应该如何有条理的进行性能优化分析呢?

本文将从用户输入一个URL请求一个页面来分析整个过程,看有哪里是非常耗费性能的,然后在针对性的去做优化。

  1. 浏览器发送一个请求
  2. DNS 解析
  3. 建立 TLS 连接(HTTPS协议)
  4. 建立 TCP 连接(TCP三次握手)(浏览器限制同一域名只能建立6个连接,如果前面已建立6个连接则进入队列等待)
  5. 连接建立之后,浏览器端会构建请求行、请求头等信息,并把和该域名相关的 Cookie 等数据附加到请求头中,然后向服务器发送构建的请求信息。
  6. 服务器进行解析请求
    • 如果响应 301,302 则重定向(浏览器需要根据请求头的Location字段重新发送请求)
    • 如果响应 200 ,浏览器就开始解析获取到的资源(假设资源是:index.html)
  7. 浏览器由上至下顺序分析 HTML 开始构建 DOM Tree
  8. 解析到样式标签 <link href="foo.css" rel="stylesheet"> 会去下载样式文件并转换成CSSDOM
  9. 解析到脚本标签<script src="foo.js"></script> 会去下载脚本并执行脚本,这个过程会造成阻塞
  10. 解析到图片标签<img src="foo.jpg" /> 会去下载相关图片(还有音频视频等资源,这里就只以图片资源为例子)
  11. 浏览器根据生成好的 DOMTree 与 CSSDOM 开始布局,生成LayoutTree
  12. 根据LayoutTree进行分层(z-index的样式层级不同),生成LayerTree
  13. 根据LayerTree生成绘制列表后再进行栅格化操作最后生成位图显示到屏幕
  14. 最后就是用户与网页交互了

从耗费性能的角度我们把这个过程分为3个阶段

  1. 网络阶段
  2. 资源加载阶段
  3. 用户交互阶段

网络阶段

  • DNS 解析
  • TCP 连接
  • HTTP 请求/响应

对于DNS解析以及TCP连接前端能做的事情太少了,因此我们把精力专注于HTTP优化。涉及到HTTP优化往往从两个方面入手。

  1. 尽量避免重定向
  2. 减少单次请求所花费的时间

尽量避免重定向

很明显经历了一系列操作,你又告诉我要重定向,那么又得重新开始发起一系列请求,所以应该尽量避免。

减少单次请求所花费的时间

1、减小 Cookie 的体积

我们知道给服务器每发送一个请求都会带上相关Cookie信息,因此应该尽量保持Cookie足够简洁

2、服务端开启 Gzip 压缩

Gzip压缩算法可以大大降低文件大小

3、使用 CDN

CDN全称是Content Delivery Network,即内容分发网络,它能够实时地根据网络流量和各节点的连接、负载状况以及到用户的距离和响应时间等综合信息将用户的请求重新导向离用户最近的服务节点上。其目的是使用户可就近取得所需内容,解决 Internet网络拥挤的状况,提高用户访问网站的响应速度

CDN 的核心点有两个,一个是缓存,一个是回源。

这两个概念都非常好理解。“缓存”就是说我们把资源 copy 一份到 CDN 服务器上这个过程,“回源”就是说 CDN 发现自己没有这个资源(一般是缓存的数据过期了),转头向根服务器(或者它的上层服务器)去要这个资源的过程。

使用CDN带来的好处:

  • 我们可以访问离我们物理距离最近的资源服务器,缩短访问链路;
  • CDN本身就已经有一套缓存策略来提供访问速度;
  • 静态资源通过部署到不同CDN服务下,可以突破浏览器的连接数量限制;
  • 由于CDN服务器的域名与本域名不同,那么访问也自然不会带上本域名的Cookie。

4、使用 service work

可以把 Service Worker 理解为一个介于客户端和服务器之间的一个代理服务器。在 Service Worker 中我们可以做很多事情,比如拦截客户端的请求、向客户端发送消息、向服务器发起请求等等,其中最重要的作用之一就是缓存资源。

目前的 Chrome 架构中,Service Worker 是运行在浏览器进程(主进程)中的,因为浏览器进程生命周期是最长的,所以在浏览器的生命周期内,能够为所有的页面提供服务。

Service Worker 只能被使用在 https 或者本地的 localhost 环境下。

Workbox 3封装好了 Service Worker 底层的API,方便易懂,下面是使用Workbox 3编写的缓存策略

// 首先引入 Workbox 框架
importScripts('https://storage.googleapis.com/workbox-cdn/releases/3.3.0/workbox-sw.js');

// 如果你有一些静态资源是需要永远的离线缓存,除非重新上线才更新缓存的话,那 precache 预缓存应该是你所期待的
workbox.precaching.preacheAndRoute([
    '/styles/index.0c9a31.css',
    '/scripts/main.0d5770.js',
    {
        url: '/index.html',
        revision: '383676'
    },
]);

// html的缓存策略
workbox.routing.registerRoute(
  new RegExp('.*\.html'),
  workbox.strategies.networkFirst()
);

// js css 缓存策略
workbox.routing.registerRoute(
  new RegExp('.*\.(?:js|css)'),
  workbox.strategies.cacheFirst()
);

// 图片缓存策略
workbox.routing.registerRoute(
    /.*\.(?:png|jpg|jpeg|svg|gif)/g,
    new workbox.strategies.CacheFirst({
        cacheName: 'my-image-cache',
    })
);

// cdn 缓存策略
workbox.routing.registerRoute(
  new RegExp('https://your\.cdn\.com/'),
  workbox.strategies.staleWhileRevalidate()
);

// cdn 缓存策略
workbox.routing.registerRoute(
  new RegExp('https://your\.img\.cdn\.com/'),
  workbox.strategies.cacheFirst({
    cacheName: 'example:img'
  })
);

代码上面的CacheFirst staleWhileRevalidate 是相应的缓存策略,我们来看看有哪些缓存策略。

Stale-While-Revalidate

当请求的路由有对应的 Cache 缓存结果就直接返回,在返回 Cache 缓存结果的同时会在后台发起网络请求拿到请求结果并更新 Cache 缓存,如果本来就没有 Cache 缓存的话,直接就发起网络请求并返回结果,这对用户来说是一种非常安全的策略,能保证用户最快速的拿到请求的结果,但是也有一定的缺点,就是还是会有网络请求占用了用户的网络带宽。

Cache First

当匹配到请求之后直接从 Cache 缓存中取得结果,如果 Cache 缓存中没有结果,那就会发起网络请求,拿到网络请求结果并将结果更新至 Cache 缓存,并将结果返回给客户端。这种策略比较适合结果不怎么变动且对实时性要求不高的请求。

Network First

当请求路由是被匹配的,就采用网络优先的策略,也就是优先尝试拿到网络请求的返回结果,如果拿到网络请求的结果,就将结果返回给客户端并且写入 Cache 缓存,如果网络请求失败,那最后被缓存的 Cache 缓存结果就会被返回到客户端,这种策略一般适用于返回结果不太固定或对实时性有要求的请求,为网络请求失败进行兜底。

Network Only

直接强制使用正常的网络请求,并将结果返回给客户端,这种策略比较适合对实时性要求非常高的请求。

Cache Only

直接使用 Cache 缓存的结果,并将结果返回给客户端,这种策略比较适合一上线就不会变的静态资源请求。

资源加载阶段

常见资源类型有HTML文档、CSS、JavaScript、image等资源,我们来看看究竟可以从哪些方面对资源进行优化呢?

打包优化

资源打包工具我们就以webpack为例来讲解。

资源压缩

webpack4.x版本配置压缩JS、CSS资源

const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpack = require('webpack'); //访问内置的插件
const OptimizeCSSAssetsPlugin = require("optimize-css-assets-webpack-plugin"); // 引入压缩CSS插件
const path = require('path');

const config = {
  mode: 'production',
  entry: './path/to/my/entry/file.js',
  output: {
    filename: 'my-first-webpack.bundle.js',
    path: path.resolve(__dirname, 'dist')
  },
  optimization: {
    minimizer: [
      new OptimizeCSSAssetsPlugin({}) // 压缩CSS代码
    ]
  },
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        use: 'babel-loader'
      }
    ],
    {
      test:/\.css$/,
        use:[
          'css-loader'
        ]
      },
  },
  plugins: [
    new webpack.optimize.UglifyJsPlugin(), // 压缩JS代码
    new HtmlWebpackPlugin({template: './src/index.html'})
  ]
};

module.exports = config;

按需加载

按需加载主要是使用ES6提供的 import() 语法实现

function getComponent(){
  return import(/* webpackChunkName: "lodash" */ 'lodash').then(({default: _})=>{
    var element = document.createElement('div');
    element.innerHTML = _.join(['hello','webpack'],'-');
    return element;
  })
}

document.addEventListener('click',()=>{
  getComponent().then(component=>{
    document.body.appendChild(component);
  });
});

当发生点击事件时才会去加载lodash库,而不再是初始化就直接加载,这样大大节约了首次加载时间。

预加载preload和预取prefetch

<link rel="prefetch"></link> // 被标记为prefetch的资源,将会被浏览器在空闲时间加载。
<link rel="preload"></link> // preload通常用于本页面要用到的关键资源,包括关键js、字体、css文件。preload将会把资源得下载顺序权重提高,使得关键数据提前下载好,优化页面打开速度。

webpack 通过识别代码中的魔法注释进行 prefetch | preload 打包

const element = document.createElement('button');
element.innerHTML = "登录";
element.onclick = function(){
  import(/* webpackPrefetch: true */ /* webpackChunkName: "login" */ "./login.js").then(({default:loginFunc})=>{
    loginFunc();
  })
};
document.body.appendChild(element);

webpack 判断有/* webpackPrefetch: true */注释的代码块,会打包成<link rel="prefetch"></link>,核心是利用浏览器提供的预取和预加载的能力。

上面代码是利用prefetch加载一个登录框模块。因为登录框只有点击了登录按钮才会用上,因此是不需要一进入页面就直接加载。通过这样的方式节约了首次加载时间。

tree shaking

Tree shaking的本质是消除无用的JavaScript代码

例如我们引入lodash

import { forEach } from "lodash";

forEach([1,2,3],()=>{});

webpack会自动帮我们进行tree shaking最后只会把我们需要的forEach模块打包进来。

前提条件是必须使用:ES6 的 import 和 export 模块语法。因为它在代码静态解析阶段就会生成。也就是说代码不运行起来就已经知道我们使用了lodashforEach模块,因此webpack进行打包分析时就可以剔除无用代码,只打包使用的模块。

对比下CommonJS

  • CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。
  • CommonJS 加载的是一个对象(即module.exports属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。

资源加载位置优化

通过优化资源加载位置,更改资源加载时机,尽可能快地展示出页面内容,尽可能快地使功能可用。

1、CSS文件放在head中

  • 优先下载CSS样式,避免页面已经有内容但是还没有样式的情况发生
  • 如果CSS不在head中优先下载,但是又包含了例如display:none这样的样式会导致浏览器去重新生成CSSDOM 然后作用于相应的LayerTree 然后再生成位图渲染出来。相当于是会引发重绘重排发生。

2、JS文件放在body底部

将所有的<script>标签放到页面底部,也就是</body>闭合标签之前,这能确保在脚本执行前页面已经完成了渲染。

尽可能地合并脚本。页面中的<script>标签越少,加载也就越快,响应也越迅速。无论是外链脚本还是内嵌脚本都是如此。

CSS

1、用对选择器

选择器的性能排序如下所示

id选择器(#myid)
类选择器(.myclassname)
标签选择器(div,h1,p)
相邻选择器(h1+p)
子选择器(ul > li)
后代选择器(li a)
通配符选择器(*)
属性选择器(a[rel="external"])
伪类选择器(a:hover,li:nth-child

选择的越精确浏览器需要进行的计算量就越小,性能就更好。

2、避免使用层级较深的选择器,或其他一些复杂的选择器,尤其是通配符* {},它会匹配所有元素,所以浏览器必须去遍历每一个元素!

3、避免使用CSS表达式例如expression(document.body.offsetWidth - 180 "px"),CSS表达式是动态设置CSS属性的强大但危险方法,它的问题就在于计算频率很快。不仅仅是在页面显示和缩放时,就是在页面滚动、乃至移动鼠标时都会要重新计算

JavaScript

1、事件代理

事件代理是指将事件监听器注册在父级元素上,由于子元素的事件会通过事件冒泡的方式向上传播到父节点,因此,可以由父节点的监听函数统一处理多个子元素的事件

利用事件代理,可以减少内存使用,提高性能及降低代码复杂度

<ul id="myLinks">
    <li id="goSomewhere">Go somewhere</li>
    <li id="doSomething">Do something</li>
    <li id="sayHi">Say hi</li>
</ul>

我们可以为每个li单独添加点击事件,也可以用事件代理只在ul上面添加点击事件

var list = document.getElementById("myLinks");

list.addEventListener("click", function(event){

    switch(event.target.id){
        case "doSomething":
            document.title = "I changed the document's title";
            break;

        case "goSomewhere":
            location.href = "http://www.wrox.com";
            break;

        case "sayHi":
            alert("hi");
            break;
    }
},false);

2、大数据量的运算使用 Web Worker

你可以把 Web Workers 当作主线程之外的一个线程,在 Web Workers 中是可以执行 JavaScript 脚本的,不过 Web Workers 中没有 DOM、CSSOM 环境,这意味着在 Web Workers 中是无法通过 JavaScript 来访问 DOM 的,所以我们可以把一些和 DOM 操作无关且计算量大的耗时任务放到 Web Workers 中去执行。

3、避免频繁的垃圾回收

我们知道 JavaScript 使用了自动垃圾回收机制,如果在一些函数中频繁创建临时对象,那么垃圾回收器也会频繁地去执行垃圾回收策略。这样当垃圾回收操作发生时,就会占用主线程,从而影响到其他任务的执行,严重的话还会让用户产生掉帧、不流畅的感觉。所以要尽量避免产生那些临时垃圾数据。

那该怎么做呢?可以尽可能优化储存结构,尽可能避免小颗粒对象的产生。

4、合理使用<script> async defer 属性

async:不会阻塞页面的渲染,onload事件触发前执行,不保证执行顺序,脚本一旦加载完毕就会立刻执行。

defer:不会阻塞页面的渲染,执行时间延迟到dom树构建完成后(DOMContentLoaded事件触发之前完成,保证能拿到dom元素),顺序执行。

image 图片资源

在电商网站中,图片资源不仅仅大,而且非常多,例如各种小图标之类的。那么优化图片资源嫣然也是前端优化的重中之重了。

图片格式对比

格式优点缺点适用场景
jpg色彩丰富时压缩率高、边缘平滑有损、不可重复压缩、不支持透明通道照片
webp类似 jpg、压缩率更⾼(但质量不好),支持动画、透明编码时间⻓ 8 倍、浏览器兼容性不好照片
png32无损、支持透明通道、颜色丰富、色彩有限时压缩率高、可以多次压缩不支持动画、色彩丰富时压缩率低平面设计图
png8色彩低于256种无损、色彩少时压缩率高、支持透明色彩高于256种则有损logo、icon、透明图
gif色彩低于256种无损、色彩少时压缩率高、支持动画色彩高于256种则有损动画
svg无损、可缩放、可交互只适合描述“图形”图标、图表

base64

图片的 base64 编码就是可以将一副图片数据编码成一串字符串,使用该字符串代替图像地址。

优点:

  • 减少http请求
  • 适合小的icon图片

缺点:

  • 明显会增大HTML体积,如果图片过大的话明显影响网页的打开速度,尤其在spa项目中影响首屏加载速度。
  • IE 8 以下不支持 data url

webpack 可以设置把图片小于多少kb的自动转换成base64

雪碧图

将网站要使用的小的icon放到一张png图片中,然后通过background-position定位展示相应位置的icon。这样做的好处是只需要发起一次http请求。

图片懒加载 Lazy-Load

原理是窗口滑动到可视区时加载相应的图片。避免页面加载时就下载全部图片,耗费时间。

实现原理:

<script>
    // 获取所有的图片标签
    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);
</script>

图片压缩

推荐一个图片压缩比较牛的网站https://tinypng.com/,或者使用打包工具相应的包进行图片压缩。

用户交互阶段

交互阶段的原则是使代码尽量不触发或少触发页面的重绘与重排。

重排

更新了元素的几何属性,例如改变元素的宽度、高度等,那么浏览器会触发重新布局,解析之后的一系列子阶段,这个过程就叫重排。无疑,重排需要更新完整的渲染流水线,所以开销也是最大的。

重绘

更新元素的绘制属性,如果修改了元素的背景颜色,那么布局阶段将不会被执行,因为并没有引起几何位置的变换,所以就直接进入了绘制阶段,然后执行之后的一系列子阶段,这个过程就叫重绘。相较于重排操作,重绘省去了布局和分层阶段,所以执行效率会比重排操作要高一些。

CSS

1、元素适当地定义高度或最小高度,否则元素的动态内容载入时,会出现页面元素的晃动或位置,造成重排。

2、给图片设置尺寸。如果图片不设置尺寸,首次载入时,占据空间会从0到完全出现,上下左右都可能位移,发生重排。

3、不要使用table布局,因为一个小改动可能会造成整个table重新布局。而且table渲染通常要3倍于同等元素时间。

4、能够使用CSS实现的效果,尽量使用CSS而不使用JS实现,比较操作DOM的消耗是非常大的。

5、对于一些进行动画的元素,尽量使用 transition transform 会形成自己的独立层可以避开重排和重绘阶段。

6、如果能提前知道对某个元素执行动画操作,那就最好将其标记为 will-change,这是告诉渲染引擎需要将该元素单独生成一个图层。

JavaScript

1、缓存DOM

const div = document.getElementById('div') 由于查询DOM比较耗时,在同一个节点无需多次查询的情况下,可以缓存DOM

2、减少DOM深度及DOM数量

HTML 中标签元素越多,标签的层级越深,浏览器解析DOM并绘制到浏览器中所花的时间就越长,所以应尽可能保持 DOM 元素简洁和层级较少。

3、批量操作DOM

由于DOM操作比较耗时,且可能会造成回流,因此要避免频繁操作DOM,可以批量操作DOM,先用字符串拼接完毕,再用innerHTML更新DOM。

4、批量操作CSS样式

通过切换class或者使用元素的style.csstext属性去批量操作元素样式。

5、在内存中操作DOM

使用DocumentFragment对象,让DOM操作发生在内存中,而不是页面上。

6、避免强制同步布局

浏览器具有惰性渲染机制,连接多次修改DOM可能只触发浏览器的一次渲染。而如果修改DOM后,立即读取DOM。为了保证读取到正确的DOM值,会触发浏览器的一次渲染。因此,修改DOM的操作要与访问DOM分开进行。

function foo() {
    let main_div = document.getElementById("mian_div")
    let new_node = document.createElement("li")
    let textnode = document.createTextNode("time.geekbang")
    new_node.appendChild(textnode);
    document.getElementById("mian_div").appendChild(new_node);
    //由于要获取到offsetHeight,
    //但是此时的offsetHeight还是老的数据,
    //所以需要立即执行布局操作
    console.log(main_div.offsetHeight)
}

将新的元素添加到 DOM 之后,我们又调用了main_div.offsetHeight来获取新 main_div 的高度信息。如果要获取到 main_div 的高度,就需要重新布局,所以这里在获取到 main_div 的高度之前,JavaScript 还需要强制让渲染引擎默认执行一次布局操作。我们把这个操作称为强制同步布局。

为了避免强制同步布局,我们可以调整策略,在修改 DOM 之前查询相关值。

function foo() {
    let main_div = document.getElementById("mian_div")
    //为了避免强制同步布局,在修改DOM之前查询相关值
    console.log(main_div.offsetHeight)
    let new_node = document.createElement("li")
    let textnode = document.createTextNode("time.geekbang")
    new_node.appendChild(textnode);
    document.getElementById("mian_div").appendChild(new_node);
}

9、防抖和节流

使用函数节流(throttle)或函数去抖(debounce),限制某一个方法的频繁触发

函数节流(throttle),在某段时间内,不管你触发了多少次回调,我都只认第一次,并在计时结束时给予响应。

var throttle = function ( fn, interval ) {

    var __self = fn,    // 保存需要被延迟执行的函数引用
        timer,      // 定时器
        firstTime = true;    // 是否是第一次调用

    return function () {
        var args = arguments,
            __me = this;

        if ( firstTime ) {    // 如果是第一次调用,不需延迟执行
            __self.apply(__me, args);
            return firstTime = false;
        }

        if ( timer ) {    // 如果定时器还在,说明前一次延迟执行还没有完成
            return false;
        }

        timer = setTimeout(function () {  // 延迟一段时间执行
            clearTimeout(timer);
            timer = null;
            __self.apply(__me, args);

        }, interval || 500 );

    };

};

window.onresize = throttle(function(){
    console.log( 1 );
}, 500 );

函数防抖(debounce),防抖的中心思想在于,我会等你到底。在某段时间内,不管你触发了多少次回调,我都只认最后一次。

// fn是我们需要包装的事件回调, delay是每次推迟执行的等待时间
var debounce  = function(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)
  }
}