项目背景:
微社区是手游的游戏内嵌官网,作用是作为游戏的官方产出内容、用户产出内容的展示平台。提升用户的使用粘性和游戏的社区活跃度。
只在游戏内使用的微社区,最初使用的是PC官网的静态页面切换方式,后续为了增加整体使用体验,替换Vue单页面应用的开发方式。但是,在实际使用的过程中,还是发现了一些问题:
问题:微社区的入口场景较少,大部分是首页直接进入,少部分会在英雄界面设置英雄攻略页的直接入口。使用Vue挂载的方式,页面渲染顺序为Vue加载完成后再开始首次渲染,而Vue的依赖跟使用内容对于移动端一个单单用于显示内容的页面来说,成本有点高。对现有线上Vue官网的测试发现(QQ飞车手游、王者荣耀),Vue页面在3G下首次加载,首次渲染时间一般在2.5S后。存在较大的可优化空间。
17年10月份开始,针对王者荣耀的微社区进行了优化分析,但由于王者荣耀自身体系较大,而且上线时间较长,无法进行整体的优化。恰好上个月QQ飞车手游上线,负责飞车手游微社区的同事也发现使用既有Vue方案存在一定缺陷。在领导和同事的指导下, 针对QQ飞车手游进行了整体的优化。
技术选型:
1、问题分析:
首先,通过分析已有的方案,确定页面出现的问题:
问题:
- 页面使用Vue体系,但是只使用Vue作为模版文件,页面仍旧为多页面独立切换,每次切换都需要等待Vue资源初始化,页面加载、渲染、切换时间都较慢。
- 14年移动端需求增多,页面以开发速度为优,相对来说忽略了页面的性能优化,存在较多的优化提升空间。
2、方案尝试:
一开始发现问题后,列出了需要解决的问题,并尝试继续使用Vue的既有方案对微社区整体进行优化。因为静态服务器的限制,没法使用服务器渲染的方式优化,所以准备用预先框架+Vue初始化功能+路由切换页面的方式进行整站的整合。最后用微社区做了一个方式实验,电脑上测试有较完美的效果。但是,在手机上测试的时候发现使用这个方案存在一个严重的问题,使用预先框架渲染,切换为Vue的时候,会存在一个明显的重绘过程。使用多事例挂载的方式能够一定程度避免,但是这样Vue的页面维护也有一定的成本,所以最后放弃了使用Vue的方案。
3、方案确定:
使用Vue发现问题后,重新思考了Vue对微社区体系的作用,最后总结发现,Vue体系的作用其实只有一个模版渲染、路由控制的功能,微社区的数据不是经常刷新的类型。Vue优势的利用率其实并非那么高。
最后,为了更好的控制页面的加载和渲染,使用了多页面+pjax的方案来实现微社区体系的优化和无刷新实现。
首先上一些页面优化的结果:
优化方案:
- 资源控制
- 渲染加速
- 缓存控制
- pjax无刷新方案
- 页面数据监听对比
本次优化方案大概分上面4个层次,顺序不分前后。性能优化更多的实在优化过程中对一些资源、渲染、缓存机制细节的抉择。所以下面会按照对四个纬度的分析,然后罗列优化的一些细节和方法。
1、资源控制
对已有页面进行分析:
- 页面资源为PC思维的正常加载渲染,首次渲染在css加载完之后才进行首次渲染,而且首次喧嚷的只是背景颜色,Vue的内容框架在Vue初始化完成后才开始显示并加载进一步内容。
- 页面资源没有明显的主次层级,首次有效渲染在2S后才开始。
对页面进行资源控制的优化:
资源压缩:
问题:旧页面的ui.css,main.css存在较多的重复性样式,内页的样式也都在两个样式内,样式也没有进行压缩。
优化方式: 合并/压缩
1、css代码分离:将首页需要的样式单独抽出一个首页样式进行维护,因为整体为pjax的无刷新方案,故内页需要的样式可以通过整个css进行管理,在首页主要功能加载完成后的空闲时间再加载。
2、css重复样式去重压缩:将重复定义的css样式去除,已有的样式文件可以通过firefox的样式去重工具或者node的clean-css或其他压缩工具进行处理。
3、图片base64:将较小的少量需要repeat的图片进行base64编码(base64在移动端可能会增加渲染成本,这个要慎重考虑。)
4、JS文件合并请求:图片服务器有合并请求的功能,可以将在图片服务器的js请求合并一起请求,可以一定程度提升资源的加载效率。(文件过大可能反而降低速度,因为多个请求并行发送的时间不一定比合并的慢)注意:合并请求的文件是直接简单的合并到一个请求内返回,js分号缺失可能导致js报错。
5、文件压缩:对页面html,css,js进行压缩,对页面可压缩的图片进行压缩。
加载控制:
1、资源内嵌
入口页面的渲染速度最为重要,所以选择将堵塞首次渲染的css通过include的方式嵌入,通过include的方式,是为了保证页面后续的可维护性。
2、资源预加载、预连接
提前加载:页面渲染必须的关键资源,这里对页面必须的雪碧图资源进行了preload的处理。(preload多个图片资源,第二个之后的图片资源请求的优先级可能会被下调,故需要对资源的必须性进行选择。preload只有最新的安卓支持,所以可以当作备选项处理)
预连接:移动端ios支持页面的preconnect处理,对页面即将进行连接的域名进行了preconnect的处理。(资源预连接有较多的接口,这里经过实验发现preconnect的效果比dns-prefetch稍好,所以虽然兼容性稍差,还是使用了preconnect进行预连接,根据caniuse的显示,最新的QQ浏览器支持preconnect属性。)
3、延迟加载
首屏资源优先初始化:首屏渲染必须资源优先初始化
次屏资源延迟加载:次屏包括用户操作才能出现的界面,页面整体的滑动,tab的滑动等等,当然首屏也可能有自动滚动的广告位或其他内容,这个可选择性的进行处理。
延迟加载的内容应该是页面交互功能所必须的内容,或者不加在会影响用户操作或者使用的功能。
4、按需加载
一些较长的列表图片、tab切换后的内容、点击后弹窗显示的图片内容,都可以选择性的进行按需加载。这些内容应该是不影响用户交互的内容,比如长图片列表隐藏在显示区域外的部分,可以在互动之后再进行加载。
资源缓存
1、数据缓存
首屏显示内容进行localstorage数据缓存,提前加载新闻内容,初始化后获取最新内容对新闻内容进行覆盖替换。(同时也是app sheel框架渲染优化的一部分,提前渲染缓存数据,能提升用户感知上的加载速度。)
2、文件缓存
文件缓存控制,这个就不细说了,还在已有的规则下处理。
service worker控制页面缓存,可以通过service worker对页面的资源进行缓存控制,service worker可以代理请求,直接从本地缓存中获取文件,而且缓存版本前端可以控制,可以显著提升请求的加载速度。(service worker目前只有安卓支持,安卓下微信和手Q等使用tbs内核的浏览器,都能使用service worker的特性。)
2、渲染优化
首屏渲染加速:
1、入口页面css/js内嵌,处理方式为上面资源控制的内嵌处理。
2、非首次渲染必须的JS defer处理:将js分为页面首次渲染必须的部分,和页面主体功能框架渲染必须的部分,首次渲染必须的部分include在页面内,主题功能框架渲染所需的资源进行defer处理。这样可以避免js加载对页面渲染的堵塞
3、使用App Shell,在感知上提前页面的加载速度,和数据缓存结合,提升页面的加载速度感知。
渲染代码优化:
1、css代码优化:
检测重复css:这里的工作其实跟对css文件体积的控制类似,将页面一些重复的同名样式替换掉,我使用的是clean-css直接替换并压缩。有自己习惯的工作流程的同学也可以用自己的工作流程工具进行处理。
按功能维护css文件:对于无刷新页面体系来说,页面css可以按照入口页css跟内页css的纬度进行维护,由于飞车是基于微社区的样式模版搭建的,所以内页样式还是分为两个css进行维护。入口页(既首页)则直接将首页所需的css单独抽离成一个index.css进行维护。
2、JS代码逻辑优化:
减少渲染阻塞:有时候页面存在长时间的js逻辑,可能是较长的循环,也可能是一些异步或者同步的内容加载,会导致页面长时间执行js,导致页面渲染堵塞。这种情况可以将代码进行分块,使用setTimeout(func(), 0 )的方式,在存在页面操作的位置将代码分块,这样可以将部分js强制移到下个时间循环执行,也可以保证页面不会被长时间的js执行所堵塞。
模版框架选择:飞车原来使用Vue作为整体框架,Vue的一大便捷就是可以直接使用模版实现数据的双向绑定。但是上面也分析过使用Vue存在的一系列问题,飞车最后我选择了一个相当简单的模版函数tppl作为页面模版内容的实现。
模版函数:
var tppl = function(tpl, data){
var fn = function(d) {
var i, k = [], v = [];
for (i in d) {
k.push(i);
v.push(d[i]);
};
return (new Function(k, fn.$)).apply(d, v);
};
if(!fn.$){
var tpls = tpl.split('{@');
fn.$ = "var $=''";
for(var t = 0;t < tpls.length;t++){
var p = tpls[t].split('@}');
if(t!=0){
fn.$ += '='==p[0].charAt(0)
? "+("+p[0].substr(1)+")"
: ";"+p[0].replace(/\r\n/g, '')+"$=$"
}
fn.$ += "+'"+p[p.length-1].replace(/\'/g,"\\'").replace(/\r\n/g, '\\n').replace(/\n/g, '\\n').replace(/\r/g, '\\n')+"'";
}
fn.$ += ";return $;";
}
return data ? fn(data) : fn;
}
模版语法:
{@ for(x in data){ @}
{@ var item = data[x]; @}
<li>
<a class="a-tgax" href="web201708/detail.shtml?nid={@= item.iNewsId @}" title="{@= item.sTitle @}"
class="">
<div class="ig-newslist_cate">
<span class="ig-newslist-tag " class="{@=newsClass(item.iType) @}">
<i>{@=newsType(item.iType) @}</i>
</span>
<span class="ig-newslist-date">{@=dateShorter(item.sCreated) @}</span>
</div>
<h5 class="ig-newslist-title">{@=item.sTitle @}</h5>
</a>
</li>
{@ } @}
当然,这种模版可能过于简单,但是对于页面资源来说,1kb的大小可以灵活的嵌入页面使用(juicer、doT等模版的大小大多在10K左右,若要直接嵌入,还是会稍微有点纠结),最最重要的是,这种模版已经足够我自己使用了,需要更多更能也可以考虑自己进行拓展。
3、缓存控制
缓存控制的一部分方案在上面资源控制的内容里已经有所提及,这里详细的说一下使用service worker进行的页面资源控制。
service worker使用的是最新的fetch api,可以在浏览器进程中直接代理页面发出的请求,并匹配本地缓存,如果本地缓存存在则直接返回,不发出请求。service worker请求可以完全拜托慢网络的限制,甚至可以在离线的情况下使用(当然,这不符合微社区的应用场景。)
本次优化使用的是sw-precache进行service-worker.js的配置生成,将页面资源分为
初始资源:入口页面html,基础功能js,整站通用雪碧图,初始资源会在service-worker.js安装的时候直接下载到本地缓存。
其他资源都设置为页面请求时缓存:
通配库资源:通配资源包括页面使用的库js,cdn上的图片文件。
页面版本资源:版本资源是页面每个版本经常会修改的资源,配置为按照时间戳控制的资源,并设置最长存在时间为7天,这样可以避免资源存在长缓存的情况,可以通过版本号控制资源强制刷新。
页面图片资源:页面图片资源则为页面一些封面图等新闻类图片资源,控制为请求时加载,配置最长时间30天,最大数量100,超过数量自动删除最旧资源。
runtimeCaching: [
{
urlPattern: /gtimg.cn/,
handler: 'cacheFirst'
},{
urlPattern: /ossweb-img.qq.com\/c/,
handler: 'cacheFirst'
},{
urlPattern: /ossweb-img.qq.com\/images\/js/,
handler: 'cacheFirst'
},{
urlPattern: /speedm.qq.com\/ingame\/all_seventh\/web201708\/assets/,
handler: 'cacheFirst',
options : {
cache : {
name : 'assets'+ (new Date().getTime()),
maxAgeSeconds : 604800,
ignoreSearch : false
}
}
}{
urlPattern : /itea-cdn.qq.com/,
handler: 'cacheFirst',
options : {
cache : {
name : 'detail',
maxEntries : 100 ,
maxAgeSeconds : 864000
}
}
}
],
本来考虑对内页html进行离线缓存,但是最后发现非首次安装的html,需要访问两次才能获取到最新的版本,考虑到页面的版本控制,只对入口页首次安装时就下载的index.shtml进行缓存。
staticFileGlobs: [
'web201708/assets/js/common-test.js',
'index.shtml'
]
使用service-worker,只需要通过配置后sw.js文件,并在项目跟目录的html中初始化激活即可,由于service-worker可能存在较强的缓存,所以在初始化的时候设置了强制开关(设置变量确认是否注销service-worker),保证有问题的时候可以强制注销service-worker。
var cancleSW = false;
if('serviceWorker' in navigator && !cancleSW) {
navigator.serviceWorker
.register('service-worker.js') //注册跟目录下的sw进程
.then(function() { console.log('Service Worker Registered'); })
.catch(function(err){
console.log(err);
});
}else {
navigator.serviceWorker
.getRegistration()
.then(function(res){
console.log(res)
if(res){
return res.unregister()
}else{
return
}
}).then(function(d){
console.log(d);
});
console.log('sw unable');
}
4、PJAX无刷新方案
微社区是使用场景较为特殊的移动端页面,需要尽可能的提升用户的页面体验,包括页面间切换的体验,这正是用Vue体系来搭建微社区体系的主要原因之一。飞车的优化基于王者移动端官网的使用经验,选用了另外一种无刷新的方案PJAX,同样可以保证页面间的无刷新体验,同时也可以保证后续内页的单独入口时页面资源的可控性。
和多页面切换不同,使用无刷新方案时,需要考虑页面切换过程中页面功能、性能、内存的优化处理,下面从几个方面说明:
PJAX原理及注意点:
原理:利用ajax + pushstate的方式,用ajax直接获取目标html的制定dom区域,用目标区域替换现有区域,在利用pushstate的特性替换页面路径,从而实现页面的无刷新跳转。
注意点:无刷新内容位置控制,使用pjax可以灵活控制刷新的位置,但是由于页面其他样式内容一般不做重新加载,所以需要考虑到页面切换过程中的页面样式控制。(飞车使用的是通用框架,故页面间样式兼容性较好,若页面间结构差别较大,可考虑将处理一个页面间通用的结构,将可以通用的dom结构抽离出来复用。)
通用资源抽离:
PJAX的无刷新机制下,页面只有目标区域内的模块会进行更新。所以,需要将页面的功能模块放在frames框架内,保证每次切换页面主体功能都能顺利初始化成功。
重要JS代码内嵌:涉及到页面自身的功能,可以选择嵌入页面之中,也可以选择做外链文件单独控制,飞车由于每个页面功能都存在一些区别,所以选择了直接嵌入页面控制,这样可以保证页面单独访问时的最快渲染。
通用代码抽离:将整站都依赖的库文件抽离在PJAX的更新框架外,这样每次进入站点,这些公用资源也只需要请求一次,保证了文件的复用,减少页面请求。
页面css也按照上面说过的资源处理方式处理,将css分为入口页css和内页css,入口页css用include的方式内嵌加载,内页css则在入口页空闲时候加载。这样页面切换过程中,就不用再度去请求这些css文件。
同时,内页中则用外链的方式引入这些内页css文件、公用库JS,确保单独进入页面或者刷新页面时不会因为缺失css导致页面错误。
模版复用:页面存在较多相同结构的数据渲染模版,对于模版完全一样的内容,将模版进行复用,根据传入参数对具体位置进行渲染,减少页面体积。
代码逻辑:
无刷新切换和SPA一样,页面切换过程中页面框架并不会重新刷新,所以上一个页面的js变量可能会依旧保留在window对象下,长期使用可能造成页面卡顿或者崩溃。Vue有自己的控制机制,使用PJAX的话需要自己考虑这些内容的处理。
页面数据控制:微社区现在多用v4接口动态获取数据,所以会有较多的功能接口处理,我对于飞车的处理,是将页面的功能和变量统一存储在同一个变量中,页面切换时直接替换。确保页面不会因为数据作用域问题出错。
页面初始化接口控制: 上文说过控制页面渲染的流程,保证页面首次渲染可以获取有效内容,所以我将页面初始化渲染和进一步渲染分离,初始化渲染内嵌内容的时候直接渲染。进一步渲染通过pjax的end事件激活(相当于页面的onload事件)。
页面事件、变量解绑:现在移动端的页面功能比较多,可能存在较多页面内的事件绑定或者状态监听,过多未注销的事件绑定在多页面切换的时候可能造成已删除的dom结构仍旧常驻在内存中,造成进程卡顿或者崩溃。可以在chrome的开发者工具的memory工具中查看是否又过多的无用dom引用。
点击具体的detached dom tree可以看到dom是被哪个变量引用的。通过切换过程中对变量的事件解绑和变量释放来释放对应的dom结构。JS的变量引用也可以在分析后的snapshot中查看。
统计逻辑:
在多页面切换过程中,因为页面框架整体不变,所以需要使用和之前Vue一样的上报方式,即所有页面上报都使用虚拟上报,虚拟上报的上报方式是在pgvMain的时候加上虚拟上报参数即可。(注意:tcss组件的上报逻辑是只要有一次非虚拟上报,后续所有虚拟上报也不上报pv、uv,所以需要注意,避免pv/uv上报失败)
pgvMain({ repeatApplay: 'true'});
//需要额外上报虚拟路径,可以加上virtualURL参数
pgvMain({ repeatApplay: 'true',virtualURL: "/ingame/all/detail/"+type+"/"+did+".shtml"})
对于使用v4获取详情id的通用页面上报,可以使用虚拟路径上报,自己定义一个虚拟的路径上报即可。
5、页面性能监听对比
在对已有的页面做性能优化时,需要做好前期的数据埋点,优化后使用同样的数据埋点上报,这样可以获取准确的性能反馈,通过数据确认自己的优化对哪方面数据更加有用。
上报内容:
页面dom开始时间:在html结构的头部加上new Date()时间戳
页面dom完成时间:在页面内容dom结束,jsdom之前加上new Date()时间戳
页面onload时间:在页面onload事件触发时,加上new Date()时间戳
上面3个数据是比较简单的时间上报,onload - dom开始 大致可以得到页面的onload时间,dom完成 - dom开始 大致可以得到页面dom的加载时间,这两个数据可以得到大概的页面性能数据。
上面3个时间节点都是页面请求完成之后,开始解析页面内容时候才进行定义的,跟页面的实际性能数据可能会有一点点偏差,如果想要更加精准的数据,可以使用performance timing API ,performance还有获取详情请求细节的api,但是部分ios不支持,这个有需要的可以自己选择使用。
if (window.performance.timing) {
var timingData = window.performance.timing;
function getLoad() {
if (timingData.domComplete != 0) {
clearTimeout(getLoadLoop);
pageLoadTime = timingData.loadEventEnd - timingData.navigationStart,
//页面加载完成时间
connectTime = timingData.responseEnd - timingData.requestStart,
//页面请求花费时间
renderTime = timingData.domComplete - timingData.responseEnd;
//页面dom渲染时间
reportSpeed('21964-1-1', time_load - time_start, time_all - time_start, pageLoadTime, connectTime, renderTime);
} else {
getLoadLoop = setTimeout(function () { getLoad() }, 50);
}
}
getLoad();
}
飞车使用performanc上报了页面加载完成时间、请求花费时间和页面dom渲染时间,其中因为timing.loadEventEnd的数据在页面请求未完成前是0,所以需要循环获取对应数据,有时间数据之后再上报具体数据。
页面3S留存率:在进入页面后,设置一个3S钟的延迟上报,获取页面3S后的留存率,可以获取一定比例的页面3S后留存率。
Strangeloop在对众多的网站做性能分析之后得出了一个著名的3s定律“页面加载速度超过3s,57%的访客会离开”