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页的优化效果

会员中心页的优化效果

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

相关推荐
孜然卷k2 分钟前
前端导出word文件,并包含导出Echarts图表等
前端·javascript
家里有只小肥猫23 分钟前
uniApp小程序保存canvas图片
前端·小程序·uni-app
前端大全26 分钟前
Chrome 推出全新的 DOM API,彻底革新 DOM 操作!
前端·chrome
八角丶37 分钟前
元素尺寸的获取方式及区别
前端·javascript·html
冴羽1 小时前
Svelte 最新中文文档教程(16)—— Context(上下文)
前端·javascript·svelte
前端小臻1 小时前
关于css中bfc的理解
前端·css·bfc
白嫖不白嫖1 小时前
网页版的俄罗斯方块
前端·javascript·css
HappyAcmen1 小时前
关于Flutter前端面试题及其答案解析
前端·flutter
顾比魁1 小时前
pikachu之CSRF防御:给你的请求加上“网络身份证”
前端·网络·网络安全·csrf
林的快手1 小时前
CSS文本属性
前端·javascript·css·chrome·node.js·css3·html5