阅读 2747

Vue SPA性能优化,看这一篇就够了

背景

Vue2全家桶、Webpack、SPA、电商项目

最近在做线上项目的**首屏加载时长优化**,查阅了大量的资料,也较为完整的整理了一遍之前的碎片知识点,本来计划只在团队内部做一次技术分享,但是后来一想,不如直接分享出来,说不定就会对哪位同学有一点点帮助呢。

更重要的是,由于水平和精力有限,文章中难免会有纰漏,分享出来也方便大家斧正,毕竟技术分享,是一个互相进步的过程。

关键指标

工欲善其事,必先利其器。

开撸之前,我们先来看一下衡量加载时长的关键指标(取自阿里的前端监控工具ARMS):

DOM Ready: domContentLoadEventEnd - fetchStart

页面完全加载: loadEventStart - fetchStart

具体的 PerformanceTiming API 我们不详细讲了,可以看看MDN文档

总结一下就是,domContentLoadedload 事件的触发时间节点要尽量提前

那么问题来了,如何提前呢?不急,我们先看一道面试题

一道面试题

经常逛技术社区的前端小伙伴,一定看到过这样一道面试题:

从浏览器地址栏输入URL,按下Enter后浏览器都做了什么?

一个基本及格的答案大约是这样的:

  1. 构建请求头,浏览器检查强缓存
  2. DNS解析,浏览器检查DNS缓存,host命中等
  3. 建立TCP连接
  4. 发起HTTP请求,服务器检查协商缓存
  5. 数据传输,在我们要聊的场景里,这里应该是一个html文件
  6. 根据 Connection:keep-alive 字段来判断是否断开TCP连接
  7. 浏览器解析及渲染html,若是html文件中有外链js、css等资源,则重复步骤3-6

我们定眼细看,发现问题并不简单,好像上面的每一条都和加载速度相关

但好消息是,除了第7条,其他的都可以**甩锅**给运维的小伙伴一个展示的机会嘛

OK,那我们就重点看一下**浏览器对HTML的解析和渲染流程**

补充一点,这其实是一道看似简单,却可以无限大的题目,其中的每一个环节都可以展开讲很多

比如浏览器多进程架构、IPC、渲染进程中各线程的调度

比如强缓存和协商缓存、DNS解析流程、TCP握手、常见请求头,甚至HTTP2的知识点等

但是我们今天要聊的重点是**首屏加载优化,所以我们只关心渲染进程中和加载速度**相关的内容

浏览器对HTML文件的解析与渲染

浏览器却并不能直接识别文本,所以它需要先对HTML按规则进行解析,最终生成可以识别的数据结构,这就是我们接下来要说的**解析流程**

一、解析流程

1、DOM 构建

The DOM (Document Object Model) is an API that represents and interacts with any HTML or XML document. The DOM is a document model loaded in the browser and representing the document as a node tree, where each node represents part of the document (e.g. an element, text string, or comment).

DOM是描述HTML文档的API,通过它,JS可以直接来操作文档节点。

浏览器收到服务器响应后,如果响应头中携带content-type: text/html,则会认为拿到的是 HTML 文件,触发解析行为

首先根据文件中指定的编码格式如 content-type: charset=UTF-8,将字节流转翻译成字符,经过词法分析得到的其实就是我们肉眼可以识别的字符串内容,<html><head>......</head><body>......</body><html>

然后经过语法分析,将字符串转换成一个个Token,如<head><body> 等,且每个Token 中会标明开启或闭合标签以及文本等信息,用以记录后续即将生成的 Nodes 信息的父子兄弟关系

接下来会根据得到的Tokens 生成节点信息 Nodes ,并最终构建 DOM

2、CSSOM 构建

The CSS Object Model is a set of APIs allowing the manipulation of CSS from JavaScript. It is much like the DOM, but for the CSS rather than the HTML. It allows users to read and modify CSS style dynamically.

DOM一样,CSSOM是提供给JS动态读取和操作样式的一组API

假如HTML中还引入了样式资源,例如,在DOM中插入一条 <link rel="stylesheet" href="demo.css"> 标签,浏览器会根据资源优先级开启http线程下载,并在下载完成后立即构建CSSOM。

CSSOM的构建同样需要词法分析、语法分析,最终生成 Tokens

接下来将 Tokens 转换成 styleRules ,每一条 styleRule 中都包含一个选择器和一个属性集,同一个CSS文件中的所有 styleRules 会被插入同一个 styleSheet 对象中

