H5加载性能优化实践

为什么要做H5加载性能优化

在一次OKR讨论会上,我希望把H5加载性能优化作为一个KR,但服务端同学觉得花力气提升3-400ms对业务没感知,他们也不愿意为这些付出的时间来买单。

但是想想这里慢一点,哪里体验不在意一点,产品就会越来越一般,用户只会记住体验最好的那款产品。

工程师应当具备把产品做到"极致"的愿景,在自己能力范围内把事情做到最好

再说提升的3~400ms真的无所谓吗?

  • 根据 Mobify 的研究,把页面加载时间减少100ms,留存率会增加1.11%
  • 零售商AutoAnything 把页面加载时间缩短一半后,销售额增长12~13%

在我原来的文章中提到过一次地推的经历# 这可能是最全的小程序包大小优化方案,我们要知道在我们平时测试时往往处在较好的网络环境下,一点加载问题并不能体现出来,而用户使用时的网络环境、手机设备等都可能很差,往往会把一点问题无限放大,这时就更能体现性能优化的价值。

优化的目标

确定做这件事情的价值后,我们就需要确定一个可衡量的目标,以确定我们是否实现了我们的Object,这也是OKR的基本思路。

扯远了,性能H5加载性能的指标很多,选择那个合适呢?

站在用户的角度,当点击入口,到这个页面的主要内容展示出来后才不算等待时间。所以我们选择了主内容渲染完成(FMP)作为我们的衡量指标。

经过实践,我们觉得 FMP 达到 1.2 秒是一个能实现,同时有难度需要跳一跳的目标。

优化方案

目标有了之后,就需要确定达成目标的关键措施,以及站在 以终为始 的角度来思考在什么节点完成哪些任务。

一个H5页面加载大致经过如下步骤:

按照页面整个加载过程,我们大体讨论了如下方案,首先是对webView启动优化,进行webView预加载,其次是离线缓存,减少资源请求时间,再次是资源大小优化,JS、CSS等资源的加载优化,最后是使用接口预请求减少获取首屏数据的时间,最后数据返回渲染页面时对图片进行裁剪、压缩和转webp等等。

我们把这些方案按照难易程度依次划分为:加载策略优化、资源大小优化、APP协同优化,并逐步推进。

加载策略优化

Gzip压缩

Gzip压缩很基础很简单,但很有必要。这里提一个讲是希望大家不要犯我们的错误,因为简单基础,反而没有在意。在我们排查过程中确实发现有一部分页面没有开启Gzip压缩。 我们的资源都放在CDN上,让运维同学帮开启Gzip压缩即可 开启Gzip压缩之前:

开启Gzip压缩之后:

可以看到开启Gzip压缩效果非常明显,多有JS基本都有一倍以上的减少,尤其是主JS文件app.js 更是有1.7M降低到了120KB。

CDN和缓存

CDN这里就不再讲了,我们很早之前前端资源就已放在CDN上。

缓存策略可以设置为HTML文件不做缓存,js、CSS等静态资源因为每次打包发布都会生成一个新的hash值作为文件名,所以可以设置为永不过期。

域名预解析

当我们输入一个H5页面路径,会先去进行DNS解析,获取到服务器地址,在请求资源,DNS解析的步骤可以分为:

  1. 查看浏览器缓存
  2. 查看系统缓存
  3. 查看路由器缓存
  4. 查看ISP DNS 缓存
  5. 询问域名服务器(还有其他后续,这里不展开)

如果在加载完HTML,发起接口请求之前,能对接口的域名进行预解析,就可以省掉DNS查找所划分的时间。

如图所示,省掉DNS解析至少可以节省130多ms的时间,而DNS预解析的改造成本很小,只需在HTML加上如下代码即可。

html 复制代码
<link rel="dns-prefetch" href="//xxx.baidu.com">
<link rel="dns-prefetch" href="//xxx.test.com">

preconnect

除了域名预加载外,还可以使用 preconnect 提前建立 TCP 握手连接和 TLS 协议,允许浏览器在 HTTP 请求实际发送到服务器之前建立早期连接。可以预先启动 DNS 查找、TCP 握手和 TLS 协商等连接,从而消除这些连接的往返延迟并为用户节省时间。 目前来说很多浏览器都已支持。

