“非主流”的纯前端性能优化

做者:ChenJing

性能优化一直是前端研究的主要课题之一,由于不只直接影响用户体验,对于商业性公司,网页性能的优劣更关乎流量变现效率的高低。例如 DoubleClick by Google 发现:javascript

  • 若是页面加载时间超过 3 秒,53% 的用户会选择终止当前操做并离开
  • 网站加载时间在 5 秒内的发布商比 19 秒内的广告收入至少多出一倍

同时,性能优化学习的不断深刻,也一样是一个专业前端工程师的进阶之路。不过,随着 HTTP/2 和 SSR(服务端渲染)的不断普及,早期雅虎 35 条中的不少内容彷佛已经显得有些过期,很多纯前端的细节优化方案也逐渐被认为微不足道。css

可是,今天,咱们依然想谈几个容易被不少前端工程师忽视,但却卓有成效的纯前端优化细节(技术框架以 Vue 为主)。html

1、self

这里想说的 self 并非  WindowOrWorkerGlobalScope 下的 self,或者说 window 的替身,而是 const self = this  中的 self,或者说对象缓存。前端

在几乎全部数据类型皆对象的 JavaScript 中,能有效下降属性访问深度的对象缓存是前端优化最基础的课程,即便在浏览器已经进化到即便没有明确地声明缓存对象,内核解析时也会自动缓存以增长解析效率的今天。vue

良好的对象缓存不只仅只是为了不写出下面的代码:java

const obj = {
        human: {
                man: {}
        }
}
 
obj.human.man.age = 18
obj.human.man.name = 'Chen'
obj.human.man.career = 'programmer'

还有一个更加剧要的缘由:有效减小工程上线时压缩后的代码量!node

首先,看一下上面代码压缩后的结果:react

var ho={human:{man:{}}};ho.human.man.age=18,ho.human.man.name="Chen",ho.human.man.career="programmer";

而后,对属性对象 man 作一次变量缓存:git

const obj = {
        human: {
                man: {}
        }
}
const man = obj.human.man
 
man.age = 18
man.name = 'Chen'
man.career = 'programmer'

再次压缩代码后的结果:github

var ho={human:{man:{}}},yo=ho.human.man;yo.age=18,yo.name="Chen",yo.career="programmer";

能够看到,对象缓存使得代码容量有了明显的减小。

那么,对于实际的项目,变量缓存对整体代码又会带来多大容量的缩减呢?回到小节讨论的开始,咱们一块儿感觉一下不缓存的 this 对象带来的直观震撼吧。

vivo 某个项目的一个 js 文件:

整个文件存在 3836 个 this,保存到本地大概 375 KB。若是缓存 this,代码压缩时 4 个字符的 this 会被压缩成单字符变量。

整个文件的存储大小下降到 364 KB,一个 this 对象缓存便可让压缩后的代码容量降低超过 10 KB,注意,仅仅只是一个 this 对象!

2、Object.freeze()

咱们知道,在 Vue 组件或者 Vuex 的 state 中定义的数据是响应式的,当这些数据发生改变时,会通知 View 层更新界面。

首先,简单回忆一下 Vue 响应式数据的原理,以下图。

其中:

每个组件 component 都拥有一个本身的观察者 watcher,内部封装了 Vue.prototype._render() 函数

每个响应式数据属性都拥有一个本身的依赖 dep 收集器,用以收集依赖该数据的组件的 watcher

响应式数据的三个基本步骤:

(1)组件数据的响应化流程:component(options) -> observe(data) -> Reactive Data

  • component 的数据部分,全部的 options.data 属性经过 observe() 中的 Object.defineProperty() 函数转换成访问器属性
  • 在每个数据属性被Object.defineProperty() 转换时的函数闭包空间中,存在一个本身的 dep 收集器

