Vue实现原理+前端性能优化

一、Vue实现原理

1、Vue简介

现在的大前端时代,是一个动荡纷争的时代,江湖中已经分成了很多门派,主要以Vue,React还有Angular为首,形成前端框架三足鼎立的局势。Vue在前端框架中的地位就像曾经的jQuery,由于其简单易懂、开发效率高,已经成为了前端工程师必不可少的技能之一。

Vue是一种渐进式JavaScript框架,完美融合了第三方插件和UI组件库,它和jQuery最大的区别在于,Vue无需开发人员直接操作DOM节点,就可以改变页面渲染内容,在应用开发者具有一定的HTML、CSS、JavaScript的基础上,能够快速上手,开发出优雅、简洁的应用程序模块。

但是我们提及Vue的时候,更多的是关注它的用法,而不是学习它是如何解决前端问题的,这多少有点亚健康。有前端开发经验的人,一定在开发过程中遇到过奇奇怪怪的问题,然后稀里糊涂地解决,倘若再次遇到相似的问题,便再次手足无措,作为一名前端工程师,在遇到问题的时候我们是否能准确定位产生问题的原因并及时解决,主要取决于我们对前端框架的理解是否足够深入。

2、Vue实现原理

2.1 虚拟DOM(Virtual DOM)

随着时代的发展,Web应用的页面交互效果越来越复杂,页面功能越来越丰富,需要维护的状态越来越多,DOM操作也越来越频繁。DOM操作虽然简单易用,但是会产生不好维护的问题。

在程序执行的过程中,Watcher初始化时会将每一个节点和状态进行一一关联和映射,setter监听到Data的状态发生改变后,就会通知Watcher,Watcher会将这些变化通知曾经记录过的DOM以及跟这些状态相关的节点,从而触发页面的渲染过程。组件接收到状态变化后,会通过编译将模板转换成渲染函数Render,执行渲染函数就会得到一个虚拟DOM树,通过对比旧的虚拟DOM和新生成的虚拟DOM树,来更新对应的实际DOM节点,执行页面渲染。

主流前端框架几乎都在使用虚拟DOM,但是在使用虚拟DOM的时候,Angular和React都无法确定具体是哪个状态发生了变化,因此需要在旧的虚拟DOM和新的虚拟DOM之间进行暴力对比,但Vue从1.0版本开始,就通过细粒度的绑定来更新视图,也就是说,当状态发生变化的时候Vue可以知道具体是哪个状态哪些节点需要发生改变,从而对这个节点执行更新,然而这种细粒度的变化侦测会有一些内存开销影响性能,一个项目越复杂,开销就越大。

Vue从2.0版本后,为了优化性能,引入了虚拟DOM,选择了一个折中的方案,既不需要暴力对比整个新旧虚拟DOM,也不需要通过细粒度的绑定来实现视图的更新,即以组件为单位进行Watcher监听,也就是说即便一个组件内有多个节点使用了某个状态,也只需一个Watcher来监听这个状态的变化,当这个状态发生变化时,Watcher通知组件,组件内部通过虚拟DOM的方式去进行节点的对比和重新渲染。

2.2 常用指令实现原理

指令是指Vue提供的以“v-”前缀的特性,当指令中表达式的内容发生变化时,会连带影响DOM内容发生变化。Vue.directive全局API可以创建自定义指令,并获取全局指令,除了自定义指令,Vue还内置了一些开发过程中常用的指令,如v-if、v-for等。在Vue模板解析时,会将指令解析到AST,使用AST生成字符串的过程中实现指令的功能。

在解析模板时,会将节点上的指令解析出来并添加到AST的directives属性中,directives将数据发送到VNode中,在虚拟DOM进行页面渲染时,会触发某些钩子函数,当钩子函数被触发后,就说明指令已生效。

2.2.1 v-if指令原理

在应用程序中使用v-if指令:

create if
create else

在编译阶段生成:

(create)
 ? _c('div',[_v("create if")])
 : _c('div',[_v("create else")])

在代码执行时,会根据create的值来选择创建哪个节点。

2.2.2 v-for指令原理