JS加载优化

JS的加载和执行会堵塞HTML的解析,对于单页应用来说,大多数情况下HTML没有什么实质性要展示给用户的内容。重要的都在JS中,一个JS下载和执行堵塞其他JS的下载,主内容也无法渲染。 script的加载方式有如图几种:

  • 默认情况HTML解析,然后加载JS,此时HTML解析中断,然后执行JS,最后JS执行完成恢复HTML解析。
  • defer情况下HTML和JS并驾齐驱,最后才执行JS
  • async情况则HTML和JS并驾齐驱,JS的执行可能在HTML解析之前就已经完成了
  • 最后module情况和defer的情况类似,只不过会在提取的过程中加载多个JS文件罢了

这里我们给主业务相关的JS都加上了 defer,这样可以在所有元素都加载完成后再按顺序执行。

对于性能监控、日志等JS可以单独使用 async。

下图是没有设置 defer 的情况,部分JS文件的加载堵塞了其他JS。

预加载

预加载可以使用 preload 和 prefetch。

  • preload 告诉浏览器立即加载资源;
  • prefetch 告诉浏览器在空闲时才开始加载资源,浏览器不一定会加载这些资源

由于CSS会阻塞页面的渲染,可以把css文件加入到preload中,最高优先级下载。

而prefetch的使用场景为,在用户进入下一步之前,提前加载相关的资源。

prefetch目前IOS还不支持,可使用fetch&xhr代替。

代码如下:

html 复制代码
<head>
	<link rel="preload" href="/preload.hash.css" as="style">
	<link rel="preload" href="/preload.hash.js" as="script">
	<link rel="prefetch" href="/prefetch.hash.css" as="style">
	<link rel="prefetch" href="/prefetch.hash.js" as="script">
	<link rel="preload" href="/img/img.png" as="image" type="image/png">
</head>

资源大小优化

公共包提取

公共包提取也是一个常规手段。但我们从最初搭建项目架构的时候,就使用的"单库多项目"的模式,使公共包更容易提取,也能发挥更大的价值。

单个H5的项目往往比较小,并且多个项目之间也有很多可以共用的代码,所以我们设计了"单库多项目",在一个git库中维护很多个H5项目,除了共用的有部分 npm 包完,还公用部分业务代码。

我们可以通过 webpack.DllPlugin 插件,不但把公用的部分单独打包,还可以提升打包速度(不用每次都打公共包)。

所有的H5页面都会公用一部分 dll 打包的JS,这样单个用户只要访问了一个页面,后续再访问其他H5页面时,都无需在请求公共JS文件。

减少包大小

Tree Shaking

我们的项目是通过webpack进行打包,在webpack2就已经支持了 Tree Shaking。Tree Shaking会去除无用的代码,前提是模块必须采用 ES6 Module 语法,因为 Tree Shaking 依赖 ES6 的静态语法:import 和 export。

Tree Shaking 在去除代码冗余的过程中,程序会从入口文件出发,扫描所有的模块依赖,以及模块的子依赖,然后将它们链接起来形成一个 "抽象语法树" (AST)。随后,运行所有代码,查看哪些代码是用到过的,做好标记。最后,再将"抽象语法树"中没有用到的代码"摇落"。经历这样一个过程后,就去除了没有用到的代码。

手动精简代码

除了使用 Tree Shaking 剩下的就是些细致活了,主要使用 webpack-bundle-analyzer 插件,通过可视化的报告,分析打包结果,帮助开发人员更好地理解和优化构建产物的大小和依赖关系。

输出的报告如图所示:

经常遇到的问题可能有如下情况:

  • 有些包自在开发、测试环境运行,就需要写成动态加载的模式,避免打包到线上包,例如vconsole
  • 尽量使用轻量级的包,例如使用 Day.js 替代 Momnet.js
  • 尽量不要使用 lodash 这类较大的包,即使使用也要用按需加载

lodash 引入优化

js 复制代码
import _ from 'lodash';
import {isEmpty} from 'lodash';

上边两种模式,都会打包整个lodash,打包提交在72KB左右,可以具体模块具体引入。

js 复制代码
// 打包后只会对用到的具体模块进行打包
import isEmpty from 'lodash/isEmpty';

