2016-11-17 11:16:09

[性能优化实践] 单页应用

SPA(单页应用)的性能优化策略, 与传统MVC框架直出的策略有很多不同之处.
本文介绍博客前端的SPA优化策略, 以及一些常见的通用优化办法

SPA策略

XHR过滤不需要的属性

以本博客首页为例, 请求的post模型中有summary, content, markdownContent三个较大的属性, 对首页而言contentmarkdownContent并没有用

本博客依此做了一个过滤, 未优化前一次请求为160ms, 优化后一次请求仅为40ms. 不过,SSR首屏后, 这种优化带来的加速不大.

单向数据流

为了做到每个请求都物尽其用, 单向数据流是最好的选择

假如有两个不同的组件, 同时依赖一个数据, 不用单向数据流的话, 就得发出两个相同的请求.
我认为在这种情况下, 不能用LocalStorage来解决, 毕竟两个组件哪一个首先挂载好这件事是没有定论的

本博客前台未使用单向数据流前, 向/api/option发出了两次相同请求, 每个请求50ms, 优化后获得了50ms的提升

服务端渲染(SSR)

SSR是SPA的终极目的地了, 除了可以解决SEO外, 还可以大幅度降低首屏加载速度.
至于为什么不说非首屏的加载速度, 要知道SPA非首屏的页面, 大概率只需要发一个XHR而已, 因此只需要解决首屏渲染的问题

SSR依靠的服务端可以是express或者koa2, 这个服务端推荐与数据库放在内网甚至是同一个服务器上, 这样通过内网访问, 可以大幅度缩短XHR请求的耗时.

展示类型的单页应用的SSR中,获取到的数据中,_id属性用处也不大,同样可以过滤掉,能省1k是1k

本博客通过SSR, 将首屏访问时间从平均800ms降低至平均300ms, 其他简单页面比如关于页面, 甚至降低到了200ms以内

分块懒加载

分块异步加载也是SPA的一种常见首屏优化策略, 指将首屏中不需要的组件分割开, 在访问其他路径时才加载服务器的另一个块来获取需要的组件

不过它与SSR有一些冲突的地方, 例如目前vue2的SSR与分块懒加载是不能共存的, 不过好消息是vue2的一名核心开发者正在解决这个问题

2017-02-07更新

分块懒加载已经可以和SSR共同使用,另外,博主写了一篇优化分块懒加载与SSR的首屏访问的文章,效果非常强大,能够将所有路由全部分块出去并完全不影响首屏加载速度

SSR组件级缓存

通过id+更新时间缓存了列表中的每一条数据, 因此给博客首屏带来了50ms的优化。
这种id+更新时间的列表缓存套路出现各个地方,虽然没有新意,但是效果真的相当好。 因为说实在,毕竟虚拟DOM不也就一个抽象一点的缓存嘛。

减少首页数据

以屈屈为例, 他的首屏仅仅几百字, 而我的首屏之前总共有几千字。
特别是SSR需要将vuex初始状态也序列化到页面中。因此,减少首页数据时,对SSR的单页应用来说,首屏减少的数据量应该会正好多一倍,这就很关键了。

尽量减少DOM后, 页面大小减少了30kb, gzip从96kb降到了90kb

合并js文件

这个策略是针对未开启http2多路复用的SPA的. 特别是打包时一定要合并vendor

开了多路复用后, 打包反而会增加耗时, 毕竟复用的那一个TCP链接能省不少时间

mongoDB分页优化

mongoDB的skip是非常慢的.

有一种优化策略如下:

假设每页10条, 那么请求第一页的时候, 额外多请求1条, 即请求11条. 如果返回11条, 则说明有下一页, 如果不足11条, 则说明无下一页 接下来, 请求第二页的时候, 将第一页的第10条的id拿到作为基准id, 通过find({"_id":{"$gt": 'the id'}}).limit(11)查询下一页的11条.

这样有一个弊端, 即无法直接访问非第一页, 因为没有基准id.
不过, 像博客这种小流量的系统, 我们可以将第1, 第11, 第21等等的_id通过redis缓存起来,在客户端请求分页时直接用redis中的_id去查询接下来的10条即可

避免使用大型类库

都用上SPA了, 还是对大型类库说拜拜吧.
react和vue都支持通过挂载ref直接获取dom, 这时候就该原生JS出马了.
我觉得这一点应该是前端的进步, 无论前端怎么变, 原生JS永远是大厦的根基

tree-shaking打包减少无用代码

webpack2默认支持这玩意, 修改babelrc后, 前台打包从142k降到了122k 虽然我觉得一脸懵逼: node_modules里没有es6 Module的库, 它是怎么压缩到的??

不过, tree-shaking与分vendor打包有冲突, 因为分了vendor的话, 所有vendor的库都不会被tree-shaking到, 以本博客前台为例, tree-shaking且不分vendor时打包为一个122k的app块, tree-shaking且分vendor时打包22k的app块与120k的vendor块.

这时如果开启了http2多路复用的话, 真的不好说是分vendor复用的tcp链接节省时间, 还是tree-shaking省下的20k代码更节省时间. 但是在不考虑http2的情况下, tree-shaking毫无争议地比分vendor要好

serialize-javascript