最后同样是生成节点信息 Nodes ,并最终构建 CSSOM

这么看来其实CSSOM的构建过程与DOM构建极为相似,只是其中涉及到的解析算法不同,这里推荐大家读一下这两篇文章,干货很多,读了之后对DOM Tree和 CSSOM Tree的构建过程应该可以有一个比较深刻的理解。

从Chrome源码看浏览器如何构建DOM树

从Chrome源码看浏览器如何计算CSS

到这这里为止,浏览器对HTML文件的解析流程我们基本已经搞清楚了,如下图所示:

HTML parse

这里需要补充一个知识点,浏览器的解析和渲染是在单独的渲染进程完成的,渲染进程中运行着很多条线程,其中Main Thread 本质上是一个loop,他会调度各个 Worker Threads 在合适的时机分工合作,DOM和CSSOM的构建都是在单独的Worker Thread中进行的,所以构建过程是并行的,互不阻塞

CSSOM构建完成之后,浏览器开始渲染

二、渲染流程

1、构建Layout Tree

在开始分析之前,我们先看一下Chrome源码中对 Layout 以及 layout Tree 的解释

The purpose of the layout tree is to do layout (aka reflow) and store its results for painting and hit-testing. Layout is the process of sizing and positioning Nodes on the page.

简单理解就是,layout ,就是计算页面中各个节点的尺寸和位置信息的过程,也就是我们常说的reflow(回流)

而构建 Layout Tree 的目的,则是用来 layout 并存储信息,用于后续的绘制

实际上,构建 Layout Tree 过程就是遍历 DOM Tree ,排除不会展示的内容,如 display:none/contents 的节点和 head 节点,结合 CSSOM Tree 中命中的样式规则生成 Layout Object

这里又有一条值得注意的点,layout过程是依赖DOM Tree和CSSOM Tree的,所以CSSOM构建虽然不会阻塞DOM构建,却会最终阻塞页面渲染

2、layout

layout 过程其实是一连串非常复杂的计算,本文不会详细讲计算过程,我们只需要知道计算的目的主要是为了确认 Layout Tree 中各个节点的尺寸及位置,最后输出一个盒子模型,它会精确地捕获每个元素在视口内的确切位置和尺寸,所有相对测量值都将转换为屏幕上的绝对像素。

3、paint

布局完成后,浏览器会立即发出 Paint SetupPaint 事件,将 Layout Tree 转换成屏幕上的像素

paint 过程更是过于复杂,有精力的同学可以深入下去研究研究,但是我们今天的主题是**加载速度优化,实际上主要还是应该在解析流程**上下功夫

想想看,假如我们可以更快的得到一个更精简的 DOM TreeCSSOM Tree,后续的**渲染流程**是不是也会得到相应的提升呢?

理论结合实践

上面一个章节,我们已经基本搞清楚浏览器的解析及渲染流程了,但是理论是掌握了,如何在我们的项目中去应用呢?首先,我们得看一下我们的项目打包出来的的HTML长啥样

(线上代码涉密,所以我们打包一个Vue demo 看看)

<html lang="en">
 <head>
  <meta charset="utf-8" />
  <meta http-equiv="X-UA-Compatible" content="IE=edge" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <link rel="icon" href="/favicon.ico" />
  <title>demo</title>
  <link href="/js/about.c06f3b2f76de6a796643.js" rel="prefetch" />
  <link href="/css/app.877b7338.css" rel="preload" as="style" />
  <link href="/js/app.675cf67d3925ba86f862.js" rel="preload" as="script" />
  <link href="/css/app.877b7338.css" rel="stylesheet" />
 </head>
 <body>
  <noscript>
   <strong>We're sorry but router-demo doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
  </noscript>
  <div id="app"></div>
  <script src="/js/app.675cf67d3925ba86f862.js"></script>
 </body>
</html>
复制代码

由上至下解析标签

解析head

我们发现head中有meta、title、link标签

meta标签是编码、协议以及视口相关内容,我们不关心,主要看一下link

link标签并不会阻塞后续标签解析,关联的资源浏览器会根据优先级开启network线程下载

细心的小伙伴可能已经注意到,vue-cli中webpack打包后的文件中其实已经有对文件预加载的优化:preloadprefetch,实际上就是对资源加载优先级的标记

  • preload:本页一定会用到的资源,高优先级加载,尽可能的提升本页加载速度
  • prefetch:后续可能会用到的资源,较低优先级,浏览器空闲时间下载,提高后续操作页面的加载速度
  • 同时link中也有标记为rel="stylesheet" 的CSS资源,最高优先级加载

