Authored by 周奇琪

Update h5-performance-optimization.md

## 移动端WEB性能优化探索实践
在移动互联网的时代里,对于一个web站点来说,移动端的用户体验尤为重要。现代web站点的设计和开发都是以移动优先作为第一原则,我们也专门为了移动端的web站点做了相应的优化和提升。而网页的打开速度和页面的流畅度,对于用户是否长时间访问至关重要。所以我们在移动端的站点通过一系列的方法,最终为了快速打开页面展示网页内容,触达用户,同时能流畅的浏览网页。
移动端的硬件条件,网络条件相对于桌面端,会复杂的多,设备类型多样,硬件配置参差不齐,分辨率碎片化,网络状况在移动过程中稳定性,速率都会变化,而对于一个页面到达用户的终端展示,会经过,用户发起请求,服务端接受请求,服务端处理请求,返回响应内容,在用户终端的浏览器展示内容,用户操作页面发起其他页面时间,而这个过程中任何一个环节的延迟都会造成性能瓶颈,降低用户继续访问的可能性,所以我们在服务器端,浏览器端,网络,多个方面做了一系列的优化工作。
### WEB服务端优化
有货的WEB端主要使用了nodejs,基于后端服务提供的RESTful接口服务来实现的前后端分离,这里的服务端优化主要是指在nodejs实现的web服务端进行优化。
```
WEB前端(nodejs)
|
------------
|
后端(接口服务,API GATEWAY)
```
![前后结构图](http://upload-images.jianshu.io/upload_images/3232335-a2f60048a1f19243.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
优化的目的是提升服务端的响应和并发能力,充分发挥nodejs的异步非阻塞的特性,主要从以下几个方面去优化。
#### 接口服务调用的优化
对于一个页面展示的路由,要处理这个路由,可能需要调用多个接口并且进行进行界面逻辑的处理。大体过程:
![一次访问的时序图](http://upload-images.jianshu.io/upload_images/3232335-37fe918ef10c675c.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
````
用户发起请求
\| |\
路由控制调用服务层
| |\
\| |
服务层调用接口服务->进行View Object处理
|
\|/
后端接口返回
````
* **接口合并** 我们对于一个页面调用可以合并的接口,进行接口合并,减少接口调用次数,如:以商品详情页为例,商品的一些特性,可以在一个接口返回,尽可能的减少接口调用的个数,因为每次接口的处理都有网络IO,对象序列化,压缩和解压的过程。
* **接口合并** 我们对于一个页面调用可以合并的接口,进行接口合并,减少接口调用次数,如:以商品详情页为例,商品的一些特性,可以在一个接口返回
* **接口异步调用** 但是并不是所有的接口都可以合并,对于无法合并的接口,我们尽量使用node的异步非阻塞的特性,进行异步调用,同时调取多个接口,而最终的调用耗时取决于最慢的接口
* **接口异步调用** 但是并不是所有的接口都可以合并,对于无法合并的接口,我们尽量使用node的异步非阻塞的特性,进行异步调用,同时调取多个接口,而最终的调用耗时取决于最慢的接口。这里要说明一点:**对于接口依赖,如A接口依赖B接口的返回结果,像这种情况,我们最好梳理下接口设计,减少这样的串行调用,因为这样,调用耗时是多个接口耗时的总和**
这里要说明一点:**对于接口依赖,如A接口依赖B接口的返回结果,像这种情况,我们最好梳理下接口设计,减少这样的串行调用,因为这样,调用耗时是多个接口耗时的总和。**
* **减少接口交互数据** 返回的数据较多的情况下,会导致JSON序列化,数据批量对象处理,产生额外的性能损耗。可以做下接口返回数据结构的精简,返回必要的字段(页面会展示用到的数据)以及可以调整返回item个数。从而达到减少数据的返回消息体的大小。此外请求接口时需要gzip压缩,可以大大的减少网络传输的时间,尽管需要解压会消耗一部分CPU的时间,但是对接网络IO的损耗,还是值得的。
可以分享一组基准压测数据,在调用接口使用gzip和不使用gzip的QPS数据
```
xxx
```
* **减少接口调用次数** 如何减少接口调用次数呢?对于一个页面,可能就会存在调用必要的接口,在这里我们使用到了缓存机制,对于热数据进行接口缓存,我们使用了一些内存数据库,同时对于一些规格数据可以进行进程级的缓存(如:导航信息,品类信息等)。缓存是有一定原则的:第一,需要容易命中的数据,第二,可以被缓存的数据,数据更新频率是可控的。通过缓存机制,一部分接口调用就会走到缓存,减少的接口调用的IO。此外缓存的数据可以是对接口数据处理后的视图对象,同时也减少的数据处理的时间。
* **内部服务调用DNS缓存** 我们的内部服务使用域名方式,为了提高服务的灵活配置,但是需要内部DNS服务器进行域名解析,这个是有一定耗时的,所以我们在DNS这块加了DNS应用端cache,减少DNS解析的时间。
#### 业务处理的优化
现在我们主要的服务端业务处理,主要对于页面逻辑的处理,如路由控制,会话处理,视图对象处理,模板渲染。我们在这些处理过程中进行了一些优化。
如何发现node的性能问题,主要可以使用cpu-profile进行cpu处理堆栈的抓取,然后使用chrome的dev-tools进行火焰图的分析,找到性能瓶颈。
* **计算密集型操作使用原生实现** js是不擅长计算密集型的操作,如Hash处理,加密解密,压缩解压,像这些操作可以直接使用nodejs提供的原生实现(crypto, Zlib)
... ... @@ -94,15 +87,14 @@ result:5d3b7d53fdd4daaa2d75370e8a5d1789
````
差距还是比较明显的。
* **模板渲染的优化** 我们在实际使用过程中,发现模板的渲染是十分消耗性能的,特别的模板的预处理过程,如果预处理过程是在用户访问过程中去处理,会慢不止一个数量级,所以我们把预处理的过程提前了(改造了hbs),在启动web应用时,已经预编译完成。同时我们发现handlebars的一些默认配置属性,如缩减处理,在字符串拼接过程中会损耗一定的性能,所以可以关闭html片段的缩减。
此外,我们还把可以缓存的html片段进行进程级的缓存,性能提升显著,可以把一些不怎么会变的html公共部分进行缓存。通过内部缓存刷新机制进行定时刷新html片段。
#### nginx的优化
* **启用page cache** 使用了nginx的proxy_cache模块,配置了一些缓存机制,不同页面路由的缓存时长会读取node服务在http头里面返回的max-age时间。
```
proxy_cache cache_one_wap;
proxy_cache_valid 200 1m;
... ... @@ -111,7 +103,6 @@ proxy_cache_key $host$uri$args;
add_header X-Cache-Status $upstream_cache_status;
```
然后我们会在应用服务添加max-age配置的中间件,对路由进行拦截装饰http header:
```` javascript
const cachePage = {
'/': x * MINUTE,
... ... @@ -122,40 +113,97 @@ const cachePage = {
...
}
````
> 另外要注意一个设置nginx缓存的时候,如果有服务端设置cookies的情况下,并且以服务端cookies的值作为标识用户会话信息,不要设置`proxy_ignore_headers "Set-Cookie";`,不然缓存会导致会话信息窜读的情况。
> 另外要注意一个设置nginx缓存的时候,如果有服务端设置
cookies的情况下,并且以服务端cookies的值作为标识用户会 话信息,不要设置`proxy_ignore_headers "Set-Cookie";`,不然缓存会导致会话信息窜读的情况。
* **全站HTTPS** 为什么要上全站https呢,这个主要考虑到https可以防止中间人攻击以及内容劫持,提高网站的访问安全性。但是因为多了SSL/TLS的服务端和浏览器端的处理,当然在性能方面也会有一点的下降,但是我们同时也启用HTTP/2,而主要的特性:多路复用 (Multiplexing)多路复用允许同时通过单一的 HTTP/2 连接发起多重的请求-响应消息。另外PWA里面的service worker 也是必须要求网站的协议是https的,同时也是为了这个做了铺垫,HTTPS是现代WEB的发展趋势。至于PWA不是本文讨论的重点,可以在后续其他的主题展开。
* **全站HTTPS** 为什么要上全站https呢,这个主要考虑到https可以防止中间人攻击以及内容劫持,提高网站的访问安全性。但是因为多了SSL/TLS的服务端和浏览器端的处理,在性能方面也会有相应的下降,但是我们同时也启用HTTP/2,而主要的特性:多路复用 (Multiplexing)多路复用允许同时通过单一的 HTTP/2 连接发起多重的请求-响应消息。另外PWA里面的service worker 也是必须要求网站的协议是https的,同时也是为了这个做了铺垫,HTTPS是现代WEB的发展趋势。至于PWA不是本文讨论的重点,可以在后续其他的主题展开。
## 浏览器端优化
移动终端五花八门,导致过重的浏览器的处理和效果,会导致体验的不一致,特别是安卓手机,所以我们在浏览器端的策略是,尽量轻量化网页,当前页面只处理当前必要的内容多页面的方式。这个和现在google提出的开源项目AMP是一个思路。
#### 首屏直出优化
* **直出文档,简化dom结构**
* **首屏渲染,js延迟执行**
* **首屏只加载所需样式**
* **优化直出服务端处理时间**
从用户发出请求到页面完全展示,一般来说在网络正常基本上1s以上,但是如果页面打开耗时超过1s,用户流失的概率就会线性上升。所以移动端秒开至关重要,所以我们的思路是建设百屏时间,尽快把浏览器可视区域展示出来,就是所谓的首屏,所以我们就从以下几个方面做了优化。
* **直出文档,简化dom结构** 服务端进行HTML渲染输出,只处理首屏需要的HTML,并且简化DOM的树状结构,如:
```html
<div class="nav">
<ul>
<li>xxxx</li>
<li>xxxx</li>
</ul>
</div>
```
可以简化成
```
<ul class="nav">
<li>xxxx</li>
<li>xxxx</li>
</ul>
```
在页面只保留必要的DOM,此外,可以估算下世面手机分辨率,确定最大首屏的输出可视区域的DOM,如果需要滚屏到第二屏的,可以延迟通过Ajax获取内容加载。减少DOM,可以减少HTML的输出,当然更重要的浏览器的布局和渲染的时间大大减少。当然首屏的静态资源和样式要优先加载,下个关键点就是首屏只加载所需样式和静态资源。
* **首屏只加载所需样式和静态资源** 光有DOM的处理是远远不够的,要从白屏到展示完整的首屏,还需要样式和静态资源。所以要优先加载样式和静态资源,所以直接把公共样式中首屏用到的样式抽离出来,并且首屏用到的样式,直接在html页面内置。此外,图片和字体等其他需要展示的部分,优先加载,促使首页快速展示出来。
* **首屏渲染,js延迟执行** 当首屏渲染的时候,这时候js的执行可能会阻塞渲染的线程,所以为了减少对浏览器主线程的渲染过程,尽量延迟进行js执行,特别是操作DOM的情况,不然首屏展示过程中会产生额外的重布局和重绘,js引入或代码直接放到页面的底部,在body之后,在html之前。
* **优化直出服务端处理时间** 另外再强调下直出的关键,服务端的处理wait尽量减少,对于首屏直出至关重要。
#### 图片优化
* **图片质量和体积控制**
* **WEBP的运用**
* **图片质量和体积控制** 对于移动终端来说,分辨率相当于桌面会小很多,首先会降低图片的分辨率,以及图片的DPI值,第二步会降低图片的质量,以保证图片的体积变小。
* **WEBP的运用** webp不是所有的浏览器都支持,目前支持webp的浏览器,如下:
![webp支持度](http://upload-images.jianshu.io/upload_images/3232335-b760e0e7388befa7.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
参见:[https://caniuse.com/#feat=webp](https://caniuse.com/#feat=webp)
所以,我们的做法是对于需要js加载的图片,进行webp的判断,如果支持,就是直接加载webp的格式图片,如果不支持,采用默认的jpg的格式。
```javascript
if (window.supportWebp && (/format\/png/i.test(query) || /format\/jpg/i.test(query))) {
imgUrl = imgUrl.replace(/format\/png/i, 'format/webp').replace(/format\/jpg/i, 'format/webp');
}
```
![webp加载截图](http://upload-images.jianshu.io/upload_images/3232335-31b9cf08e10607e8.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
#### 浏览器端缓存优化
* **缓存优化(max-age)**
* **预加载和懒加载**
当存在缓存,可以减少浏览器的再次请求,大大提升了网页的打开速度,一个优秀的缓存策略可以缩短网页请求资源的距离,减少延迟,并且由于缓存文件可以重复利用,还可以减少带宽,降低网络负荷。那么下面我们就来看看服务器端缓存的原理。
* **缓存优化(max-age)** 页面的缓存状态是由http协议的header决定的,我们主要使用了max-age(单位S),设置缓存的最长有效时间,使用的是时间长短,例如说我设置max-age=60,也就是说在请求发出后的60秒内,浏览器再次请求时不会再请求服务器,而是从浏览器缓存中读取数据。
![max-age](http://upload-images.jianshu.io/upload_images/3232335-dfa89a039e628693.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
* **预加载和懒加载** 预加载和懒加载是一对好兄弟,用的好,可以极高提升浏览器端的体验,就是要确定在何时预加载,何时懒加载。我们主要在浏览器首屏结束后,当浏览器相对idle的时候,可以预加载下一屏即将展示的内容。当用户在即将触发下一屏时,下一屏的数据或DOM已经stay by了,自然体验会流畅很多,但是在预加载是需要一个度,因为一个页面的DOM过多,对于浏览器占有的内存也会过多,预加载最好是用户即将触发需要浏览的内容,如第二屏,轮播后面的内容,tab页等。懒加载的运用场景主要还是为了减少单次DOM渲染的大小,对于当前页面的非可视区域,当需要展示或用户事件触发才进行加载。所以懒加载和预加载,在不同的场景下会有不同的运用,前提是保障页面的流畅度。
#### CSS,JS的优化
* **多页面css和js构建**
我在项目构建主要采用了webpack的工具链,对css,进行依赖管理和构建打包,最小化css,js,并针对我们现有的多页面项目进行多入口的分包管理。
* **多页面css和js构建** 打包代码如下,便利js的源文件目录,构建各个页面模块的js,所有的页面会包含两个js文件,libjs(全局公用的js),xxx.js(当前页面特有的js),css也是一样。这样每个页面的js和css都会最小化,同时我们也对这些个静态字符串文件进行gzip压缩,当然这些文件会按照版本进行静态存储,以及CDN的缓存。
```javascript
// 构建各模块子页面JS
// 新的生成规则 module/page/index.js
shelljs.ls(path.join(__dirname, '../js/**/index.js')).forEach((f) => {
const dir = _.slice(f.split('/'), -3); // [modulename, page, index.js]
// Important
// 生成规则:module.page: './js/module/page/index.js'
entries[`${dir[0]}.${dir[1]}`] = path.join(__dirname, `../js/${dir.join('/')}`);
});
```
#### DOM优化
* **简化DOM**
页面流畅度和DOM渲染和操作息息相关,渲染流程大致如下:
* 处理HTML 标记并构建 DOM 树。
* 处理 CSS 标记并构建 CSSOM 树。
* 将 DOM 与 CSSOM 合并成一个渲染树。
* 根据渲染树来布局,以计算每个节点的几何信息。
* 将各个节点绘制到屏幕上。
![image.png](http://upload-images.jianshu.io/upload_images/3232335-e7d65369781c9b3a.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
可以使用DEVTOOLS分析整个渲染过程中那块存在性能问题。
![image.png](http://upload-images.jianshu.io/upload_images/3232335-f4b3e0dd47b8b79c.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
* **简化DOM,DOM操作优化** 简化DOM可以减少渲染过程的时间,优化DOM操作,可以减少重布局和重绘的时间。简化DOM在上面的首屏直出已经介绍过相应的做法。这里主要说下DOM操作的优化,第一,减少DOM操作次数,可以把多次DOM操作在js的执行过程中生成好结果HTML,一次插入到DOM;第二,尽量在使用不在页面DOM树里面直接操作,可以脱离文档流的DOM中进行操作,可以使用fragment,一次插入文档流中。当然现在的react和vue都使用虚拟DOM的技术,通过diff算法进行通用化的DOM操作。这个也不失是一种效率高效的做法,但是对于一些不易优化的页面,还是需要人为干预和操作DOM使其性能最好。
* **减少重布局和重绘** 第一,要减少布局调整,当您更改样式时,浏览器会检查任何更改是否需要计算布局,以及是否需要更新渲染树。对“几何属性”(如宽度、高度、左侧或顶部)的更改都需要布局计算。第二,绘制的复杂度、减小绘制区域:除 transform 或 opacity 属性之外,更改任何属性始终都会触发绘制。绘制通常是像素管道中开销最大的部分;应尽可能避免绘制。通过层的提升和动画的编排来减少绘制区域。可以使用 Chrome DevTools 来快速确定正在绘制的区域。打开 DevTools,按下键盘上的 Esc 键。在出现的面板中,转到“rendering”标签,然后选中“Show paint rectangles”。每次发生绘制时,Chrome 将让屏幕闪烁绿色。如果看到整个屏幕闪烁绿色,或看到您认为不应绘制的屏幕区域,则应当进一步研究。
![image.png](http://upload-images.jianshu.io/upload_images/3232335-735e1f6746f12675.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
* **减少重布局和重绘**
* **页面动画优化** 尽量使用CSS3的动画,使用 transform 和 opacity 属性更改来实现动画。使用 will-change 或 translateZ 提升移动的元素。避免过度使用提升规则;各层都需要内存和管理开销。此外,需要减少动画的图层,每多一个图层,会多一份内存占有和管理的开销。如果一定要使用js的动画,建议使用:requestAnimationFrame。
* **页面动画优化**
移动web端的优化以上每个点如果展开去讲,都可以单独写一篇文章,我们分别在以上方面做了优化,并且,也产生了比较不错的效果,移动端的打开速度和体验都有了不错的提升,但是优化这件事情是永无止境的,没有最好,只有更好,需要开发者探究根本勇于创新,达到更好的更优的境地。
* **DOM操作优化**
我们后续,还可以在web的基本技术点上深挖,同时在PWA以及AMP等现代web新思维的多个方面大家积极面对,继续探索,在未来的web前端之路可以走的更好,提供更优的用户体验,创造更高社会价值。
... ...