H5加载优化最佳实践

4,770 阅读15分钟

为什么网站速度很重要

多项研究表明,网站速度会影响转化率(即用户完成目标操作的比率)。与速度较慢的网站相比,不仅更多用户停留在快速加载的网站上,而且他们的转化率也更高。许多公司发现页面加载时间减少几毫秒都会增加转化率:

  • Mobify发现将其首页的加载时间减少100毫秒会导致基于会话的转换次数增加1.11%

  • 将页面加载时间缩短一半后,零售商AutoAnything的销售额增长了12-13%

  • 沃尔玛发现,将页面加载时间缩短一秒,可以将转换率提高2%

因此提高站点性能是优化转换率的重要组成部分。

从用户角度而言:

优化能够让页面加载得更快、对用户的操作响应的更及时,能够给用户提供更为良好的体验。

从服务商(公司)角度而言:

优化能够减少页面请求数或者减小请求所占带宽,能够节省可观的资源成本,最终提高收益转化。

设立优化目标

加载时长和页面放弃率关系:

从上图中可以看出,如果页面加载时间大于 2s,页面放弃率大幅加快,所用我们要求 H5 页面打开必须控制在 2s 以内,设立的目标为 1.2s 以内。

H5性能分析

性能分析工具

H5 启动流程

对于一个普通用户来讲,打开一个 H5 通常会经历以下几个阶段:

  1. 交互无反馈

  2. 到达新的页面,页面白屏

  3. 页面基本框架出现,但是没有数据;页面处于loading状态

  4. 出现所需的数据

如果从程序上观察,H5 启动过程大概分为以下几个阶段:

如何缩短这些过程的时间,就成了优化 H5 性能的关键。接下来我们详细看一下各个阶段注意的优化点。

加载策略优化

异步加载

先来看张图:

从这张图里面,我们看到了什么,大概总结为以下四点:

  • 默认情况HTML解析,然后加载JS,此时HTML解析中断,然后执行JS,最后JS执行完成恢复HTML解析。

  • defer情况下HTML和JS并驾齐驱,最后才执行JS

  • async情况则HTML和JS并驾齐驱,JS的执行可能在HTML解析之前就已经完成了

  • 最后module情况和defer的情况类似,只不过会在提取的过程中加载多个JS文件罢了

defer

添加defer属性后,js脚本在所有元素加载完成后执行,而且是按照js脚本声明的顺序执行,但要等到dom文档全部解析完才会被执行。

async

添加async属性后,js脚本是乱序执行的,不管你声明的顺序如何,只要某个js脚本加载完就立即执行。

module

现代浏览器中,我们可以声明acript标签type="module"属性从而拥抱es6的模块导入导出语法,就像这样:

<script type="module">
  import { app } from "./math.js";
  // ……
</script>

module翻译过来就是模块的意思,加载也和defer差不多,只不过可以加载多个JS文件而已。

预加载

preload & prefetch

preload 是一个新的 Web 标准,在页面生命周期中提前加载你指定的资源,同时确保在浏览器的主要渲染机制启动之前。

使用如下:

<link rel="preload" as="style" href="/static/css/app.ddda99b2.css">
<link rel="preload" as="script" href="/static/js/app.5703f1ba.js">

注意:preload 紧挨着 title 放,使其最早介入。

prefetch 是提示浏览器,用户在下次导航时可能会使用的资源(HTML,JS,CSS或者图片等),因此浏览器为了提升性能可以提前加载、缓存资源。prefetch 的加载优先级相对较低,浏览器在空闲的时候才会在后台加载。用法与 preload 类似,将 rel 的值替换成 prefetch 即可。

preload 是告诉浏览器页面必定需要的资源,浏览器一定会加载这些资源,而 prefetch 是告诉浏览器页面可能需要的资源,浏览器不一定会加载这些资源。所以建议:对于当前页面很有必要的资源使用 preload,对于可能在将来的页面中使用的资源使用 prefetch。