(2)响应式数据的依赖收集流程:component(template) -> watcher(vm._render())(get) -> Reactive Data

  • component 的模板字符串,经过 Vue compiler 后生成渲染函数 vm._render()
  • 每个 component 拥有一个本身的观察者 watcher,watcher 中封装了vm._render(),组件初次渲染时:

    (a)watcher 实例暂存在 Dep.target 属性上

    (b)watcher 执行 vm._render() 函数,并进一步触发 vm._render() 所依赖数据属性的 getter

    (c)watcher 实例被收集到其全部依赖数据属性的 dep 收集器中

(3)响应式数据改变时的从新渲染流程:Reactive Data(set) -> dep 收集器 -> watcher(vm._render()) -> 异步队列

  • 当响应式数据被修改时,触发数据属性的 setter 函数
  • 数据属性的 setter 函数会促使 dep 收集器将其收集的全部 watcher 实例推入异步队列 queueWatcher
  • 异步队列会被总体放入 nextTick() 中,即在下一个 tick 时被一次性所有执行;其实在 watcher() 中,渲染函数 vm._render() 是被封装到 vm._update() 中的,它在执行时,会首先经过 vnode 的 diff 算法比对找到修改的最少步骤,而后将最小的差别化渲染到页面
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
...
    // 若是没有旧的虚拟节点 prevVnode,表示是初次渲染,直接渲染到页面
    if (!prevVnode) {
        // initial render
        vm.$el = vm.__patch__(
            vm.$el, vnode, hydrating, false /* removeOnly */,
            vm.$options._parentElm,
            vm.$options._refElm
        )
         
    // 非初次渲染,数据修改致使须要更新页面时,进行 vnode diff 后将最小的差别化渲染到页面
    } else {
        // updates
        vm.$el = vm.__patch__(prevVnode, vnode)
    }
...
}

每个响应式数据对象属性都必定会经历三个基本步骤中的 1 和 2,不过,不少属性在应用的整个生命周期中可能都不会经历步骤 3,由于它们始终没有改变。

可是,须要注意的是:之因此 Vue 会进行步骤 1 和 2 的操做,其实主要就是为了步骤 3 作准备,若是步骤 3 得不到执行,那么前两步的操做就是无心义的,或者说浪费。是否有方式避免这种浪费呢?有,就是 Object.freeze()。

在将普通数据转变成响应式数据的核心函数 defineReactive(Vue 2.6.x src/core/observer

/index.js) 中,有一个判断,若是属性自己不是 configurable 的,则不会被转化成响应式数据,即不会执行上面的流程 1,与此同时,非响应式的数据也天然不会执行流程 2。

/**
 * Define a reactive property on an Object.
 */
export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  const dep = new Dep()
 
  const property = Object.getOwnPropertyDescriptor(obj, key)
  if (property && property.configurable === false) {
    return
  }
   
  ...
}

对于整个应用生命周期中,不会改变的数据,可使用 Object.freeze() 将其 configurable 属性置为 false;或者,将整个数据对象都 freeze 掉:

/**
 * 深度冻结对象
 */
function deepFreeze(obj) {
  Object.keys(obj).forEach(key => {
    const prop = obj[key]
 
    typeof prop === 'object' && prop !== null && deepFreeze(prop)
  })
 
  return Object.freeze(obj)
}

而后,“解冻”部分须要改变的数据,并将其转换成响应式数据。

注意,若是解冻的属性值是对象,不能经过简单地赋值“解冻”该对象,由于对象的引用传递特性致使其 configurable 依然是 false。可使用下面的简单深复制方法,让源对象丢失 configurable 属性:

/**
 * 简单对象深复制
 * -- 子对象引用关系丢失
 * -- 不适合循环引用数据
 */
function deepClone(obj) {
  return JSON.parse(JSON.stringify(obj))
}

对于 Object.freeze() 带来的性能提高,Vue 官方的一个big table benchmark里,作了一个 1000 x 10 的表格渲染对照实验,使用 Object.freeze() 的渲染速度比不使用时快了 4 倍。

