本文对兴趣部落项目前端开发中使用到的性能优化方式进行总结。兴趣部落项目是手机QQ(以下简称手Q)中最大的纯网页应用,每日有大量的用户访问,对于腾讯这样一个对产品有着极致要求的公司,性能优化是一个绕不开的话题。下面就对项目中所使用的性能优化的方式做一个梳理。
离线包
Hybrid App 最大的一个问题就是页面需要从网络拉取,所以加载速度慢,从而影响用户体验,兴趣部落中使用一个叫做 AlloyKit Mobile 的技术架构,这种技术架构中包含很多有利于提升性能的模块,这里要说的是离线包模块。
离线包模块可以允许手Q 永久缓存 HTML、JS、CSS 等静态资源,从而当用户访问网页时实现“秒开”,统计数据显示,使用离线包能够提升 85% 以上的速度。那有人问,既然这样, 为什么不把所有静态资源一起打个包,然后在 APP 发布时一起包进去呢?其实这样做就失去了 Hybrid App 的一个优势:快速迭代,因为网页中的静态资源的更新必须跟随 APP 更新的节奏,而我们知道 APP 更新频率相比于一般的网页应用是相当慢的,而离线包保持了网页应用快速迭代的优良传统。
那离线包是如何保持快速迭代的优良传统的呢?这要从离线包的更新原理来说了。首先需要事先说明的是,并不是所有的网页都被放入到离线包里的,那样的话,离线包还不得爆炸!一般只会放几个非常重要的网页,比如兴趣部落里就放三个最重要的页面到离线包里。对于这几个最重要的页面,请求资源时会在 URL 中带有 _bid 参数。每次请求带有 _bid 的 URL 时,都会询问客户端是否有离线包,有的话则走离线包,否则正常访问网络。另外,还会发出去一个请求,检查离线包有没有更新,如果离线包有更新,那么下载离线包,以便下次使用。当然内部还会更加复杂,比如会检查上次检查更新的时间,时间间隔太短的话就不会去检查更新,再比如离线包使用了 CMEM 做缓存等等。现在你知道为什么手Q 用了一段时间后体积变大了很多吧,逃)当然你可以在手Q 的“设置-聊天记录-清空缓存数据”来清除所有的离线包等缓存资源。
那每次更新是否将离线包里所有的东西都更新呢?非也。比如说这次新增了一个图片,新的离线包只会更新这一张图片,而不会更新所有离线包内容,这在离线包里被称为“增量包”。又有人问,如果是修改一个文件里的一小部分内容呢,那也需要将这一个文件更新吗?亦非也。其实增量包分为两种,一种叫“基于文件的增量包”,刚刚新增一张图片就属于这种情况。还有一种叫“bsdiff 增量包”,它采用二进制方式对比,生成的增量包仅仅包含变化的那一小部分,这使得更新效率更高。
关于离线包还有很多可以聊的,比如刚发布的离线包发现错误应该咋办?现在的策略是用户点击了“兴趣部落”后才会下载离线包,下次再次访问时才能使用离线包,如果我等不及,而想用户第一次就能访问离线包怎么办?还有等等的一系列问题。后面可以再写一篇博客,着重讲解一下离线包。
资源加载策略
在资源加载上做优化的根本原因就是网络通信时间占据了响应时间的大部分,很多的性能优化都是从这方面着手的,包括上面的“离线包”策略,但因为“离线包”实在太过重要,所以将之单独抽出来讲。下面讲一讲其他的资源加载策略。
构建
因为兴趣部落开始于 2014 年,所以项目采用 grunt 作为构建工具,以及使用 webpack 来打包,最近刚刚升级到 webpack2。我们使用 grunt 来对文件进行 minify、uglify,以及对部分文件进行 concat,从而有效地减小文件的大小以及向服务端请求的次数。简单介绍下上面三种构建的策略,minify 是指去除代码中的空白以及一些多余的字符,比如分号;uglify 从名字上看是“丑化”,处理之后的代码几乎无法阅读,乍一看它是用来混乱代码的,但它其实最大的作用是压缩代码,混乱代码可以使用 obfuscate;concat 就是将几个静态文件合并成一个文件。
另外,还使用 webpack 对文件进行打包,也能够减少向服务端请求的次数。前一段时间发布了 webpack3,其中一个特性就是 Scope Hoisting,简单点介绍就是 webpack2 以及 webpack1 会将每个模块都打包在一个单独的闭包中,这些闭包是拖累浏览器速度的一个因素,而 Scope Hoisting 解决这个问题的方案就是将不同的模块打包在一个大的闭包中,从而提升代码运行效率。不过技术新出,不能完全了解里面的坑,将之正式使用到这样一个大型项目中还需要一段时间。
资源按需加载
这个方式很常见,即在打开页面时不必要加载所有内容,而是根据自己的需要来加载。比如下面这个页面:
首页选择最上面的“排行”菜单时,只会加载类目列表以及明星列表的部分内容,当下拉明星列表时,还会根据需要再次向服务器请求列表后面的内容。
模块按需加载
还是以上面的图举例,当我点击屏幕下方的“部落“Tab 时,”部落“组件才会被加载,背后是利用 webpack 的 require.ensure()
实现的。
减少不必要的通信
很多时候存在多余通信的情况,比如最近我在做的一个“红点需求”,简单点讲就是本来需要向服务器发送大量请求,经过优化,将请求数量减少了到了几乎为 0,这些都可以说是不必要的请求。下一段是对这个过程的详细描述,自己都觉得有些啰嗦,不感兴趣的可以直接略过哈。
看下图,在兴趣部落“我的”面板,“我的” Tab 会根据“留言消息”、“任务领心”和“系统通知”的红点而确定,也就是说只要这三者有一个有红点,那么“我的” Tab 上就会有红点,一般情况下“留言消息”和“系统通知”没有红点,而“任务领心”产品经理给的需求是:有红点时点进去就消除红点,如果之后再做一些加心的操作,那红点重新出现。而加心的操作有很多,在很多页面用户都可能会做一些加心的操作,所以“任务领心”的红点会一会儿出现一会儿消失,紧随着的是“我的” Tab 上的红点也会一会儿出现一会儿消失。问题就出现在这,因为“我的” Tab 是首页的一部分,所以每次进入首页时都要跟服务器请求一次,而首页的 PV 是非常巨大的,相对应的红点状态的请求数量也非常巨大。所以想到的一个策略就是当用户操作加心时,利用 localStorage 设置一个标志位,回到首页时就读取 localStorage 里的标志位,根据这个标志位来判断是否显示红点,当点击“任务领心”时,就把标志位归位。这样,顿时减少了上亿的请求。
上面只是做一个简单的举例,项目中还有很多这样的地方。除此以外,如果一个页面需要发出多个请求,还会根据实际情况合并一些请求。这些细小的改动在请求数量较小时可能没多大影响,但每秒 PV 以万计时,将会使得请求数量大大减少,自然优化了性能。
缓存
localStorage:因为 WebView 的缘故,手Q也能同普通浏览器一样使用 localStorage。项目中有很多场景需要缓存,比如有些功能基于地理位置的,第一次获取到的经纬度数据可以使用 localStorage 缓存起来,当一定时间内再次需要获取地理位置时,直接使用缓存即可,除了缓存地理位置本身,还会缓存根据地理信息获得的数据,经过一定算法计算,在一定范围内可以直接从缓存中获取这些数据,避免重复请求数据;
静态资源缓存:一般对静态资源的 Response Header 加上 cache-control:max-age=3600
以及 expires:***
来缓存这些静态资源。
HTTP/2
看上面的图片,很多的资源加载的协议已经使用了 HTTP/2,再看 Connection ID 一栏,你会发现有的资源的 Connection ID 是一样的,这说明什么呢?这就是 HTTP/2 中的一个很重要的特性:多路复用。除了多路复用,当然还利用了 HTTP/2 的一些其他特性。
图片资源加载
图片占据了绝大部分的带宽,所以优化图片对性能优化作用巨大,我们在项目中用到了以下技术:
Base64:将一些小的、程序中用到的图片用 Base64 表示,减少请求次数;
WebP:一些图片使用 WebP 格式,使得图片压缩地更小;
sharpP:腾讯自己的一套压图方案,帧压缩效率比 WebP 高 31%,比 jpeg 高 43%。
DNS 预获取
网络请求中域名解析一般会花费很大部分时间,而加了 dns-prefetch
可以提前去解析资源的域名,这样可以减少网络请求时间。
可以在 HTML 页面中加入上面的代码来使用 DNS 预获取功能,有的支持 dns-prefetch
的浏览器并不需要第一行代码。
直出
目前业界普遍的做法就是前后端分离,服务端提供数据,客户端根据数据渲染页面,前后端分离在这里其实也就是数据与渲染逻辑分离。直出,几乎等同于服务端渲染,是在服务端将数据通过渲染逻辑生成一个页面,然后将生成的页面直接传给客户端。为什么这种方式会比客户端渲染有更高的性能呢?看两张图:
(图片来源:InfoQ)
上面两张图分别是服务端渲染(Server Side Rendering, SSR)和客户端渲染(Client Side Rendering)。我们仔细看一下图中请求资源到页面展示的一个简单过程。在 SSR 中,服务端接收到请求后就在服务端将 HTML 页面生成好,然后将之返回给客户端,客户端拿到完整的 HTML 很快便能够将页面渲染出来,用户便能看到页面,与此同时,客户端也在拉取 JS 等其他资源,当 JS 拉取到本地并执行完后,页面就变得可交互了。而客户端的过程是将数据、JS 等资源拉取到本地,由本地执行 JS,然后渲染页面,渲染出的页面可以立即交互。对比上面两种渲染方式可以看出,服务端渲染以客户看到页面为第一要务,也就是很多公司考核的首屏加载时间,交互可以放在次要的位置。
在兴趣部落中,我们利用“玄武”框架来逐渐实现服务端渲染。玄武为古代四大神兽之一,是乌龟和蛇的合体,乌龟象征长寿、稳重,蛇象征灵敏,命名寄托了开发者想把它做成一个即稳定又灵活可拓展的 Web 应用框架的希冀。玄武框架基于 koa,可以使得开发者只需要关注业务逻。
合并上报
但凡线上项目,没几个不上报数据的,上报数据就会进行网络通信,而数据上报跟性能息息相关,如何处理数据上报便是一个很重要的问题。兴趣部落同样也涉及很多上报,比如老板关心的 DAU(Daily Active User,日活跃用户数量),产品经理关心的 PV(Page View,页面浏览量)、UV(Unique Visitor,独立访客),运营关心的引流,开发关心的脚本错误量等等,那这些数据从哪里来?都是通过数据上报。而兴趣部落项目访问量巨大,现在日常 PV 达数十亿,每一次 PV 都会伴随数个上报请求,粗略计算,上报请求也要达上百亿。兴趣部落采取的做法是合并上报,前端收集上报请求,而不是立即发送,将这些上报放到一个队列中,延时对这些队列里的请求参数做压缩,生成一个统一的 URL,再将之发送至服务器中。采用这种方式后,请求次数减少超过 80%,流量也节省了 70%。
以上仅仅简单描述了这些实践的大概原理,对于每一种实践都可以单独抽出来述以长篇大论。因为实习不久,对团队项目还不是完全了解,以后再发现有什么好的实践就继续补充。如果你的团队有什么好的性能优化实践,希望不吝留言。