解析body

body内容比较简单,noscript标签是处理浏览器无法识别scrip标签异常的,在现代浏览器中基本不需要理会

然后是一个id为app的div空标签,以及一个script标签

仔细观察我们可以发现,这个JS文件其实已经在link中被标记了preload,属于高优先级资源,所以浏览器其实早早的就开始下载了,所以在解析到这个script标签的时候,浏览器会等待js资源下载完成,JS线程接管,而UI线程会被挂载,也就是解析和渲染会被中断,等JS中的同步代码执行完后,UI线程接管,继续解析和渲染

这里需要重点关注的是,JS资源下载完,并不会立即执行,而是**必须等待前面的CSS的加载和解析完成**才会执行,为什么会这样呢?

因为在CSS中,后面的规则是可以影响到前面的规则的,所以CSSOM必须构建完整才是有效的

假设一种情况,我们在CSS中先设置字体大小为14px ,后面又改为 16px ,这样渲染的内容就会不准确,所以渲染一定会等待CSSOM构建完成后才开始

而JS又是可以操作DOM和CSSOM的,所以,JS的执行必须等待CSS的加载和解析完成

这其实是非常非常重要的一个点,因为在我们的**关键渲染路径**上,是一定会有至少一个CSS资源的,这个资源的加载和解析时长,最终会完整的反应到我们的首屏时间里,举个例子:假如我们在CSS中通过 @import 又引入了其他外部样式,那就必须等待新资源加载并解析完成后,JS才会被执行,所以从首屏速度方向考虑,是非常不推荐用 @import 引入外部样式资源的

后续就是处理闭合标签,html解析及渲染完成

domContentLoaded

我们先看一下MDN文档中关于 domContentLoaded 事件的定义

当初始的 HTML 文档被完全加载和解析完成之后,dOMContentLoaded 事件被触发,而无需等待样式表、图像和子框架的完全加载。

根据上一章的分析,我们可以总结出三种情况:

  • 无script,有css

    在没有script标签的情况下,由于DOM构建和CSS的下载是并行的,所以 domContentLoaded 事件在DOM构建完成后即可触发,而不必等待CSS的下载和解析

  • 有script,无css

    解析到script标签的时候,GUI线程会被挂载,等JS同步代码执行完成后才会继续构建DOM,完成后触发

  • 有script,有css

    解析到script标签的时候,浏览器会等待CSS下载及解析完成,并执行首次渲染之后才会执行,等JS同步代码执行完成后才会继续构建DOM,完成后触发

发现问题了么,CSS资源的加载及解析速度会直接影响 domContentLoaded 事件触发的时间节点,从而影响首屏加载速度,所以这应该是我们要着重优化的点,加速CSS资源的加载及解析

同样的,JS的加载和执行会阻塞DOM构建,所以另一个我们要着重优化的点就是,加速JS资源的加载及执行

load

我们同样看一下MDN文档中关于 load 事件的定义

当整个页面及所有依赖资源如样式表和图片都已完成加载时,将触发load事件。

它与 domContentLoaded 不同,后者只要页面DOM加载完成就触发,无需等待依赖资源的加载。

也就是说,浏览器要在DOM构建完成后,且DOM中挂载的样式、图片,以及同步代码中触发的异步加载资源等完全加载后才会触发 load 事件,所以我们另一个要优化的重点是,加速首屏DOM中挂载的资源加载

load 事件触发,说明页面已经完全加载了,性能指标中的取数逻辑也就到此为止了

总结一下,无非就是三点优化

  • 加速CSS资源的加载及解析
  • 加速JS资源的加载及执行
  • 加速首屏DOM中挂载的资源加载

接下来,我们就研究一下如何优化

关键渲染路径

其实我们总结的这三条重点优化的点,都属于**关键渲染路径**

关键渲染路径,就是浏览器在首屏完全加载过程中要执行的所有路径,也就是说,HTML解析开始,直到 load 事件的触发,其中的每一步都包含其中

加速CSS、JS资源的加载及解析

1、更高的加载优先级

我们可以给关键资源较高的加载优先级,如设置 preload ,让浏览器提前加载,而不必等真正要用到的时候再去加载

但同时也要注意,过多的 preload 反而会影响关键资源加载,因为浏览器同域名下TCP连接是有数量限制的,比如chrome中限制了6条

所以非关键渲染路径资源不能设置 preload