3、Pre 机制

浏览器的 pre(预)机制。

因为可动态修改 DOM 的自然属性,JavaScript 不只自己的执行是单线程的,并且其加载/解析执行时 HTML 的解析也是中止的,甚至在早期的浏览器中,其它资源的加载线程也会被同时阻止。

例如,在 IE7 中,页面的瀑布流:

其余资源的加载、解析、执行不能和 JavaScript 的加载执行并行,这致使了页面的加载时间很长。为了提升网络利用率,后来的主流浏览器都实现了预加载机制,即解析 HTML 页面的同时,启动一个轻量级解析器优先扫描 HTML 中的全部标记,寻找样式表、脚本、图像等静态资源,尽量地并行加载它们。

IE8 中的页面瀑布流:

能够很明显地看到,静态资源被尽量的并行加载了,即便在脚本加载解析的时候。

不过,随着 Web 应用的越加复杂化,CSS 和 JavaScript 资源容量也愈来愈大,不少资源并非一开始就出如今 HTML 中,而是后期被 CSS 和 JavaScript 动态引入的。为了尽量提早解析/加载这些资源,浏览器开始提供丰富的 pre 机制。

一、Preload

浏览器内核的预加载机制只适用于在 HTML 中显式声明的资源,对于 CSS 和 JavaScript 中定义的资源可能并不起做用。preload 很好地克服了这个问题,能够经过 preload 标识须要浏览器提早加载的重要资源,例如样式表、脚本、图片、字体甚至文档。

# 预加载 css
<link rel="preload" as="style" href="/assets/css/app.css">
 
# 预加载 js
<link rel="preload" as="script" href="/assets/js/app.js">
 
# 预加载图片
<link rel="preload" as="image" href="/assets/images/man.png">
 
# 预加载字体
<link rel="preload" as="font" href="/assets/font/rom9.ttf">

二、Prefetch

Prefetch 是一个低优先级的资源提示,容许浏览器在后台(空闲时)获取未来可能用获得的资源,而且将他们存储在浏览器的缓存中。有三种不一样的 prefetch 类型:

(1)Link Prefetching:容许浏览器获取资源并将他们存储在缓存中。

  • HTML
<link rel="prefetch" href="/uploads/images/pic.png">
  • HTTP Header
Link: </uploads/images/pic.png>; rel=prefetch

(2)DNS Prefetching:容许浏览器在用户浏览页面时在后台运行 DNS 解析。

能够在一个 link 标签的属性中添加 rel="dns-prefetch' 来对指定进行 DNS prefetching 的 URL:

<!-- 域名 dns-prefetch -->
<link rel="dns-prefetch" href="//sthf.vivo.com.cn">
<link rel="dns-prefetch" href="//apph5wsdl.vivo.com.cn">
<link rel="dns-prefetch" href="//cfg-stsdk.vivo.com.cn">
<link rel="dns-prefetch" href="//trace-h5sdk.vivo.com.cn">
<link rel="dns-prefetch" href="//topicstatic.vivo.com.cn">

DNS 请求在带宽方面流量很是小,但是延迟会很高,尤为是在移动设备上。

(3)Prerendering:和 prefetching 很是类似,优化可能资源的加载,区别是 prerendering 在后台渲染整个将来可能加载的页面。

<link rel="prerender" href="https://www.vivo.com.cn">

这三种类型中,Link Prefetching 和前文的 preload 比较类似,可是优先级较低,并且更加专一于下一个页面;Prerendering 会预渲染一个用户不必定访问的完整页面,这会致使较高的带宽浪费和资源占用,应用的机会可能并很少;而 DNS Prefetching 是当前咱们应用最多的。