尤大在官方范例里使用了serialize-javascript,它可以将函数和正则也写入JSON中,但是配了isJSON: true,这会让serialize-javascript关闭序列化函数和正则的功能。除此之外,它也会将字符串中的HTML标签编码,防止xss攻击。

{"haxorXSS":"<script>"}
  ||
  \/
{"haxorXSS":"\\u003C\\u002Fscript\\u003E"}

本博客反而不需要标签编码,这会让首屏数据增加1KB(172KB => 171KB),而且速度也比原生JSON.stringify慢。

虽然不到1ms的优化不起眼,但是博主还是那句话,积少成多嘛,这总比深入nginx去压缩协议层的数据来获得1ms的优化来说容易得多吧?

轮子之心

这里的优化纯粹是用大小更小的库来替换已用的库,因为官方库提供的很多功能我们根本没用到,这里介绍四种常用的轮子

Ajax库

一个非管理系统的前端项目,GET与POST请求已经可以满足几乎所有需求了,利用XHR,我们本身可以轻易实现一个1KB大小的Ajax库,gzip后比axios会少12KB代码

function get (url, cb) {
  let requestTimeout, xhr
  try {
    xhr = new window.XMLHttpRequest()
  } catch (e) {
    try {
      xhr = new window.ActiveXObject('Msxml2.XMLHTTP')
    } catch (e) {
      return cb(new Error('XHR: not supported'))
    }
  }

  xhr.onreadystatechange = function () {
    if (xhr.readyState !== 4) return
    clearTimeout(requestTimeout)
    cb(xhr.status !== 200 ? new Error('XHR: server response status is ' + xhr.status) : false, xhr.responseText)
  }

  xhr.open('GET', url, true)
  xhr.setRequestHeader('Content-type', 'application/json')
  xhr.send()
}

状态库

vuex的思想其实蛮简单的,全局状态即Vue.prototype.$store.state,在不考虑redux思想以及边界情况下时,自实现一个满足项目需求的状态库是有一定可行性的。

路由库

vue-router内置了三种路由格式,其他两种并不是我们需要的。

MVVM核心库

这个更加极端了,即使用api兼容的MVVM库替换原库,react有比较成熟的preact与rax等,vue博主倒是没有仔细关注过。

closure compiler

closure compiler是比tree shaking更极端的压缩库了,它会在高级模式下更改对象的属性名,通常会减小超过10%的打包体积。

但是,它要求库的提供者必须提供interface来让closure compiler成功压缩,否则打包出来的是完全没法用的

通用策略

图片优化策略

  1. favicon没有必要大到128x128, 只需要32x32就足够了, 这里可以省下15kb左右大小
  2. SPA应用的静态图片资源一般都放在cdn上, 可以在上传时通过cdn提供的多媒体处理功能, 来生成缩略图, 这样除了加速访问外, 还可以打水印防盗
  3. 经常使用的图片可以换为webp格式, 以博客的logo为例, jpg格式时为20kb, webp格式仅为3kb.

但是, webp格式比较新, 有些旧浏览器并不支持(说的就是我还在用的火狐v41.0), 可以用如下代码将不支持的webp换为其他格式

img.onerror = ({ target }) => (img.src = target.currentSrc.replace('.webp', '.png'))

这里的策略实际上相当无脑,当启用SSR后,可以在服务端根据UA来判断用户是否支持webp,再传输对应的图片

LocalStorage数据缓存

LocalStorage我不大敢用它, 没有校验策略的话它简直是个定时炸弹呀, 而且SPA单向数据化之后, 需要的数据都可以从全局状态树中获得, 没有必要上LocalStorage

不过用好了还是非常强大的, 例如屈屈大神的博客, 首屏加载150ms, 我也是醉了, 硬是将一个MVC框架直出的博客弄成了单页应用, 还保证了首屏没有无用的全局状态数据(通常20kb)

使用Gzip压缩

Gzip通常能够压缩70%的文件大小, 而且对nginx来说配置也很简单, 默认的几行就开启好了. 对于单页应用来说, 请不要忘记让rest api也被gzip, 以博客一个返回3k大小的xhr为例, gzip后能省2ms, 比起压response的header得到同样2ms的加速, 总要轻松得多吧?

DOM加载优化

屈屈大神的博客这一点很明显, 首页的摘要不是Markdown解析出来的html标签, 而是纯粹的文本, 可以说是优化到了细节处

nginx升级到最新版

比较老的linux发行版自带的nginx, 版本一般都比较低, 例如本博客之前就是1.4.4, 从1.4.4升级到1.11.5后, 即使未开启https和h2, 访问速度都稳定并提高了一大截

开启HSTS

HSTS可以避免http到https跳转, 除了安全之外, 也能够省去一次http完整请求的时间, 对首屏访问也非常重要.

别忘了最后去申请HSTS preload list

启用HTTP2多路复用

图片懒加载

这个也是常见的通用策略, 略过

效果

之前, vue2单页应用, 并且没开SSR, 首屏强刷976ms:

之后, 按本文的顺序优化后, 并且开启SSR, 首屏强刷163ms:

实际上163ms的图片是在优化完成前截的. 现在换了延迟极低的hk服务器后, 博主这边甚至能压到150ms以内

本文链接:https://smallpath.me/post/spa-opti

-- EOF --