在应用程序中使用v-for指令:

  • {{item}}
  • 在编译阶段生成:

    _l((list), function(item, index){
     return _c('li',[
      _v(_s(item))
     ])
    })

    _l是renderList的别名,执行代码时,_l函数会循环list变量,调用第二个参数中传递的函数,传递两个参数:item和index,当_c函数被调用时,会执行_v函数,创建一个节点。

    2.2.3 自定义指令原理

    在应用程序中,指令的处理逻辑分别监听了create函数、update函数以及destory函数,具体实现如下:

    export default {
     create: updateDirectives,
     update: updateDirectives,
     destory: function unbindDirectives (vnode){
      updateDirectives(vnode, emptyNode)
     }
    }

    钩子函数被触发后,会执行updateDirectives函数,代码如下:

    function updateDirectives(oldVnode, vnode){
     if (oldVnode.data.directives || vnode.data.directives) {
      _update(oldVnode, vnode)
     }
    }

    在该函数中,不论是否存在旧虚拟节点,只要其中存在directives,就会执行_update函数,_update函数代码如下:

    function _update(oldVnode, vnode) {
     const isCreate = oldVnode === emptyNode
     const isDestory = vnode === emptyNode
     const oldDirs = normalizeDirectives(oldVnode.data.directives, oldVnode.context)
     const newDirs = normalizeDirectives(vnode.data.directives, vnode.context)
     const dirsWithInsert = []
     const dirsWithPostpatch = []
    
     let key, oldDir, dir
     for (key in newDirs) {
      oldDir = oldDirs[key]
      dir = newDirs[key]
      if (!oldDir) { //新指令触发bind
       callHook(dir, 'bind', vnode, oldVnode)
       if (dir.def && dir.def.inserted) {
        dirsWithInsert.push(dir)
       }
      } else { //指令已存在触发update
       dir.oldValue = oldDir.value
       callHook(dir, 'update', vnode, oldVnode)
       if (dir.def && dir.def.componentUpdated) {
        dirsWithPostpatch.push(dir)
       }
      }
     }
    
     if (dirsWithInsert.length) {
      const callInsert = () => {
       for (let i = 0; i < dirsWithInsert.length; i++) {
        callHook(dirsWithInsert[i], 'inserted', vnode, oldVnode)
       }
      }
      if (isCreate) {
       mergeVNodeHook(vnode, 'insert', callInsert)
      } else {
       callInsert()
      }
     }
    
     if (dirsWithPostpatch.length) {
      mergeVNodeHook(vnode, 'postpatch', () => {
       for(let i = 0; i < dirsWithPostpatch.length; i++) {
        callHook(dirsWithPostpatch[i], 'componentUpdated', vnode, oldVnode)
       }
      })
     }
    
     if (!isCreate) {
      for(key in oldDirs) {
       if (!newDirs[key]) {
        callHook(oldDirs[key], 'unbind', oldVnode, oldVnode, isDestory)
       }
      }
     }
    }

    isCreate:判断该虚拟节点是否是一个新建的节点。
    isDistory:判断是否删除一个旧虚拟节点。
    oldDirs:旧的指令集合,oldVnode中保存的指令。
    newDirs:新的指令集合,vnode中保存的指令。
    dirsWithInsert:触发inserted指令钩子函数的指令列表。
    dirsWithPostpatch:触发componentUpdated钩子函数的指令列表。

    通过normalizeDirectives函数将模板中使用的指令从用户注册的自定义指令集合中取出来的结果如下:

    {
     v-customize: {
      def: {inserted: f},
      modifiers: {},
      name: "customize",
      rawName: "v-customize"
     }
    }

    自定义指令的代码为:

    Vue.directives('customize', {
     inserted: function (el) {
      el.customize()
     }
    })

    虚拟DOM在对比和渲染时,会根据不同情景触发不同的钩子函数,当使用虚拟节点创建一个新的实际节点时,会触发create钩子函数,当一个DOM节点插入到父节点时,会触发insert钩子函数。

    callHook函数执行钩子函数的方式如下:

    function callHook(dir, hook, vnode, oldVnode, isDestory) {
     const fn = dir.def && dir.def[hook]
     if (fn) {
      try {
       fn(vnode.elm, dir, vnode, oldVnode, isDestory)
      } catch (e) {
       handleError(e, vnode.context, `directive ${dir.name} ${hook} hook`)
      }
     }
    }

    callHook函数的参数意义分别为:
    dir:指令对象。
    hook:将要触发的钩子函数名。
    vnode:新的虚拟节点。
    oldVnode:旧的虚拟节点。
    isDestory:判断是否删除一个旧虚拟节点。

    虚拟DOM在渲染时会触发的所有钩子函数及其触发机制如下:

    需要注意的是,remove函数是只有一个元素从其父元素中移除时才会触发,如果该元素是被移除元素的子元素,则不会触发remove函数。

    二、前端性能优化

    前端在一个应用中,主要承担在用户打开一个页面时,发送请求到服务器,接收服务器返回的页面进行渲染并将渲染结果呈现给用户的功能。

    要提高前端性能需要从与用户操作无关的客户端和服务端交互和浏览器解析页面着手,也就是从传输和渲染两方面着手。

    1、请求传输

    1.1请求维度

    基于目前前后端传输广泛使用的HTTP 1.1协议,可以从压缩请求的大小和减少请求的数量两方面着手进行优化,主要的优化手段如下:

    1.2协议维度

    从1.0的短连接到1.1的长连接到HTTP2.0、3.0,做出了很多改变,每次协议的升级对前端性能优化来讲都是一次飞跃。HTTP2.0的新特性如下:

    二进制分帧:HTTP2.0在应用层与传输层之间增加一个二进制分帧层,将所有传输的信息分割为更小的消息和帧,并对它们采用二进制格式的编码,使通信都在一个可以承载任意数量的双向数据流的TCP连接上完成。

    压缩头部:使用HPACK算法,规定了在客户端和服务器端会使用并且维护“首部表”来跟踪和存储之前发送的键值对,对于相同的头部,不必再通过请求发送,减少了头部开销。

    多路复用:客户端和服务器可以把HTTP消息分解为互不依赖的帧,然后乱序发送,最后再在另一端把它们重新组合起来。

    请求优先级:每个流都可以带有一个31bit的优先值:0表示最高优先级;2的31次方-1表示最低优先级。

    服务器推送:通过提供push-promise帧来实现真正意义上的浏览器推送,摆脱利用ajax轮询进行伪实时的场景。

    2、浏览器渲染

    2.1 浏览器单线程解析渲染阻塞

    浏览器的主要构成如下:

    它的几个常驻线程如下:

    由于GUI线程和JS引擎线程互斥,故衍生了一系列避免渲染过程中发生阻塞的优化方法,如样式文件放头部,脚本文件放在DOM节点最末尾;针对不需要操作DOM的脚本,可以采用动态创建script标签的方式载入;脚本文件加上async或者defer等。

    2.2 巨大的DOM开销

    在浏览器渲染的过程中,巨大的DOM开销无疑成为了渲染效率是最大瓶颈。通过如下代码可以输出一个空DOM节点,查看它所包含的300余个属性和事件。

    let ele = document.createElement("div")
    let obj = {}
    for (const prop in ele) {
      obj[prop]=ele[prop]
    }
    console.log(obj)
    2.2.1重绘与回流

    重绘是指当页面展示元素中的一些元素需要更新属性,这些属性只是影响元素的外观、风格,而不会影响布局的,比如background-color。回流是指当页面展示元素中的一部分(或全部)因为元素的规模尺寸、布局、隐藏等改变而需要重新构建。显然,重绘不一定导致回流,回流必然导致重绘。

    它们都会带来一定的DOM开销,需要尽力去避免,常见的避免手段有避免触发同步布局事件;对于复杂动画效果,使用绝对定位让其脱离文档流;css3硬件加速(transform、opacity、filters、Will-change)等。

    2.2.2虚拟DOM

    针对巨大的DOM开销,除了尽力避免重绘和回流,近几年还有一种比较流行的,各大框架比如VUE、react都使用的虚拟DOM的方式。

    虚拟DOM是一颗以js对象为基础的树,用对象属性来描述节点,是对DOM的抽象,通过一系列操作将其映射到真实环境。

    用一段代码来模拟展示一下这个过程,首先用户编写模板如下:

    • {{item}}

    编译后的内容如下所示,采用了creatElement语法糖的形式创建节点。

    createElement {
     "ul",
     {
      attr:{
       id: "myId"
      }
     },
     [
      createElement("li", 1),
      createElement("li", 2),
      createElement("li", 3)
     ]
    }

    经过渲染函数的执行生成虚拟DOM树,其大致结构如下:

    最终将虚拟DOM树转化为真实DOM。

    虚拟DOM对性能的DOM开销的优化主要体现在当节点有变化时,它可以通过differ算法比较变化前后的虚拟DOM结构的变化,通过对节点属性的修改做必要的调整,而不是无脑的销毁旧节点创建新节点。

    这个过程的主要步骤是:用js对象结构表示DOM树的结构,然后用这个树构造一个真正的DOM树,插入文档中;当状态变更时,重新构造一棵新树,与旧树进行对比,记录差异;将记录的差异应用到所构建的真正的DOM树上。需要特别注意的是,differ算法遵循同级比较的原则,在使用的过程中要尽量减少跨层级的DOM调整。

    发表评论

    邮箱地址不会被公开。 必填项已用*标注