在浏览一个网页时,DNS 解析老是发生在一个新域名初次被解析的时候,若是域名解析是独立串行的(如页面主域的解析),解析时间的长短(以下图中的 vivo 游戏大会员 supermember.vivo.com.cn)将直接影响页面的打开速度。得益于现代浏览器的预加载机制,除页面主域之外的其余资源域名的解析时间,必定程度上很好地掩蔽在了资源的并行加载过程当中。

可是,dns 的解析并不必定是稳定可靠的,时间跨度从几十 ms 至过千 ms 都有可能,若是页面主要资源的 dns 解析时间过长,就会直接影响用户的使用体验,因此,恰当的 DNS Prefetching 依然颇有必要。

三、Preconnect

相比于 DNS Prefetching,Preconnect 除了提早完成域名的 DNS 解析,还更近一步地完成 http 链接通道的创建,这包括 TCP 握手,TLS 协商等。

使用方法:

<!-- 域名 preconnect -->
<link rel="preconnect" href="//sthf.vivo.com.cn">
<link rel="preconnect" href="//apph5wsdl.vivo.com.cn">
<link rel="preconnect" href="//cfg-stsdk.vivo.com.cn">
<link rel="preconnect" href="//trace-h5sdk.vivo.com.cn">
<link rel="preconnect" href="//topicstatic.vivo.com.cn">

能够同时设置 Preconnect 和 DNS Prefetching,让浏览器优先进行 Preconnect,在不支持的前提下,优雅回退至 DNS Prefetching。

4、并行加载

随着 Web 应用的复杂化大型化,使用 MV* 类框架( Vue、React、Angular 等)进行快捷开发已经成为前端开发的主流模式。可是,这些框架都存在基础框架包较大,解析时间较长的问题。

首先,咱们看一个标准的 Vue 项目 - vivo 游戏大会员 Chrome 开发者工具中的瀑布流:

能够看出资源的加载存在明显的层级结构:

  • 第1级:获取页面 HTML 文档并解析
  • 第2级:获取页面 CSS 和 JavaScript 文件并解析
  • 第3级:请求接口获取服务端数据
  • 第4级:页面渲染加载主页图片等资源

同时,能够发现因为 JavaScript 文件较大,解析时间较长,第 2 级与第 3 级,以及第 3 级和第 4 级之间的时间间隔较大。若是这种串行的逐级解析加载模式可以改变为并行的加载模式,势必将显著下降页面的加载时长。

注意,若是项目未开启 HTTP/2,可能须要增长资源域名以突破浏览器对单个域名并行下载数量的限制。固然,在下面实现并行加载的过程当中,咱们也使用了很明显的反模式 - 经过 window 全局变量传递数据。不过,在没有更好的实现方案前,经过有限可控的反模式实现更好的页面体验仍是值得的。

下面,咱们讨论如何将串行加载的资源变成并行加载。

一、接口

大多数时候,接口的请求并不须要等待 Vue.js 加载解析完成,彻底能够在 HTML 中经过几行简单的 JavaScript 代码提早进行 Ajax 请求。

/**
 * 主接口请求前置
 */
var win = window
var xhr = new XMLHttpRequest()
 
xhr.open('get', '/api/member/masterpage?t=' + Date.now(), true)
xhr.onerror = function () { win._mainPageData = { msg: '请求出错', code: 10000 } }
xhr.timeout = 10000
xhr.ontimeout = function () { win._mainPageData = { msg: '请求超时', code: 10001 } }
xhr.onreadystatechange = function () {
  try {
    var status = xhr.status
 
    if (xhr.readyState == 4) {
      win._mainPageData = (status >= 200 && status < 300) || status == 304
      ? JSON.parse(xhr.responseText)
      : {
          msg: '',
          code: 10002
        }
    }
  } catch (e) { /* 请求超时时readyState可能也是4,可是访问status可能出错 */ }
}
xhr.send(null)

须要注意的是,直接插入到 HTML 中的 JavaScript 可能不会经过 babel 的编译,因此不要使用 ES6 语法,由于极可能一个简单的 const 就会让 Android 5/4.4.4 直接白屏。

