前端性能优化之 —- 静态文件客户端离线缓存
1. 前言
上次的文章给大家分享了怎么在 webpack 打包阶段去将自己的项目优化到极致。文章链接:将 webpack 打包优化到极致_20180619
前端优化是无止境的,这篇文章主要介绍我在前端工程化方面对项目优化的一些探索和经验总结。
2. 探究业务的瓶颈
H5 页面的性能瓶颈,网络因素几乎可以占到 80%。不管是减小产出文件体积,还是使用 HTTP2.0
或者是 PWA
等都是减少网络对 H5 页面加载的影响。
我们部门产品主要是在拉美国家使用。拉美国家的用户有个比较明显的特点:
- 网络环境较差(2G、3G 用户依然存在);
- 用户手机型号主要是安卓,且机型较旧(代表着 webview 较旧);
下图是从公司的 h5 性能监控平台上查询到的信息。
从上图可以看出大部分用户所在的网络环境还是很不错的。但是前线的同事还是经常会反馈我们的 H5 页面在用户手机上打开缓慢。我们讨论后有两个猜测:
- 在已经优化了文件体积的前提下,是不是我们的 page 域名(html 域名)和 static 域名(静态文件)在当地的 CDN 解析时间较慢?
- 虽然 cdn 的 Nginx 对静态文件设置了 HTTP 缓存(max-age 等字段),但是在用户手机上因为某种原因却没有按照预期缓存足够的时间;
3. 网络因素的探究
有了猜测和因为,我们就针对上面提到的问题,通过技术手段进行了探究。探究依赖的理论知识如下:
上图体现了当我们在浏览器地址栏中输入地址按下 enter 键后,到页面加载出来页面的整个生命周期。浏览器(或者是 webview)是提供了 Performance
API 让我们获取各个阶段开始执行的时间的。
查看文档根据Performance
API,编写了上报数据的脚本。脚本链接请点击这里
我把代码主要部分和需要注意的地方拿出来,大家在用的时候可以注意一下:
// 注意在实践中发现一些旧款的安卓机型是没有 getEntries 方法的,所以脚本要做兼容
const entryList = performance && performance.getEntries && performance.getEntries();
// 一般情况下我们都会将 css 放在 head 标签中,js 放在 body 标签底部。如果静态文件使用了单独的 CDN 域名这是只需要获取第一个加载的 CSS 的对象就行
for (let i = 0; i < entryList.length; i++) {const obj = entryList[i];
if (obj.initiatorType === 'link') {
linkPerformance = obj;
break;
}
}
// 下面是获取的各个阶段的开始时间
const cssDomainlookStart = linkPerformance ? linkPerformance.domainLookupStart : 0;
const cssDomainlookEnd = linkPerformance ? linkPerformance.domainLookupEnd : 0;
const connectStart = linkPerformance ? linkPerformance.connectStart : 0;
const connectEnd = linkPerformance ? linkPerformance.connectEnd : 0;
const requestStart = linkPerformance ? linkPerformance.requestStart : 0;
const responseStart = linkPerformance ? linkPerformance.responseStart : 0;
const responseEnd = linkPerformance ? linkPerformance.responseEnd : 0;
// Omega 是公司统一的上报数据的脚本。下面是计算每个阶段的开始和结束的时间差
Omega && Omega.trackEvent && Omega.trackEvent('static_domain_timing', '', {
country: country,
lookupTiming: cssDomainlookEnd - cssDomainlookStart,
connectTiming: connectEnd - connectStart,
requestTiming: responseStart - requestStart,
responseTiming: responseEnd - responseStart,
host: 'static.didiglobal.com',
cityid
});
3.1 对 html 域名的数据调研结果
因为数据是通过脚本上报先写入数据库,然后通过 SQL 脚本查出来的。所以均以表格的形式来展现。
3.2 对 CDN 域名的数据调研结果
- _c0 DNS 解析时间
- _c1 tcp“握手”时间
- _c2 发起 request 到 reques end 的时间
- _c3 response start 到 response end 的时间
- _c4 城市 ID
- _c5 国家
总结
当我们有了以上统计数据的时候,我们得出的结论是:
总体上在拉美主要城市我们的 page 域名和 static 域名运行的网络环境还是很不错的。存在慢的情况,但是与总人数相比是可以忽略不计的。
4. 问题猜想以及优化方案
上面的结论并不是说目前我们业务的网络环境已经没有优化空间了,只是跟据优化成本和紧切度来说,目前的状态还是能够满足需求的。不过后续我们有推动运维部门将我们的网络协议升级为 HTTP2.0。
4.1 问题的猜想
排除了网络因素对我们页面加载速度的影响,那么到底是什么原因影响页面的加载速度呢?因为我们的项目 100% 都是使用 vue
的单页应用,单页应用的一个最大的缺点这时就暴露了出来,“首屏”慢。
- 假如用户是第一次进入某个页面,慢是可以理解的,因为需要新下载 JS,CSS,HTML(这就是常说的“首屏慢”);
- 第 N 次进入,假如客户端缓存的 CSS,JS 等静态资源失效也会慢;
- 某个页面做了修改,上线后也会慢。(这种情况可以跟 1 类似,算是首屏)
由以上我们推理出来 3 种情况,我们提出了一个假设:
假如通过某种方式提前缓存渲染 h5 需要的 html 和静态文件(JS,CSS)是不是可以加快首屏的渲染速度,具体又能提高多少,是否有大规模推广的必要?。
4.2 数据结果
为了验证 4.1 中的猜想,我们需要数据的支撑。紧接着我们找客户端同学跟我们一起做了几次实验。
在客户端发版的时候,提前将一些“单页”H5 渲染所需要的资源跟随客户端一起打包发版,然后对这些页面的加载速度进行监控。
下面是我们对预缓存到客户端页面的前后渲染时间统计对比得出的一些数据:
由以上数据作为理论支撑,得出的结论是提前缓存静态资源到客户端内,可以明显加快页面的渲染速度。
5. 自动化的离线缓存方案
在 4.2 中为了验证预缓存静态资源到客户端内对 H5 页面渲染的影响,采用的是手动的方式。需要每次提前跟客户端沟通发版时间,提前将我们需要缓存的资源手动打包好发给客户端的同学。
这样的方式有很多缺点:
- 效率极低,每次都要手动的打包,客户端再手动的将需要缓存的资源“植入”;
- 需要增加缓存的页面也不灵活;
- 如果上线的页面出现了线上问题,修复后但是还不到客户端发版时间,这时前端上线就会导致缓存失效;
- H5 的优势就是可以随时上线。以上方法依赖客户端发版,无法动态更新;
有这么多的缺点,如果采用这种方式想要在我们的业务线推广显然是不可取的。下面我们就设计了一套自动化缓存静态资源到客户端内的方案。
5.1 客户端侧
要实现自动化缓存方案,客户端和前端肯定是要密切配合的。因为我主要是负责前端这块,客户端的内容这篇内容我就简单的介绍下。如果有同学想深入的探讨可以给我留言。
基本的流程可以参考下图:
- 当客户端启动或者是用户打开侧边栏时请求服务端 API 获取需要缓存的静态文件信息;
- 客户端在加载 h5 页面资源时加一层拦截,判断当前 url 请求的资源在客户端是否有缓存,如果有缓存直接使用本地缓存,不走网络。如果本地查找不到缓存,就直接走网络请求线上。
上面只是一个简单的流程。在实际的开发中还需要对 url 和 本地缓存文件建立映射关系,缓存的删除,以及缓存模块的下线等。
5.2 前端侧
前端项目上线流程基本都是下面这样子的。
- 本地或者是开发机(大部分公司都有专门的上线服务器来 build 代码)build 代码;
- 将 build 后的代码目录拷贝到线上服务器,有 CDN 服务器的话,同时将静态文件拷贝到 CDN 一份。
当我们在设计前端离线缓存方案的时候,一个需要考虑的重要因素就是,这个方案 不能对我们现有业务和 build,上线流程有太大的影响,换句话说我们的旧项目只要简单的修改就可以接入这一套离线缓存方案。
最后我们的的方案如下图所示:
上面的这套方案依赖的理论就是 CDN 就是来分担服务器压力,分流,提供文件下载,加速等而设计的。为了减少客户端在启动时对需要缓存的静态资源请求数,在 build 阶段会将需要缓存的资源统一打包成 zip 文件,供客户端下载。
- 每个业务线(git 仓库)都构建自己单独的离线 zip
- 离线 zip 跟随静态文件上线到 CDN。充分利用 CDN 下载资源的优势(要看下 CDN 的 nginx 配置支不支持对 zip 文件的请求)。
- 本地 build 的时存储文件信息,供以后上线 build 只对变化的资源做 zip(减少 zip 文件体积)。
6. 开发 webpack 插件
上一节的内容,可以知道我们是想将生成 zip 缓存包的时间放在 build 阶段。因为我们的所有项目都是采用 webpack 进行构建的。所以就开发了一个 webpack 插件来做这件事情。
在开发这个插件的时候我参考了 ak-webpack-plugin 这个插件的大部分配置和功能。因为ak-webpack-plugin
缺少我们业务需要的一些功能,所以我在它的基础上进行了二次开发以满足我们的业务需求。我们项目中使用的插件叫 webpack-static-chache-zip。这个插件我做 webpack2.0+
和 webpack4.0
的兼容。
插件的功能点如下:
详细的可以参考一下的配置:
const AkWebpackPlugin = require('webpack-static-chache-zip');
// 初始化插件
new AkWebpackPlugin({
// 最终生成的离线包名称,默认值是 `offline`
'offlineDir': 'offline',
// 生成环境的代码源,默认值 `output` webpack 编译产出生产环境代码的目录
'src': 'output',
// 是否保留生成的离线包文件夹(zip 包的源文件)
'keepOffline': true,
// datatype: [必填] 1(Android 乘客端),2(Android 司机端),101(iOS 乘客端),102(iOS 司机端)'datatype': '',
// terminal_id 业务名称,比如乘客端钱包,不能重复 具体查看 wiki http://wiki.intra.xiaojukeji.com/pages/viewpage.action?pageId=118882082
'terminal_id': '',
// 如果存在一个 data_type 对应多个 terminal_id 的情况。可以按照下面的方法以数组对象的方式列出来
'terminal_list': [
{
data_type: 1
terminal_id: 2
},
{
data_type: 1
terminal_id: 2
}
...
],
// 想要包含的文件路径,模糊匹配,优先级高于 excludeFile
'includeFile': [
'balance_topup',
'static',
'pay_history'
],
// 需要排除的文件路径 模糊匹配优先级低于 includeFile
'excludeFile': [
'repay_qa',
'test',
'pay_account',
'fill_phonenum',
'balance_qa',
'select_operator',
'payment_status'
],
// 缓存的文件类型,默认是 html js css 如果需要缓存其它类型文件 ['png', 'jpg']
'cacheFileTypes': [],
// 产品线 ID 为保持唯一性 接触模块请到 http://wiki.intra.xiaojukeji.com/pages/viewpage.action?pageId=272106764 查看自己使用的 module 已经被使用
// 接入是也请登记自己的 module
'module': 'passenger-wallet',
// 页面域名 , 可以配置多个域名,主要适用于一个文件上线后可能被多个域名使用的场景
// 比如:https://aaaa.com/a.html 和 https://bbbb.com/a.html 其实访问是同一个文件只是由于业务场景的不同使用不同的域名
'pageHost': 'https://page.didiglobal.com',
// urlpath
'urlPath': '/global/passenger-wallet/',
// 这个字段和下面的 patchCdnPath 较为特殊。比如我们打包产出的路径 /xxxx/xx/output/aaa/bb/index.html 在上线的时候实际上是将 output 目录 copy 到了
// 服务器上 原理上我们的页面的 url 应该是 https://page.didiglobal.com/aaa/bb/index.html 但是某些项目可能为了缩短路径查找 通过 ngxin 配置 我们实际访问
// 的 https://page.didiglobal.com/index.html 所在在这里可以配置 patchUrlPath: 'aaa/bb'
'patchUrlPath': '',
// cdn 域名 静态文件域名(js/css/html)如果不配置或者设置为空数组会默认使用 pageHost
'cdnHost': 'https://static.didiglobal.com',
// cdnpath 如果不设置会默认使用 urlPath
'cdnPath': '',
// 参考上面的 patchUrlPath 使用方法
'patchCdnPath': '',
// zip 文件的域名如果不设置会默认使用 cdnHost
'zipHost': '',
// zipPath 如果不设置会默认使用 cdnPath
'zipPath': '',
// 一个 H5 页面会跑在不同端内(比如我们巴西和 global 司机是两个单独的客户端),这两个端内的 h5 页面又有不同的
// page 域名和 static 域名 这个时候可以通过 otherHost 配置来设置环境页面和静态文件的域名,// 可以不设置或者为空
'otherHost': {
// 页面的域名
'page': 'page.99taxis.mobi',
// 可以设置单独的 cdn 域名如果不设置则与 page 域名相同
'cdn': 'static.99taxis.mobi'
},
// 压缩参数,详参 https://archiverjs.com
'zipConfig': {zlib: {level: 9} },
// 下列回调方法,可以直接使用 this.fs (fs-extra), this.success, this.info, this.warn, this.alert
// 在 拷贝文件到 offline 离线文件夹之前
beforeCopy: function () {},
// 在 拷贝文件到 offline 离线文件夹之后
afterCopy: function () {},
// 在压缩 offline 离线文件夹之前
beforeZip: function (offlineFiles) {// offlineFiles 在离线包文件夹内的文件路径信息},
// 在压缩 offline 离线文件夹之后
afterZip: function (zipFilePath) {// zipFilePath 最终生成的离线 zip 包路径}
})
6.1 插件工作流程
以下是插件的工作流程
- 获取 webpack 编译结束的时机
- 从编译产出目录拷贝文件到 offline 目录(要压缩为 zip 文件的目录),过程中会根据
include
和exclude
来判断文件 文件是否需要拷贝 - 如果已经有上线编译信息,就将这次需要压缩的 zip 文件与上次的文件对比
6.2 版本 diff
这里的版本 diff 是指,第一次上线全量的离线包之后,为了节省用户流量和用户手机的磁盘空间,第二次到第 N 次上线,我们应该上线的都是 diff 过后的 zip 包,并且需要将一些旧的文件信息告诉客户端端,让客户端进行删除操作(节省用户磁盘空间)。
文件信息和 diff 信息都应该使用存储服务(数据库)存储起来的。但是为了加快编译速度在编译和 diff 的过程中是不和服务器进行接口请求发生数据交换的。所有信息都以 json 文件
的形式存储在本地。在编译上线完之后将信息再统一发送给服务器存储。
diff 的基本流程如下图:
我们的需求是只在线上缓存 5 个版本的离线包。也就是说每次上线的版本都会跟之前的 5 个版本发生组合关系。根据高中时学习的组合公式,我们应该最多需要缓存 10 个 diff 版本和 1 个全量版本。也就是 11 个版本。
6.3 diff 原理
因为客户端在下载完离线缓存资源之后需要校验离线文件的完整性的。所以在生成 zip 包的时候插件会计算每个需要缓存文件的 MD5,客户端在下载完之后也计算 MD5,跟我的 MD5 进行比较。
因为我在 build 阶段会计算一次文件的 MD5,在 diff 的时候我会将这个 MD5 值作为一个文件的唯一标识。
版本的 diff 原理可以简化为一下两个数组的比较。如下图:
以下是核心的代码示例,可能我的实现不是最好的,如果大佬有更好的实现方法可以留言给我(手动感激)
// 有两个数组 oldArr 和 newArr ,oldArr 变成 newArr 后那些元素是新增的,哪些元素是应该被删除的
// 我们可以给元素添加字段 type 来做标记 0 标记需要删除 1 标识已有的 2 标记元素是新增的
const oldArr = [{tag: 1},
{tag: 2},
{tag: 3},
{tag: 4}
];
const newArr = [{ tag: 3},
{tag: 4},
{tag: 5},
{tag: 6}
]
const newArrTag = [];
// 需要删除的
const delArrList = [];
for (let i = 0; i < newArr.length; i++) {const newItem = newArr[i];
// 默认将 newArr 每一项都设置为新增
newItem.type = 2;
newArrTag.push(newItem.tag);
for (let m = 0; m < oldArr.length; m++) {const oldItem = oldArr[m];
if(newItem.tag === oldItem.tag) {newItem.type = 1}
}
}
for (let n = 0; n < oldArr.length; n++) {const oldItem = oldArr[n];
if(!newArrTag.includes(oldItem.tag)) {
oldItem.type = 0
delArrList.push(oldItem);
}
}
const resultArr = newArr.concat(delArrList);
console.log(resultArr)
// 以下是输出结果,将 oldArr 变化成 newArr 之后每个元素是新增,应该删除,还是使用旧的都做了标记
[{ tag: 3, type: 1},
{tag: 4, type: 1},
{tag: 5, type: 2},
{tag: 6, type: 2},
{tag: 1, type: 0},
{tag: 2, type: 0} ]
以上的代码就是版本 diff 的流程。当然与 webpack 和业务结合起来比复杂一些。有兴趣的同学可以查看前面的插件链接,读一下源码。
总结:
这篇文章介绍了我们团队为了优化 H5 页面在客户端加载速度,在静态文件离线缓存方面做得一些前期调研和实际开发工作,以及到最后工程化的实践。总体对我们页面加载速度的提升还是很明显的。只需要以 webpack 插件的方式接入,不需要对现有前端项目做太多改造,对前端工程师工作效率提高也很大。
篇幅有限,文章中有些内容可能介绍的还是比较笼统模糊,有兴趣的同学或者是想在自己的团队实践的同学,有问题可以留言讨论。
如果您觉得我写的还不错可以去 github 给我的插件和 blog 仓库点个 star。您的鼓励是我写作的最大的动力。