注意:用 preload 和 prefetch 情况下,如果资源不能被缓存,那么都有可能浪费一部分带宽,请慎用。非首页的资源建议不用 preload,prefetch 做为加载下一屏数据来用。

preconnect & dns-prefetch

dns-prefetch

DNS 请求需要的带宽非常小,但是延迟却有点高,这一点在手机网络上特别明显。预读取 DNS 能让延迟明显减少一些(尤其是移动网络下)。为了帮助浏览器对某些域名进行预解析,你可以在页面的html标签中添加 dns-prefetch 告诉浏览器对指定域名预解析。

dns-prefetch 是一项使浏览器主动去执行域名解析的功能。dns-prefetch 应该尽量的放在网页的前面,推荐放在 后面。具体使用方法如下:

<link rel="dns-prefetch" href="//*.com">

注意:dns-prefetch需慎用,推荐首屏加载资源添加DNS Prefetch

preconnect

和 DNS prefetch 类似,preconnect 不光会解析 DNS,还会建立 TCP 握手连接和 TLS 协议(如果是https的话)。用法如下:

<link rel="preconnect" href="//*.com.cn" />

注意:目前只支持Firefox 39+和Chrome 46+

将 dns-prefetch 和 preconnect 两者结合起来可提供进一步减少跨域请求的感知延迟的机会。您可以安全地将它们一起使用。

prerender

prerender 是一个重量级的选项,它可以让浏览器提前加载指定页面的所有资源。

<link rel="prerender"  href="/about.html"/>

prerender 就像是在后台打开了一个隐藏的 tab,会下载所有的资源、创建DOM、渲染页面、执行JS等等。如果用户进入指定的链接,隐藏的这个页面就会进入马上进入用户的视线。需慎用。

资源请求优化

数据预获取

在 Vue 项目中前端请求接口通常是在 Vue 组件中获取,是要等 Vue 加载解析, 时间就比较滞后。

现在我们将首屏数据获取的接口请求以内联的方式写入到 index.htnl 中,使用 XMLHttpRequest 不依赖其他第三方库。使异步请求和资源加载同时进行,提高效率。如下使用:

  function ajax (url, method) {
    var xhr = new XMLHttpRequest()
    var _time = Date.now()
    url = url + '?t=' + _time

    xhr.open(method, url, true)
    xhr.onerror = function () {
      // ……
    }
    xhr.timeout = 10000
    xhr.ontimeout = function () {
      // ……
    }
    xhr.onreadystatechange = function () {
      try {
        var status = xhr.status
        if (xhr.readyState == 4) {
          if ((status >= 200 && status < 300) || status == 304) {
            // 请求成功
            var _data = JSON.parse(xhr.responseText)
            window._mainPageData = _data
          }
        }
      } catch (e) {
      }
    }
    xhr.send(null)
  }

  /**
   * 首屏请求前置
   */
  ajax('/api/query', 'get')

在 VUE 根组件 app.vue 的 created 生命周期中检测是否取到了主页数据(即 window._mainPageData)

created () {
    // 获取在多个页面使用的主页数据
    this.checkMainPageData()
  },