二、图片

一般,Web 应用主页首屏会有几张装饰性且容量较大的图片,将图片写在 Vue 组件中,图片的加载会推迟到组件解析完成,咱们一样能够在 HTML 中提早加载这些图片。

一种方式是使用前文 Pre 机制中提到的 Preload:

<link rel="preload" as="image" href="/assets/images/00.png">
<link rel="preload" as="image" href="/assets/images/01.png">
<link rel="preload" as="image" href="/assets/images/02.png">

尽管 Preload 拥有更简洁且不阻塞页面渲染的优势,可是这种方式当前依然存在两个明显的问题:

(1)低版本 Android 不支持 Preload

(2)若是项目须要判断环境是否支持 webp 格式,以便有区分地加载图片的 webp 格式和普通格式,Preload 就很差办了,除非你两种格式都加载,但很明显这样会形成严重的流量浪费。

因此,咱们可使用 JavaScript 代码在判断环境是否支持 webp 格式后,加载须要格式的图片:

/**
 * webp 探测
 */
var win = window
var doc = document
 
win._supportsWebP = (function () {
    var mime = 'image/webp'
  var canvas = typeof doc === 'object' ? doc.createElement('canvas') : {}
  canvas.width = canvas.height = 1
  return canvas.toDataURL ? canvas.toDataURL(mime).indexOf(mime) === 5 : false
}())
 
/**
 * 图片预加载
 */
var body = doc.body
var parentNode = document.createDocumentFragment()
var imgPostfix = '.png' + (win._supportsWebP ? '.webp' : '')
var linkPrefix = '//topicstatic.vivo.com.cn/f5ZUD0HxhQMn3J32/wukong/img/'
var imgPreLoad = win._imgPreLoad = [
  linkPrefix + '5f88483c-4d76-42d4-912d-35c8c92be8e6' + imgPostfix,
  linkPrefix + '5ee4c220-cd98-4d8c-9cdc-5fca3e103227' + imgPostfix,
  linkPrefix + '131008e1-9230-480c-934a-30f9f83e17ae' + imgPostfix,
  linkPrefix + 'cee41d4d-853d-4677-9a20-b9b5e1c4ffbenwebp' + imgPostfix,
  linkPrefix + 'ddf2cad0-d334-437a-8923-7b36a65544d1nwebp' + imgPostfix
]
 
imgPreLoad.forEach(function (link) {
  var img = doc.createElement('img')
 
  img.src = link
  img.style.left = '-9999px'
  img.style.position = 'absolute'
 
  parentNode.appendChild(img)
})
 
body.insertBefore(parentNode, body.firstChild)

此外,在合适的时候,能够尝试使用 svg 图片,除了永不失真的图片质量,更重要的是,svg 能够很好地打包到代码中,并始终保持比 base64 更好的可读性。

三、字体

有的时候,为了实现更好的视觉效果,并能应对动态变化的接口数据,咱们会引入一些系统不支持的字体,好比数字字体 Rom9。

不过,咱们可能只是用到字体中的某一部分,好比数字,此时除了使用字体编辑软件删除不须要的字符外,咱们还能够将字体 base64 化后整合到 CSS 中以便更好地并行加载:

@font-face{
    src: url(data:font/truetype;charset=utf-8;base64,AA...省略...AK) format("truetype");
    font-style: normal;
    font-weight: normal;
    font-family: "Rom9";
    font-display: swap;
}

5、应用

咱们将上面有关的讨论应用到实际的项目 vivo 游戏大会员中。

首先,看一下并行加载优化后的资源瀑布流,本来处于第 二、第 3 和第 4 级的资源并行加载了。

经过视频能够更直观地感觉优化带来的改善:

优化前:

优化后:

能够看到,页面的打开速度不只更快,并且并行加载使得图片的呈现也再也不带有“节奏”了。