还可以使用 webpack-lodash-plugin 插件,即使使用的是全局引入,也会去除未引用的模块。

但我建议还是使用第二种引入具体模块的做法。

路由懒加载&分屏加载

路由懒加载

路由懒加载也是单页应用性能优化的常规手段了,在React 16.6版本之后,支持了 Suspense 和 lazy 让实现路由懒加载更加容易。

路由懒加载是在使用 React Router 进行页面路由时,将页面组件按需加载,而不是一次性加载所有页面组件。

当路由被匹配时才会加载对应的组件,而不是一次性加载所有路由组件,从而减少页面加载时间和网络带宽的消耗。

下面是一个使用路由懒加载的例子:

js 复制代码
import React, { lazy, Suspense } from 'react';
import { Route, Switch } from 'react-router-dom';

const Home = lazy(() => import(/* webpackChunkName: Home*/ './components/Home'));
const About = lazy(() => import(/* webpackChunkName: About*/ './components/About'));
const Contact = lazy(() => import(/* webpackChunkName: Contact*/ './components/Contact'));

function App() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <Switch>
          <Route exact path="/" component={Home} />
          <Route path="/about" component={About} />
          <Route path="/contact" component={Contact} />
        </Switch>
      </Suspense>
    </div>
  );
}

export default App;

这里使用到了动态 import(),import() 是基于Promise的API,因此 import() 的返回值是一个完成状态或拒绝状态的Promise对象,webpack在编译时,识别到动态加载的import语法,则webpack会为当前动态加载的模块创建一个单独的bundle。

组件分屏加载

分屏加载和懒加载的原理一样,利用 Suspense 和 lazy 把非首屏加载的组件单独拆分,优先加载首屏的JS,之后再动态加载。但要注意拆分的 bundle 数量不易过多,如果每个bundle都很小,并且数量很多,其实还不如合并为几个较大的bundle一次加载,因为默认情况下,浏览器对同一域名下的并发请求数量有限制,通常为6-8 个。

对于一些在最底层,不容易展示的模块,还可以使用 Intersection Observer API 和懒加载结合,在即将展示的时候,再去加载对应的 bundle。

图片优化

在我们优化过程中,图片加载的优化带来的效果也非常明显,图片太多导致向服务器请求的次数太多,图片太大导致每次请求的时间过长,导致用户长时间等待。

图片太大我们主要通过转webp和图片瘦身,图片太多我们主要是使用图片懒加载和雪碧图。

图片转webp和瘦身

webp格式的图片在无损压缩模式下,能将png图片大小压缩26%;有损压缩模式下,能将jpeg图片大小压缩25-34%。

目前大部分浏览器都已支持webp格式的图片,我们可以判断浏览器是否支持,不支持的情况下降级到使用 png 等格式的图片。 判断是否支持webp的方法,原理是通过使用 canvas 导出一张 webp 格式的 base64 图片,通过判断头部是否包含 webp 来判断浏览器是否支持 webp。

js 复制代码
const supportwebp = () => {
	try {
		return (document.createElement("canvas").toDataURL("image/webp", 0.3).indexOf("data:image/webp") === 0);
	} catch (err) {
		return false;
	}
};

图片瘦身

由于我们的图片都存储在七牛上,在瘦身这块,我们使用了七牛的 imageMogr2 能力,把 png 图片转化为 webp 格式、按照展示内容的大小对图片进行裁剪、缩放、质量压缩等操作。

大体方法如下:

ts 复制代码
function getSrc(src: string, quality: string, width: string) {
	// 各项参数判断拼接
	let thumbnail: number = Number((width.match(/\d+/) || [])[0]) || 0,
	// 切割图片宽度
	service_w = (src.match(/w=\(\d+\)/) || [])[0] || "",
	// 图片质量
	qualityScript = "/quality/" + quality,
	// 图片格式,默认转换为webp
	formatScript = supportwebp() ? "imageMogr2/format/webp" : "imageMogr2/auto-orient";
	const service_w_num = Number((service_w.match(/\d+/) || [])[0]);
	// service_w_num为src上图片尺寸,thumbnail为自定义尺寸,取两者最小值
	thumbnail = service_w_num && service_w_num < thumbnail ? service_w_num : thumbnail;
	if (width || service_w_num) {
		qualityScript +=
		"/thumbnail/" + (width ? thumbnail : service_w_num) + "x";
	}
	return src + formatScript + qualityScript;
};