2、更小的资源体积

  • 开启Gzip

    Gzip可以极大的压缩资源体积,是简单有效的提升性能的方式。生产环境的数据显示,730K的主资源文件压缩后可以到210K左右,效果显著。

  • Code Splitting

    在上面的Demo中,我们看到这么一条资源: <link href="/js/about.c06f3b2f76de6a796643.js" rel="prefetch" />

    在Webpack打包的过程中,异步引入的资源会被打进单独的chunk文件,在需要执行该文件时才会加载,这就是代码分割的可行性前提

    1. 我们首次进入某页面(如Home),其实并不需要同步加载其他页面(如About)的资源,所以我们完全可以将每个单页的资源分割出来,在真正要用到的时候再加载。所以我们可以做一个路由懒加载,如Demo所示:

      // ...
      const routes = [
        {
          path: '/',
          name: 'Home',
          component: Home
        },
        {
          path: '/about',
          name: 'About',
          // route level code-splitting
          // this generates a separate chunk (about.[hash].js) for this route
          // which is lazy-loaded when the route is visited.
          component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
        }
      ]
           
      const router = new VueRouter({
        mode: 'history',
        base: process.env.BASE_URL,
        routes
      })
      export default router
           
      复制代码

      About 组件借助了 import() 语法异步加载,About 组件会被打包进单独的chunk,假如我们首次访问的是 Home 页,就可以不必同步加载About 资源

    2. 再细化到Home 页面,假如我们在Home 页面中有一个弹窗组件 HomeModal ,只有在用户触发查询事件的时候才会展示出来,那其实我们首屏的时候也不需要同步加载 HomeModal 资源,如:

           <template>
             <div class="home">
               <div @click="showModal = !showModal">展示弹窗</div>
               <HomeModal v-if="loadModal" v-show="showModal">
             </div>
           </template>
                
           <script>
           const HomeModal = () => import('@/components/HomeModal.vue')
           export default {
             name: 'Home',
             components: {
               HomeModal
             },
             data() {
               return {
                 showModal: false,
                 loadModal: false,
               }
             },
             methods: {
               // 根据业务指定加载时机
               lazyLoadHomeModal () {
                 this.loadModal = true
               }
             }
           }
           </script>
      复制代码

      在合适的时机给 v-if 置为 trueVue 在挂载 HomeModal 组件的时候浏览器才会异步加载资源并渲染,而后续的展示隐藏我们依然可以通过 v-show 来控制

      同样的,非UI资源依然可以利用这种方式异步加载,如:

      const getCat = () => import('./cat.js')
      // 根据业务指定加载时机
      getCat()
        .then({ meow } => meow())
      复制代码

      Code Splitting 是非常有效的减小关键资源体积的方式,但是一定要根据自身业务取舍,不能矫枉过正。要确保异步加载的不能是关键资源,且制定良好的加载时机触发策略。

    3. UI库按需加载

      你的项目中可能会引用一些第三方的UI库,这会极大的提升开发效率,但你用到的可能只是库中的一部分UI组件,所以按需加载是很有必要的。按需加载的配置方式每个UI库的readme中都会有详细说明,可以去查阅官方文档

    4. Uglify

      良好的代码缩进和排版无疑可以提升我们的代码可读性,但是打包成生产环境代码的时候可以将无用的空格等信息去除,减少字符数,这一步通常打包工具都会为我们考虑到。

      但是我们依然可以做很多其他工作,比如检查代码中的DeadCode ,生产环境中去除log ,去除无用样式等。因为无用资源不仅会占用我们的资源加载时间,还会占用资源解析时间,所以这一步是一举两得的。

      有时候我们会在CSS中使用base64加载图片资源,有一些优点,但还是非常不建议这么做。

      因为上面我们提过了,CSS是会阻塞渲染的,base64编码是会使文件变大的,而这部分文件体积最终都会体现在打包出的CSS文件中,无谓的延长首屏时间,所以更推荐CDN引入图片资源。

  • CDN加速

    CDN的好处就不用多讲了,同样属于关键优化点

  • HTTP2

    得益于HTTP2多路复用的特性,经过Code Splitting 的碎片资源会有更优秀的加载性能。

    关于HTTP2,可以参考这篇文章HTTP2 详解

3、更快的执行速度

CSS的解析速度我们能控制的不多,但是更好、更精炼的样式结构无疑是非常有好处的

我们分析的重点是JS的执行速度,因为我们的项目是以Vue作为Demo的,所以我们就以Vue的整个生命周期来具体分析

