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

原创
2020/09/23 09:18
阅读数 2.4K

链接:https://mp.weixin.qq.com/s/N3vDWhT6oCZgYi2Am5GP7A
作者:ChenJing

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

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

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

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

一、self

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

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

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

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

还有一个更加重要的原因:有效减少工程上线时压缩后的代码量!

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

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

然后,对属性对象 man 做一次变量缓存:

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

再次压缩代码后的结果:

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 对象!

二、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 倍。

三、Pre 机制

浏览器的 pre(预)机制。

由于可动态修改 DOM 的天然属性,JavaScript 不仅本身的执行是单线程的,而且其加载/解析执行时 HTML 的解析也是停止的,甚至在早期的浏览器中,其它资源的加载线程也会被同时阻止。

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

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

IE8 中的页面瀑布流:

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

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

1、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">

2、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 依然很有必要。

3、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。

四、并行加载

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

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

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

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

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

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

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

1、接口

大多数时候,接口的请求并不需要等待 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 直接白屏。

2、图片

通常,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 更好的可读性。

3、字体

有的时候,为了实现更好的视觉效果,并能应对动态变化的接口数据,我们会引入一些系统不支持的字体,比如数字字体 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;
}

五、应用

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

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

通过视频可以更直观地感受优化带来的改善:

优化前:

优化后:

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

更多内容敬请关注vivo 互联网技术微信公众号

注:转载文章请先与微信号:Labs2020联系

展开阅读全文
加载中
点击引领话题📣 发布并加入讨论🔥
0 评论
0 收藏
2
分享
返回顶部
顶部