前端性能优化之----静态文件客户端离线缓存_20191110

前端性能优化之----静态文件客户端离线缓存

1. 前言

上次的文章给大家分享了怎么在webpack打包阶段去将自己的项目优化到极致。文章链接:将webpack打包优化到极致_20180619

前端优化是无止境的,这篇文章主要介绍我在前端工程化方面对项目优化的一些探索和经验总结。

2. 探究业务的瓶颈

H5页面的性能瓶颈,网络因素几乎可以占到80%。不管是减小产出文件体积,还是使用HTTP2.0或者是PWA等都是减少网络对H5页面加载的影响。

我们部门产品主要是在拉美国家使用。拉美国家的用户有个比较明显的特点:

  1. 网络环境较差(2G、3G用户依然存在);
  2. 用户手机型号主要是安卓,且机型较旧(代表着webview较旧);

下图是从公司的h5性能监控平台上查询到的信息。

从上图可以看出大部分用户所在的网络环境还是很不错的。但是前线的同事还是经常会反馈我们的H5页面在用户手机上打开缓慢。我们讨论后有两个猜测:

  1. 在已经优化了文件体积的前提下,是不是我们的page域名(html域名)和static域名(静态文件)在当地的CDN解析时间较慢?
  2. 虽然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的单页应用,单页应用的一个最大的缺点这时就暴露了出来,“首屏”慢。

  1. 假如用户是第一次进入某个页面,慢是可以理解的,因为需要新下载JS,CSS,HTML(这就是常说的“首屏慢”);
  2. 第N次进入,假如客户端缓存的CSS,JS等静态资源失效也会慢;
  3. 某个页面做了修改,上线后也会慢。(这种情况可以跟1 类似,算是首屏)

由以上我们推理出来3种情况,我们提出了一个假设:

假如通过某种方式提前缓存渲染h5需要的html和静态文件(JS,CSS)是不是可以加快首屏的渲染速度,具体又能提高多少,是否有大规模推广的必要?。

4.2 数据结果

为了验证 4.1 中的猜想,我们需要数据的支撑。紧接着我们找客户端同学跟我们一起做了几次实验。

在客户端发版的时候,提前将一些“单页”H5渲染所需要的资源跟随客户端一起打包发版,然后对这些页面的加载速度进行监控。

下面是我们对预缓存到客户端页面的前后渲染时间统计对比得出的一些数据:

由以上数据作为理论支撑,得出的结论是提前缓存静态资源到客户端内,可以明显加快页面的渲染速度。

5. 自动化的离线缓存方案

4.2中为了验证预缓存静态资源到客户端内对H5页面渲染的影响,采用的是手动的方式。需要每次提前跟客户端沟通发版时间,提前将我们需要缓存的资源手动打包好发给客户端的同学。

这样的方式有很多缺点:

  • 效率极低,每次都要手动的打包,客户端再手动的将需要缓存的资源“植入”;
  • 需要增加缓存的页面也不灵活;
  • 如果上线的页面出现了线上问题,修复后但是还不到客户端发版时间,这时前端上线就会导致缓存失效;
  • H5的优势就是可以随时上线。以上方法依赖客户端发版,无法动态更新;

有这么多的缺点,如果采用这种方式想要在我们的业务线推广显然是不可取的。下面我们就设计了一套自动化缓存静态资源到客户端内的方案。

5.1 客户端侧

要实现自动化缓存方案,客户端和前端肯定是要密切配合的。因为我主要是负责前端这块,客户端的内容这篇内容我就简单的介绍下。如果有同学想深入的探讨可以给我留言。

基本的流程可以参考下图:

  1. 当客户端启动或者是用户打开侧边栏时请求服务端API获取需要缓存的静态文件信息;
  2. 客户端在加载h5页面资源时加一层拦截,判断当前url请求的资源在客户端是否有缓存,如果有缓存直接使用本地缓存,不走网络。如果本地查找不到缓存,就直接走网络请求线上。

上面只是一个简单的流程。在实际的开发中还需要对 url 和 本地缓存文件建立映射关系,缓存的删除,以及缓存模块的下线等。

5.2 前端侧

前端项目上线流程基本都是下面这样子的。

  1. 本地或者是开发机(大部分公司都有专门的上线服务器来build代码)build 代码;
  2. 将build后的代码目录拷贝到线上服务器,有CDN服务器的话,同时将静态文件拷贝到CDN一份。

当我们在设计前端离线缓存方案的时候,一个需要考虑的重要因素就是,这个方案不能对我们现有业务和build,上线流程有太大的影响,换句话说我们的旧项目只要简单的修改就可以接入这一套离线缓存方案。

最后我们的的方案如下图所示:

上面的这套方案依赖的理论就是 CDN 就是来分担服务器压力,分流,提供文件下载,加速等而设计的。为了减少客户端在启动时对需要缓存的静态资源请求数,在build阶段会将需要缓存的资源统一打包成zip文件,供客户端下载。

  1. 每个业务线(git仓库)都构建自己单独的离线zip
  2. 离线zip跟随静态文件上线到CDN。充分利用CDN下载资源的优势(要看下CDN的nginx配置支不支持对zip文件的请求)。
  3. 本地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 插件工作流程

以下是插件的工作流程

  1. 获取webpack编译结束的时机
  2. 从编译产出目录拷贝文件到offline目录(要压缩为zip文件的目录),过程中会根据 includeexclude来判断文件 文件是否需要拷贝
  3. 如果已经有上线编译信息,就将这次需要压缩的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 。您的鼓励是我写作的最大的动力。

你可能感兴趣的