我们知道,Vue项目中,Webpack打包是以 main.js 为入口的,那我们看一下 main.js

import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'

Vue.config.productionTip = false

new Vue({
  router,
  store,
  render: h => h(App)
}).$mount('#app')
复制代码

Demo的 main.js 还是非常简单的,但是通常情况下,你可能会把一些全局逻辑放进来,比如全局组件、全局 Filters 注册,以及一些业务中可能需要全局处理的逻辑,检查一下,非必要的逻辑是不是有更好的处理方式

然后我们就要看一下, new Vue() 以及 mount() 函数都做了什么

在一篇文章里解读Vue的完整流程不太现实,所以我把 new Vue() 以及 mount() 中可以在性能优化上做手脚的部分总结了一下

  1. initState()

    Vue中 dataprops 的数据劫持以及响应式,都是这个方法中进行的。所以我们应该检查一下我们业务中用到的数据是否都要响应式,如果是一些与UI无关的数据,就不要放在 data 里了

  2. mount()

    在Vue中,mount() 的本质是编译模板文件生成 Render 函数 ,执行 Render 函数生成虚拟DOM,最终根据虚拟DOM生成真实DOM并挂载到目标节点上,在我们的Demo中,就是 render: h => h(App) ,所以我们还得看一下 APP 组件

  3. APP

    <template>
      <div id="app">
        <div id="nav">
          <router-link to="/">Home</router-link> |
          <router-link to="/about">About</router-link>
        </div>
        <router-view/>
      </div>
    </template>
    复制代码

    APP 的模板很简单,其实就是根据 Router 为我们匹配目标组件

    但是在我们真正的业务中,APP组件通常不会这么简单,假如你有引入的其他组件,请参考我们上面章节中提到的,非关键资源异步加载

  4. Router

    Rrouter 主要是为我们匹配目标组件,如果组件是异步加载的,命中后会请求资源。但是我们知道 Vue-Router 中其实为我们暴露出来一部分钩子函数,这部分逻辑是会在目标页真正挂载之前进行的,所以假如我们在目标组件甚至全局混入的前置钩子函数中有费时甚至阻塞的操作,请思考一下更好的业务处理方式。

  5. 目标组件

    目标组件就是我们要访问的单页。其实每个组件都会执行 Vue.extend() ,得到的就是一个合并了全局属性的Vue实例,最终目标组件也会走一遍完整的Vue生命周期,只是这个时候,Vue要去编译目标页里复杂的模板文件,最终将DOM挂载到目标节点上。我们知道,DOM操作是非常昂贵的,所以我们要考虑一下,目标页面的首次渲染,是否有必要去生成完整的DOM结构呢?当然没有必要,其实业内有非常好的解决方案:骨架屏

这里要强烈安利一下这个文档,Vue技术揭秘,作者非常深入条理的为我们解析了Vue2源码的执行逻辑,我本人每次看都有新的收获,我认为每一个Vue开发者都需要读一下Vue2的源码,即使Vue3.0已经发布了,但是其中的思想还是相通的。

加速首屏DOM中挂载的资源加载

在我们聊的业务场景中,DOM挂载的资源加载结束,就会触发 load ,也就是说直到这里,页面才算完全加载

上一章我们讲到了目标页面使用**骨架屏**,一个简洁美观的骨架屏展示,可以提供良好的用户体验,更重要的是,骨架屏的DOM结构可以非常简单,且不需要挂载额外资源,对我们的domContentLoadedload 事件触发时机的提前都有很大帮助。

假如你不愿意使用骨架屏,那么就要考虑一下如何精简首屏渲染DOM中挂载资源的加载速度了,比如缩略图的使用、图片的压缩、webp、CDN等

骨架屏之后

我们做足了功课,终于极大的优化了首屏速度,起码ARMS取数上看是美观了很多,领导很高兴。

但是骨架屏之后的,骨架屏之后的渲染页面虽然不会计入首屏时间里,但依然会实打实的影响用户体验,所以对我们真正业务相关的DOM结构,还是要沉下心来不断优化的。

最后

这篇文章更多的是对这次性能优化工作的记录,所以总结性的东西较多,并没有深入浅出,文中借鉴或引用的资料如下

Vue技术揭秘

关键渲染路径

从浏览器多进程到JS单线程,JS运行机制最全面的一次梳理

(1.6w字)浏览器灵魂之问,请问你能接得住几个?

HTTP2 详解

从Chrome源码看浏览器如何构建DOM树

从Chrome源码看浏览器如何计算CSS

MDN文档