在移动互联网的时代里,对于一个 web 站点来说,移动端的用户体验尤为重要。现代 web 站点的设计和开发都是以移动优先作为第一原则,我们也专门为了移动端的 web 站点做了相应的优化和提升。而网页的打开速度和页面的流畅度,对于用户是否长时间访问至关重要。我们在移动端的站点通过一系列的方法,最终为了快速打开页面展示网页内容,触达用户,同时能流畅的浏览网页。
移动端的硬件条件,网络条件相对于桌面端,会复杂的多,设备类型多样,硬件配置参差不齐,分辨率碎片化,网络状况在移动过程中稳定性,速率都会变化,而对于一个页面到达用户的终端展示,会经过,用户发起请求,服务端接受请求,服务端处理请求,返回响应内容,在用户终端的浏览器展示内容,用户操作页面发起其他页面时间,而这个过程中任何一个环节的延迟都会造成性能瓶颈,降低用户继续访问的可能性,所以我们在服务器端,浏览器端,网络加载,多个方面做了一系列的优化工作。
WEB 服务端优化
有货的 WEB 端主要使用了 nodejs,基于后端服务提供的 HTTP 接口服务来实现的前后端分离,这里的服务端优化主要是指在 nodejs 实现的 web 服务端进行优化。
优化的目的是提升服务端的响应和并发能力,充分发挥 nodejs 的异步非阻塞的特性,主要从以下几个方面去优化。
接口服务调用的优化
对于一个页面展示的路由,要处理这个路由,可能需要调用多个接口并且进行进行界面逻辑的处理。大体过程:
- 接口合并 我们对于一个页面调用可以合并的接口,进行接口合并,减少接口调用次数,如:以商品详情页为例,商品的一些特性,可以在一个接口返回,尽可能的减少接口调用的个数,因为每次接口的处理都有网络 IO, 对象序列化,压缩和解压的过程。
- 接口异步调用 但是并不是所有的接口都可以合并,对于无法合并的接口,我们尽量使用 node 的异步非阻塞的特性,进行异步调用,同时调取多个接口,而最终的调用耗时取决于最慢的接口。
这里要说明一点:对于接口依赖,如 A 接口依赖 B 接口的返回结果,像这种情况,我们最好梳理下接口设计,减少这样的串行调用,因为这样,调用耗时是多个接口耗时的总和。 - 减少接口交互数据 返回的数据较多的情况下,会导致 JSON 序列化,数据批量对象处理,产生额外的性能损耗。可以做下接口返回数据结构的精简,返回必要的字段(页面会展示用到的数据)以及可以调整返回 item 个数。从而达到减少数据的返回消息体的大小。此外请求接口时需要 gzip 压缩,可以大大的减少网络传输的时间,尽管需要解压会消耗一部分 CPU 的时间,但是对接网络 IO 的损耗,还是值得的。
可以分享一组基准压测数据,在调用接口使用 gzip 和不使用 gzip 的 QPS 数据
- 减少接口调用次数 如何减少接口调用次数呢?对于一个页面,可能就会存在调用必要的接口,在这里我们使用到了缓存机制,对于热数据进行接口缓存,我们使用了一些内存数据库,同时对于一些规格数据可以进行进程级的缓存(如:导航信息,品类信息等)。缓存是有一定原则的:第一,需要容易命中的数据,第二,可以被缓存的数据,数据更新频率是可控的。通过缓存机制,一部分接口调用就会走到缓存,减少的接口调用的 IO。此外缓存的数据可以是对接口数据处理后的视图对象,同时也减少的数据处理的时间。
- 内部服务调用 DNS 缓存 我们的内部服务使用域名方式,为了提高服务的灵活配置,但是需要内部 DNS 服务器进行域名解析,这个是有一定耗时的,所以我们在 DNS 这块加了 DNS 应用端 cache,减少 DNS 解析的时间。
业务处理的优化
现在我们主要的服务端业务处理,主要对于页面逻辑的处理,如路由控制,会话处理,视图对象处理,模板渲染。我们在这些处理过程中进行了一些优化。
如何发现 node 的性能问题,主要可以使用 cpu-profile 进行 cpu 处理堆栈的抓取,然后使用 chrome 的 dev-tools 进行火焰图的分析,找到性能瓶颈。
- 计算密集型操作使用原生实现 js 是不擅长计算密集型的操作,如 Hash 处理,加密解密,压缩解压,像这些操作可以直接使用 nodejs 提供的原生实现(crypto, Zlib)
以下是一组使用原生和 js 的 md5 处理性能对比:
返回结果:
native md5::31.59 result:5d3b7d53fdd4daaa2d75370e8a5d1789 js md5::181.54 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; proxy_cache_min_uses 1; proxy_cache_key $host$uri$args; add_header X-Cache-Status $upstream_cache_status;
然后我们会在应用服务添加 max-age 配置的中间件, 对路由进行拦截装饰 http header:
const cachePage = { '/': x * MINUTE, '/boys': x * MINUTE, '/girls': x * MINUTE, '/kids': x * MINUTE, '/lifestyle': x * MINUTE, ... }
另外要注意一个设置 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 不是本文讨论的重点,可以在后续其他的主题展开。
浏览器端优化
移动终端五花八门,导致过重的浏览器的处理和效果,会导致体验的不一致,特别是安卓手机,所以我们在浏览器端的策略是,尽量轻量化网页,当前页面只处理当前必要的内容多页面的方式。这个和现在 google 提出的开源项目 AMP 是一个思路。
首屏直出优化
从用户发出请求到页面完全展示,一般来说在网络正常基本上 1s 以上,但是如果页面打开耗时超过 1s,用户流失的概率就会线性上升。所以移动端秒开至关重要,所以我们的思路是减少白屏时间,尽快把浏览器可视区域展示出来,就是所谓的首屏,我们就从以下几个方面做了优化。
直出文档,简化 dom 结构 服务端进行 HTML 渲染输出,只处理首屏需要的 HTML,并且简化 DOM 的树状结构,如:
<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 尽量减少,对于首屏直出至关重要。
图片优化
图片质量和体积控制 对于移动终端来说,分辨率相当于桌面会小很多,首先会降低图片的分辨率,以及图片的 DPI 值,第二步会降低图片的质量,以保证图片的体积变小。
提高 CDN 缓存命中,第一,减少缩略图的尺寸规格,第二,尽量和其他端的图片规格保持一致,如 APP 端,小程序。
WEBP 的运用 webp 不是所有的浏览器都支持,所以,我们的做法是对于需要 js 加载的图片,进行 webp 的判断,如果支持,就是直接加载 webp 的格式图片,如果不支持,采用默认的 jpg 的格式。
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'); }
浏览器端缓存优化
当存在缓存,可以减少浏览器的再次请求,大大提升了网页的打开速度,一个优秀的缓存策略可以缩短网页请求资源的距离,减少延迟,并且由于缓存文件可以重复利用,还可以减少带宽,降低网络负荷。那么下面我们就来看看服务器端缓存的原理。
缓存优化(max-age)
页面的缓存状态是由http 协议的header 决定的,我们主要使用了max-age(单位S),设置缓存的最长有效时间,使用的是时间长短,例如说我设置max-age=60, 也就是说在请求发出后的60 秒内,浏览器再次请求时不会再请求服务器,而是从浏览器缓存中读取数据。
预加载和懒加载
预加载和懒加载是一对好兄弟,用的好,可以极高提升浏览器端的体验,就是要确定在何时预加载,何时懒加载。我们主要在浏览器首屏结束后,当浏览器相对idle 的时候,可以预加载下一屏即将展示的内容。
当用户在即将触发下一屏时,下一屏的数据或DOM 已经stay by 了,自然体验会流畅很多,但是在预加载是需要一个度,因为一个页面的DOM 过多,对于浏览器占有的内存也会过多,预加载最好是用户即将触发需要浏览的内容,如第二屏,轮播后面的内容,tab 页等。
懒加载的运用场景主要还是为了减少单次DOM 渲染的大小,对于当前页面的非可视区域,当需要展示或用户事件触发才进行加载。所以懒加载和预加载,在不同的场景下会有不同的运用,前提是保障页面的流畅度。
DNS 预读取
DNS 预读取配置的 DNS 的解析,可以减少 DNS 的次数,也可以加速不同域名的资源加载,目前支持的浏览器还是比较多的。
配置也很简单:
<link rel="dns-prefetch" href="//cdn.yoho.cn"> <link rel="dns-prefetch" href="//static.yohobuy.com"> <link rel="dns-prefetch" href="//img10.static.yhbimg.com"> <link rel="dns-prefetch" href="//img11.static.yhbimg.com"> <link rel="dns-prefetch" href="//img12.static.yhbimg.com"> <link rel="dns-prefetch" href="//img13.static.yhbimg.com"> <link rel="dns-prefetch" href="//analytics.m.yohobuy.com"> <link rel="dns-prefetch" href="//search.m.yohobuy.com"> <link rel="dns-prefetch" href="//list.m.yohobuy.com"> <link rel="dns-prefetch" href="//guang.m.yohobuy.com">
当然最好的减少 DNS 的时间是减少站点使用 DNS 的数量, 我们会去掉部分二级域名。
CSS,JS 的优化
项目构建主要采用了 webpack 的工具链,对 css,进行依赖管理和构建打包,最小化 css,js,并针对我们现有的多页面项目进行多入口的分包管理。
多页面 css 和 js 构建
打包代码如下,便利 js 的源文件目录,构建各个页面模块的 js,所有的页面会包含两个 js 文件,libjs(全局公用的 js),xxx.js(当前页面特有的 js),css 也是一样。这样每个页面的 js 和 css 都会最小化,同时我们也对这些个静态字符串文件进行 gzip 压缩,当然这些文件会按照版本进行静态存储,以及 CDN 的缓存。
// 构建各模块子页面 JS // 新的生成规则 module/page/index.js shelljs.ls(path.join(__dirname, '../js/**/index.js')).forEach((f) => { const dir = _.slice(f.split('/'), -3); // Important // 生成规则:module.page: './js/module/page/index.js' entries[`${dir[0]}.${dir[1]}`] = path.join(__dirname, `../js/${dir.join('/')}`); });
DOM 优化
页面流畅度和 DOM 渲染和操作息息相关,渲染流程大致如下:
- 处理 HTML 标记并构建 DOM 树。
- 处理 CSS 标记并构建 CSSOM 树。
- 将 DOM 与 CSSOM 合并成一个渲染树。
- 根据渲染树来布局,以计算每个节点的几何信息。
- 将各个节点绘制到屏幕上。
可以使用 DEVTOOLS 分析整个渲染过程中那块存在性能问题。
简化 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 将让屏幕闪烁绿色。如果看到整个屏幕闪烁绿色,或看到你认为不应绘制的屏幕区域,则应当进一步研究。
页面动画优化
尽量使用 CSS3 的动画,使用 transform 和 opacity 属性更改来实现动画。使用 will-change 或 translateZ 提升移动的元素。避免过度使用提升规则;各层都需要内存和管理开销。此外,需要减少动画的图层,每多一个图层,会多一份内存占有和管理的开销。
如果一定要使用 js 的动画,建议使用:requestAnimationFrame。此外,能不用页面动画的场景尽量不要使用动画,如果一定要使用,可以简化动画渲染的过程。
之前我们使用过一个 js 插件,iScroll 就是一个案例,页面内初始化了多个 iScroll 实例,特别在安卓手机上特别卡顿,最后,我们的解决办法是自己使用 css3 动画和 touch 事件简单轻量的实现需要滑动的部分,对于页面滚动部分使用了原生的 scroll,保证了不同终端体验一致。
总结
移动 web 端的优化以上每个点如果展开去讲,都可以单独写一篇文章,我们分别在以上方面做了优化,并且,也产生了比较不错的效果,移动端的打开速度和体验都有了不错的提升,普遍打开的时间提升了 30-50%,在网络稳定的情况下,基本上服务端的耗时在 50ms 以内,首屏时间在 500ms 以内,但是优化这件事情是永无止境的,没有最好,只有更好,需要开发者探究根本勇于创新,达到更好的更优的境地。
我们后续,还可以在 web 的基本技术点上深挖,同时在 PWA 以及 AMP 等现代 web 新思维的多个方面大家积极面对,继续探索,在未来的 web 前端之路可以走的更好,提供更优的用户体验,创造更高社会价值。
感谢徐川对本文的审校。
评论