methods: {
  checkMainPageData(){
    const mainPageData = window._mainPageData
    // 接口请求未完成
    if (!mainPageData) {
       return window.requestAnimationFrame(this.checkMainPageData.bind(this))
    }
    // 以获取到数据
    // 进行逻辑处理
}

window.requestAnimationFrame(callback) 请求动画帧。告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。

减少包体积

提取第三方库

大部分情况下,很多第三方库的代码并不会发生变更(除非是版本升级),这时就可以用到 DllPlugin:把复用性较高的第三方库打包在一起,在不升级这些库的情况下,第三方库不需要重新打包。

这样做的优点:

  1. 提取的第三方库生成的资源版本号(资源的访问连接)不会变,提高了缓存的利用;

  2. 避免打包出单个文件的大小太大,不利于加载;

  3. 每次构建只重新打包业务代码,提高打包效率。

关于提取的第三方库我们推荐只提取基础库和常用首屏所使用到的。例如:

'h5sdk',
'vue',
'vue-lazyload',
'vue-router',
'vuex',
'vuex-router-sync',
'axios',
'js-cookie'

字体文件压缩

在项目中常常会用到一些特殊字体,而字体文件都是比较大,如果将整个字体文件引入到项目中势必拖慢页面的加载速度,并且在字体加载中会出现严重的替换和抖动,为了避免这种情况,我们就要对字体文件做处理,主要分为以下情况:

开发预知会出现哪些文字:

可以通过 font-spider 压缩工具,提取需要的文字进行按需压缩。

可以看到 1.7M 大小字体包,提取了需要的字体压缩后只有 11KB。

字体文件引用

压缩后的字体文件大小在 20KB 以内的,我们推荐将字体文件转成 base64 (工具),这样会更快的加载出对应字体的。并且通过 webpack 插件 html-webpack-plugin 在生成 HTML 文件的时候将 base64 字体直接注入到 HTML 中。

<style>
@font-face{
  src: url('data:font/ttf;charset=utf-8;base64……') format("truetype");
  font-style:normal;
  font-weight:normal;
  font-family:"vCustom";
  font-display:swap;
}
</style>

以下是字体文件转 base64 前后的对比:

  • 前:

  • 后:

图片优化

图片是网站性能优化需要重点关注的方向。为什么这么说呢?来看个图片:

以大会员为例,整站资源中图片占比达到80%左右,首屏中图片占比超过了20%。

图片压缩与合并

压缩

一般 UI 提供的切图都是未通过压缩的图片,所有在开发过程中,我们必须再压缩一次。如果压缩后的图片还是大于 500KB 就要考虑将图片分割成多张。

目前市面上图片压缩比较多,给大家推荐个好用的工具(地址)。可批量压缩各类图片,

以及看到压率。

合并

使用CSS Sprites(雪碧图)将一些相关(在同一屏展示的图片)小图合并成大图,这样做的优点:

  1. 能很好地减少网页的http请求,从而大大提高了页面的性能;

  2. 能减少图片的字节。比如:把3张图片合并成1张图片的字节总是小于这3张图片的字节总和。

图片加载

图片的大小和数量优化完成后,还远没达到我们的目的。还需要在加载方式和引入上做些优化。

Webp

HTML 最初加载的 js 文件(该文件会以内联的方式直接写入到 HTML 文件中)中加入判断是否支持 Webp的代码,如下:

window._supportsWebP = (function () {
  var canvas = typeof doc === 'object' ? doc.createElement('canvas') : {}
  canvas.width = canvas.height = 1
  return canvas.toDataURL ? canvas.toDataURL('image/webp').indexOf('image/webp') === 5 : false
})()

项目中引入的图片可根据环境是否支持 Webp 格式而采用不同的格式。

图片上传到CDN

项目中用到的大图我们会上传到素材中心走CDN,这样做的优点:

  1. 充分利用CDN加速;

  2. 分散域名,提高浏览器并发加载数;

  3. 素材中心上传的图片会自动生成 Webp 格式,方便调用。

如下使用:

const win = window
const imgPostfix = win._supportsWebP ? '.webp' : '.png' // 图片后缀
const linkPrefix = win.vassetsLinkPrefix // CDN域名+图片路径
const imgUrl = linkPrefix + 'Z38pyBH3O' + imgPostfix  //完整的图片url

预加载

上文中提到的 preload 拥有不阻塞页面渲染的优点,但是这种方式在处理图片时依然有一些问题:

  1. 低版本浏览器或低版本 Android 不支持 preload;

  2. 如果项目需要判断环境是否支持 Webp 格式,加载不同格式的图片,preload 就办不到;

所以,我们可以在 HTML 最初加载的 js 文件中使用 js 插入需要预加载的图片,并将其移出屏幕窗口以外。

预加载增强版

对于一些不会经常变,但又是需要服务器下发的图片(比如:头像、banner、氛围背景等),在页面首次加载的时候将图片路径存储到 localStorage(后续加载都会去更新本地存储的数据),等下次加载页面的时候会先取存储的图片预加载。

  • 存储:

    win.localStorage.setItem('xxx', { JSON.stringify({ imgs: [ 'xxx' // 完整的图片url ] }) })

  • 获取:

    // 预加载图片处理 win.preloadImg = function (imgs) { if (imgs && imgs.length > 0) { var body = doc.body var parentNode = doc.createDocumentFragment() imgs.forEach(function (link) { var img = doc.createElement('img') img.src = link img.alt = '' img.style.left = '-9999px' img.style.position = 'absolute' parentNode.appendChild(img) }) body.insertBefore(parentNode, body.firstChild) } }

    // 加载已知图片 var imgPostfix = win._supportsWebP ? '.webp' : '.png' // 图片后缀 var preloadInfo = win.localStorage.getItem('xxx') //获取本地localStorage存储的图片 var linkPrefix = win.vassetsLinkPrefix // CDN域名+图片路径 var _imgPreLoad = [ linkPrefix + 'uHgTfg4mr' + imgPostfix, //完整的图片url linkPrefix + 'Hz5JVty-I' + imgPostfix //完整的图片url ] // 获取缓存的图片预加载 if (preloadInfo) { preloadInfo = JSON.parse(preloadInfo) win.preloadImg(preloadInfo.imgs) win._preloadInfo = preloadInfo } win.preloadImg(_imgPreLoad)

以下是图片预加载前后的对比:

  • 前:

  • 后:

图片懒加载

正确使用懒加载它能极大的提升用户体验。就比如说图片,图片一直是影响网页性能的主要元凶,现在一张图片超过几兆已经是很经常的事了。如果每次进入页面就请求所有的图片资源,那么可能等图片加载出来用户也早就走了。所以,非首屏的图片资源需要懒加载,进入页面的时候,只请求可视区域的图片资源。

按需加载

利用 webpack 对代码进行分割是按需载的前提,按需载就是异步调用组件,需要时候才下载。

Vue 异步组件

当我们的项目足够大,使用的组件就会很多,此时如果一次性加载所有的组件是比较花费时间的。一开始就把所有的组件都加载是没必要的一笔开销,此时可以用异步组件来优化一下。

vue-router 配置路由,使用 Vue 的异步组件技术,可以实现按需加载。webpackChunkName:xx 代码将组建分隔生成一个 js 文件(注意:没有必要把每个组件分割成一个 js 文件,按照业务可以将相关组件合并成一个 js)。我们推荐非首屏组件可以使用异步组件引入,如下:

components: {
  mFoot: () => import(/* webpackChunkName: "index-delay" */ './modules/foot'), // 页面尾部
  mDiscount: () => import(/* webpackChunkName: "index-delay" */ './modules/discount'), // 折扣礼券
  cPopupBuy: () => import(/* webpackChunkName: "popup-buy" */ '@/component/popup-buy'), // 购买支付弹窗
  // ……
}

Webpack 会将任何一个异步模块与相同的块名称组合到相同的异步块中。

Vue 异步路由

当打包构建应用时,JavaScript 包会变得非常大,影响页面加载。如果我们能把不同路由对应的组件分割成不同的代码块,然后当路由被访问的时候才加载对应组件,这样就更加高效了。

结合 Vue 的异步组件和 Webpack 的代码分割功能,轻松实现路由组件的懒加载。

{
  path: '/about',
  name: 'about',
  meta: { title: '关于' },
  //打包后,每个组件单独生成一个chunk文件
  component: () => import(/* webpackChunkName: "about" */ '@/views/about/index.vue')
}

webpack配置

performance

用来配置如何展示性能提示。例如,如果一个资源超过 250kb,webpack 会对此输出一个警告来通知你。

performance.maxAssetSize

资源(asset)是从 webpack 生成的任何文件。此选项根据单个资源体积(单位: bytes),控制 webpack 何时生成性能提示。

用法:

module.exports = {
  //...
  performance: {
    maxAssetSize: 1024 * 300 // 单个静态资源文件的大小最大超过300KB则会给出警告
  }
};

将打包后的静态资源控制在 300KB 以内,最终通过 Gzip 压缩后,基本都在 100KB 以内。

DllPlugin

DllPlugin 的作用在上文“提取第三方库”中已详细说明,下面看看用法:

//webpack.dll.conf.js
module.exports = {
  mode: 'production',
  entry: {
    libs: [
      // …… 需要抽离的第三方库
  },
  output: {
    path: path.join(__dirname, './dllFile'), // 动态链接库输出路径
    filename: '[name].[hash:8].js', // 动态链接库输出的文件名称
    library: '[name]_[hash:8]' // 全局变量名称 导出库将被以var的形式赋给这个全局变量 通过这个变量获取到里面模块
  },
  plugins: [
    new webpack.DllPlugin({
      path: path.join(__dirname, './dllFile/[name]-manifest.json'),
      name: '[name]_[hash:8]', // 和library 一致,输出的manifest.json中的name值
      context: __dirname // 该属性需要与DllReferencePlugin中一致
    })
  ]
}

然后需要执行这个配置文件生成依赖文件,接下来需要使用 DllReferencePluhin 将依赖文件引入项目中。

// webpack.conf.js
module.exports={
  //...省略其他配置
  plugins: [
    new webpack.DllReferencePlugin({
      context: __dirname,
      mainfest:require('./dllFile/libs-mainfest.json')
    })
  ]
}

生产环境去掉调试代码

生产环境打包时添加配置,去掉调 console 代码,减少体积。

module.exports = {
  plugins: [
    // ……
    new UglifyJsPlugin({
      uglifyOptions: {
        compress: {
          drop_console: true
        }
      }
    })
  ]
}

Tree Shaking

用于移除模块内部的无效代码。它依赖于 ES6 模块语法的特性,例如 import 和 export。

目前 webpack 默认 ES6 规范编写的模块都能使用tree-shaking(文档)。

其他

缓存

浏览器缓存

合理利用浏览器缓存机制。浏览器缓存机制是指通过 HTTP 协议头里的 Cache-Control(或 Expires)和 Last-Modified(或 Etag)等字段来控制文件缓存的机制。

目前前端的静态资源都是有 MD5 戳,Cache-Control 可以配置相对比较长的时间如30天。首页 index.html 建议不要配置缓存,且不要在 html 注入太多的代码,最好保证在 10KB 以内。

离线缓存

离线缓存依赖客户端能力。用户通过客户端首次打开 H5 时,会将静态资源缓存到本地,包含了该静态资源包的指纹,每次用户访问该 H5 页面,客户端都会拿本地缓存的静态资源包的指纹去服务器对比最新的对比。如果一致那么客户端直接拦截静态资源请求直接替换成本地的资源加载,否则去服务器获取最新资源加载。这样省去了网络请求静态资源的环节,极大的提升了页面加载速度。

HTTP/2

在性能提升方面 http/2 主要利用了对路复用的特性。因为在 http 1.1 时代,浏览器会限制同一个域的同时请求数,Chrome是限制6个,总连接数是17个。当我们开启了 http/2 之后,个数几乎没有限制了。

http 1.1 资源加载图:

http/2 资源加载图:

你会发现这些资源都是同时加载的,后面加载的资源不需要进行排队。也就是说理论上带宽有多大,就能传多快。

结论

经过以上的各种性能优化方案的实施,我们来看一下优化前后数据的对比:

优化前:

优化后:

用上面的图中的“网页可见时间”和下面图中的“首次可交互时间”取值基本一致,可横向比较。可以看到从开始的 2s 左右降低到 1s 左右,性能提升100%左右。

参考

www.keycdn.com/support/pre…