当前位置:首页 > 技术文章 > H5秒开优化实践

H5秒开优化实践

go1238个月前 (07-24)技术文章721

为什么要做H5加载性能优化 在一次OKR讨论会上,我希望把H5加载性能优化作为一个KR,但服务端同学觉得花力气提升3-400ms对业务没感知,他们也不愿意为这些付出的时间来买单。但是想想这里慢一点,哪里体验不在意一点,产品就会越来越一般,用户只会记住体验最好的那款产品。

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

在一次OKR讨论会上,我希望把H5加载性能优化作为一个KR,但服务端同学觉得花力气提升3-400ms对业务没感知,他们也不愿意为这些付出的时间来买单。
但是想想这里慢一点,哪里体验不在意一点,产品就会越来越一般,用户只会记住体验最好的那款产品。

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

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

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

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

优化的目标

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

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

站在用户的角度,当点击入口,到这个页面的主要内容展示出来后才不算等待时间。所以我们选择了主内容渲染完成(FMP)作为我们的衡量指标。
经过实践,我们觉得 FMP 达到 1.2 秒是一个能实现,同时有难度需要跳一跳的目标。

优化方案

目标有了之后,就需要确定达成目标的关键措施,以及站在 以终为始 的角度来思考在什么节点完成哪些任务。
一个H5页面加载大致经过如下步骤:

Pasted image 20240603154739.png

按照页面整个加载过程,我们大体讨论了如下方案,首先是对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查找所划分的时间。

Pasted image 20240603165527.png

Pasted image 20240603165500.png

如图所示,省掉DNS解析至少可以节省130多ms的时间,而DNS预解析的改造成本很小,只需在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 协商等连接,从而消除这些连接的往返延迟并为用户节省时间。 目前来说很多浏览器都已支持。

Pasted image 20240603175931.png

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。

Pasted image 20240603174113.png

预加载

预加载可以使用 preload 和 prefetch。

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

由于CSS会阻塞页面的渲染,可以把css文件加入到preload中,最高优先级下载。
而prefetch的使用场景为,在用户进入下一步之前,提前加载相关的资源。
prefetch目前IOS还不支持,可使用fetch&xhr代替。

代码如下:

<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 引入优化

import _ from 'lodash';
import {isEmpty} from 'lodash';

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

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

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

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

路由懒加载&分屏加载

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

路由懒加载是在使用 React Router 进行页面路由时,将页面组件按需加载,而不是一次性加载所有页面组件。
当路由被匹配时才会加载对应的组件,而不是一次性加载所有路由组件,从而减少页面加载时间和网络带宽的消耗。

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

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。

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

图片瘦身

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

大体方法如下:

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毫秒。

Pasted image 20240604190043.png

图片懒加载

对于不在首屏展示的图片,可以使用懒加载,类似于组件的分屏加载。
原理是先把图片的URL放置在 data-xxx 属性下,在页面滚动过程中去判断图片是否进入或者即将进入到可视区域,再将data-xxx中图片的路径赋值给src,这样就实现了图片的按需加载。

而判断图片是否即将进入可视区域的方法有多种。
方法一:在 <img /> 元素上加上 loading="lazy" 属性,不过要注意兼容性问题; 方法二:使用 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 秒开方案-接口预请求[3]

优化效果

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



声明:本站所有内容均为自动采集而来,如有侵权,请联系删除

相关文章

Redis连环五十二问!看谁顶得住?

Redis连环五十二问!看谁顶得住?

基本 1.说说什么是Redis? Redis是一种基于键...

用 PHP 处理 10 亿行数据!

用 PHP 处理 10 亿行数据!

今天,我将带大家一起走进“挑衅十亿行“数据的世界。当然,这个事情是依据GitHub上的一个“十亿行挑衅”(1brc)运动而来,现在正在进行,如果你没有听说过,可查看Gunnar Morlings 的 1brc 存储库。https://github.com/gunnarmorling/1brc我之所以...

2024 年的最佳 PHP 框架

2024 年的最佳 PHP 框架

在本文中,我们将预测在 2024 年持续风行的最佳 PHP 框架。我们首先将看看PHP框架是什么,什么时候该斟酌应用PHP框架,以及应用PHP框架的重要长处都是什么。我还会介绍最合适初学者的 PHP 框架以及用于 Web 开发的最佳框架。什么是PHP框架?     &...

一文读懂多家厂商的大模型训练、推理、部署策略

一文读懂多家厂商的大模型训练、推理、部署策略

4 月 20 日,第 102 期源创会在武汉胜利举行。本期邀请来自武汉人工智能研讨院、华为、MindSpore、京东云、Gitee AI 的人工智能专家,环绕【大模型竞技与性能优化】主题发表演讲。接下来就一起看看本期运动的出色瞬间吧!大合影 get ✅披萨和礼物不能少!接下来进入主题演讲回想环节。可...

请立刻停止编写 Dockerfiles 并使用 docker init

请立刻停止编写 Dockerfiles 并使用 docker init

您是那种认为编写 Dockerfile 和 docker-compose.yml 文件很苦楚的人之一吗?我承认,我就是其中之一。我总是想知道我是否遵守了 Dockerfile、 docker-compose 文件的最佳编写实践,我畏惧在不知不觉中引入了安全破绽。但是现在,我不必再担忧这个问题了,感激...

服务器为什么大多用 Linux 而不是 Windows ?

服务器为什么大多用 Linux 而不是 Windows ?

前几天在知乎看到一个话题很有意思,且很有讨论意义。“服务器为什么大多用 Linux”,除了开源、好用等原因,回答也代表了各种不同人需求和看法,摘取一些分享给大家,也欢迎留言讨论。来自知乎好友“熊大你又骗俺”的回答首先在20年前,windows server+iis+asp+access 的方案,还是...