经过这两项优化,我们的菜谱H5页面,请求所有图片资源的耗时有6000ms 降低到了 1550ms,单张图片请求由最高达4.66秒降低到了350毫秒。

图片懒加载

对于不在首屏展示的图片,可以使用懒加载,类似于组件的分屏加载。

原理是先把图片的URL放置在 data-xxx 属性下,在页面滚动过程中去判断图片是否进入或者即将进入到可视区域,再将data-xxx 中图片的路径赋值给src,这样就实现了图片的按需加载。

而判断图片是否即将进入可视区域的方法有多种。

方法一:使用 Intersection Observer 异步观察目标元素和文档视窗的交叉状态;

方法二:在 onscroll 事件中,判断 clientHeight+scroolTop 是否大于 offsetTop;

方法三:在onscroll 事件中,判断 bound.top 是否小于等于 clientHeight;

具体的细节这里就不展开了。

以下是我们图片优化后的效果对比:

APP协同优化

经过以上优化,依然难达到 FMP < 1.2秒,所以我们有和APP团队协同,从APP层面联动H5自身,进行了很多优化。

APP域名预建连

对于H5进程使用的域名,配置下发给APP,在APP启动后,空闲时间提前发起一个请求,把DNS解析结果缓存在本地。

这样H5页面打开是域名的DNS解析可以直接使用本地的解析结果。

webView预加载

过往,我们打开一个H5页面,APP端是先进行切换页面的动画,之后创建webview容器。现在我们改成点击后立即创建webview容器并先隐藏,切换动画结束后再展示,这样可以节省几百毫秒的时间。

离线缓存

离线缓存是APP在WiFi情况下,提前下载H5的静态资源,用户打开H5后使用本地资源直出,省去了大量请求资源的时间。但同时也有很多坑,详细方案后续可以单独写一篇文章来讲解。

接口预请求

接口预请求是利用APP,在打开H5页面的同时请求H5的首屏接口数据,等HTML加载完成后,注入到JS中,可以直接使用 APP 已请求的首屏数据进行渲染,这样就节省了获取首屏数据的时间,可以极大的提升FMP。

详细方案见我另一篇文章:低成本的 H5 秒开方案-接口预请求

优化效果

经过多种方案持续优化,我们花了大概几个月的时间,把大部分H5页面按照上述方案都优化了一遍,最终约有80%的页面FMP打到了1.2秒以内。 FMP平均值在1080ms左右。 用户访问量最大的CMS H5页面,由原来的1800ms降低到了800~900ms,性能提升一倍,几乎做到和原生相同的体验。

CMSH5页的优化效果

会员中心页的优化效果

坚持做对的事情,把事情做对

相关推荐
正小安1 小时前
如何在微信小程序中实现分包加载和预下载
前端·微信小程序·小程序
_.Switch3 小时前
Python Web 应用中的 API 网关集成与优化
开发语言·前端·后端·python·架构·log4j
一路向前的月光3 小时前
Vue2中的监听和计算属性的区别
前端·javascript·vue.js
长路 ㅤ   3 小时前
vite学习教程06、vite.config.js配置
前端·vite配置·端口设置·本地开发
长路 ㅤ   3 小时前
vue-live2d看板娘集成方案设计使用教程
前端·javascript·vue.js·live2d
Fan_web3 小时前
jQuery——事件委托
开发语言·前端·javascript·css·jquery
安冬的码畜日常3 小时前
【CSS in Depth 2 精译_044】第七章 响应式设计概述
前端·css·css3·html5·响应式设计·响应式
韩楚风4 小时前
【linux 多进程并发】linux进程状态与生命周期各阶段转换,进程状态查看分析,助力高性能优化
linux·服务器·性能优化·架构·gnu
莹雨潇潇4 小时前
Docker 快速入门(Ubuntu版)
java·前端·docker·容器
Jiaberrr4 小时前
Element UI教程:如何将Radio单选框的圆框改为方框
前端·javascript·vue.js·ui·elementui