头图

渲染流程

模板编译的过程大致是:Vue会把用户在<template></template>标签中写的类似于原生HTML的内容进行编译,经过一系列的逻辑处理生成渲染函数,也就是render函数,而render函数会将模板内容生成对应的VNode,而VNode再经过patch过程从而得到将要渲染的视图中的VNode,也就是DOM-DIFF的过程,最后根据VNode创建真实的DOM节点并插入到视图中, 最终完成视图的渲染更新。

所谓渲染流程,就是把用户写的类似于原生HTML的模板经过一系列处理最终反应到视图中称之为整个渲染流程。整个流程图如下:
image.png
从图中我们也可以看到,模板编译过程就是把用户写的模板经过一系列处理最终生成render函数的过程。对应源代码中如下代码:

export const createCompiler = createCompilerCreator(function baseCompile (
  template: string,
  options: CompilerOptions
): CompiledResult {
  // 模板解析阶段:用正则等方式解析 template 模板中的指令、class、style等数据,形成AST
  const ast = parse(template.trim(), options)
  if (options.optimize !== false) {
    // 优化阶段:遍历AST,找出其中的静态节点,并打上标记;
    optimize(ast, options)
  }
  // 代码生成阶段:将AST转换成渲染函数;
  const code = generate(ast, options)
  return {
    ast,
    render: code.render,
    staticRenderFns: code.staticRenderFns
  }
})

让我们实践以下,如下最简单的模板:

<body>
    <div id="root"></div>
    <script>
        let vue = new Vue({
            el: '#root',
            template: '<div>count: {{count}}<button @click="add">加一</button></div>',
            data: {
                count: 0
            },
            methods: {
                add: function(){
                    this.count ++ 
                }
            }
        })
    </script>
</body>

经过编译后生成渲染函数的结果为:

在讲述DOM-DIFF的过程之前需要关注为什么是虚拟DOM?因为真实的 DOM 节点数据会占据更大的内存,当我们频繁的去做 DOM 更新,会产生一定的性能问题,因为 DOM 的更新有可能带来页面的重绘或重排。我们可以用 JS 的计算性能来换取操作 DOM 所消耗的性能。最直观的思路就是我们不要盲目的去更新视图,而是通过对比数据变化前后的状态,计算出视图中哪些地方需要更新,只更新需要更新的地方,而不需要更新的地方则不需关心,这样我们就可以尽可能少的操作 DOM 了。这也就是上面所说的用 JS 的计算性能来换取操作 DOM 的性能。

updateComponent = function () {
    // _render()渲染函数到虚拟节点
    vm._update(vm._render(), hydrating);
};
Vue.prototype._update(vnode,prevVnode){
    // __patch__()来更新新旧虚拟节点
    vm.$el = vm.__patch__(prevVnode, vnode);
}

渲染函数到虚拟DOM转换的内部的过程是怎样的呢?

render函数种的_c、_v等等对应虚拟节点种不同的节点类型,Vue中一共六个节点类型,分别是:注释节点、文本节点、元素节点、组件节点、函数式组件节点、克隆节点。

我们来看看上述的渲染函数生成的虚拟DOM:

当我们点击按钮后生成的虚拟DOM:

两个虚拟DOM之间的对比,也叫DOM-DIFF算法,采用深度优遍历,示意图如下:

整个patch无非就是干三件事:①创建节点:新的VNode中有而旧的oldVNode中没有,就在旧的oldVNode中创建。②删除节点:新的VNode中没有而旧的oldVNode中有,就从旧的oldVNode中删除。③更新节点:新的VNode和旧的oldVNode中都有,就以新的VNode为准,更新旧的oldVNode。

如果是相同的Vnode就要进行子节点的对比,示意图如下的动画:

VUE的DIFF算法的核心就是updateChildren方法,它是一个基于链表的双向对比。其中两种交叉的情况,旧节点链表的头和新节点链表的尾如果相同,就需要把老节点链表的头放到老节点链表的尾上去,旧节点链表的尾和新节点链表的头如果相同,同理,将旧节点链表的尾放在旧节点链表的头上去,可见其对比的结果是基于老节点链表上的。如果都不等,遍历老节点上的key,如果新节点链表的头的key在老节点的key的MAP中找到,就把新节点链表的头放在旧节点链表的头上去,如果不存在key,那就在旧节点链表的头上新建一个新节点链表的头。遍历完成后,新节点链表上存在的旧节点链表上不存在就批量更新到旧节点上,反之,批量删除。

最后一步就是虚拟DOM怎么转换成真实的DOM,有两种方式,初始化的时候直接挂载,另一种点击按钮后虚拟节点进行对比。

先看看初次挂载:

if (!prevVnode) {
    // initial render
    vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */);
} else {
    // updates
    vm.$el = vm.__patch__(prevVnode, vnode);
}

function createElm () {
    vnode.elm = nodeOps.createElement(tag, vnode);
    {
        createChildren(vnode, children, insertedVnodeQueue);
        insert(parentElm, vnode.elm, refElm);
    }
}
// 还是通过document.createElement来创建真实的DOM
function createElement (tagName, vnode) {
   var elm = document.createElement(tagName);
   if (tagName !== 'select') {
       return elm
   }
}

点击按钮后,count变为1,DOM-DIFF找到要更新的节点直接取代这个文本节点:

// node已经是真实存在的node,并且只想页面已经渲染的文档
function setTextContent (node, text) {
    node.textContent = text;
}

至此,我们完成了整个编译到渲染的过程。

变化侦听

vue中通过Observer类来作用于对象,对象可以通过Object.defineProperty方法将数据属性变更为访问器属性,访问器属性中的getter和setter可以实现对对象变化侦测,Vue中递归的把一个对象每个属性通过这种方式变为可侦测的。如下示意代码:

Object.defineProperty(obj, key, {
      enumerable: true,
      configurable: true,
      get: function reactiveGetter() {console.log('我被读取了')},
      set: function reactiveSetter(newVal) {console.log('我被修改了')}
});

但对于数组不存在这个方法,Vue采用拦截数组方法的方式来侦测数组的变化,具体的做法是采用寄生模式来实现数组方法拦截,如下伪代码push方法示例:

const arrayMethods = Object.create(Array.prototype)
const original = Array.prototype.push
def(arrayMethods, 'push', function mutator () {
    var result = Array.prototype.push.apply(arrayMethods, args);
    dep.notify() 
    return result
});
array.__proto__ = arrayMethods;//如果不能用隐式原型链链接,那么vue直接复制一个方法副本到数组实例上。

Object.defineProperty方法无法侦测到对象属性的新增和删除,Vue中的数组拦截器只是简单的拦截作用于数组本身的几种方法,而对于常用的索引操作也无能为力。针对这些弊端,Vue提供几个静态方法和实例方法来弥补侦听的不足。针对对象其具体做法重新加入侦听系统或者删除该属性然后通知依赖更新,针对数组其具体做法是使用拦截器中的splice方法实现数组元素的增删。

依赖生命周期

Vue在初始化状态时,会将数据加入变化侦听系统,将computed和watch中的方法遍历生成一个个方法依赖,在实例挂载生成独一份的视图依赖,这里有个经典的问题就是Vue有了响应式系统,为什么还需要虚拟DOM,Vue可以将视图中的每一个数据对应一份依赖,每一份依赖对应着一个DOM级别的改动,Vue侦听的数据一旦改变那么就会通知数据对应的依赖进行简洁的DOM更新。Vue1.0版本正是这样做的,DOM级别的细粒度,但这样做会生成很多的依赖带来了内存的开销,因此2.0引入了虚拟DOM,将粒度改为组件级别,这也就时为什么只生成独一份的视图依赖,它的表达式是function({vm._update(vm._render(), hydrating)},从代码表达式也看得出来组件实例的render和update流程。

依赖是视图和模型之间的桥梁,变化侦测起到了触发器的作用,因此在变化侦测中收集依赖和通知依赖更新,一旦数据被渲染函数用到那么就会触发getter,收集依赖。同理,数据被改变,触发setter,通知该数据关联的依赖更新视图。

Vue通过Dep类来管理依赖,由于对象和数组变化侦测的方式不同,依赖的收集都是在getter中完成,而通知依赖更新是以不同的方式引入Dep类从而通知相关的依赖进行更新操作。对象是在defineReactive中访问Dep类,数组拦截器的依赖管理器的引入是直接绑定在每个value的__ob__属性上,如下伪代码:

class Observe(){
    constructor(){
        this.dep = new Dep();
        def(value, '__ob__', this);
    }
}
new Observe(value)
// 在数组中通知依赖更新
def(arrayMethods, 'push', function mutator () {
    var result = Array.prototype.push.apply(arrayMethods, args);
    this.__ob__.dep.notify()
    return result
});

视图依赖存在有个newDeps属性,用来存放当前视图上每个数据对应的依赖数组dep,当一个数据改变后,将会从依赖视图中的newDeps中寻找该数据对应的dep,逐个更新依赖。当更改一个数据,它不一定只在视图中体现,还有其他地方,如computed和watch中也会触发,因此收集依赖不只是收集当前视图的依赖,而且还收集computed和watch的依赖。

当一个数据改变时,触发function({vm._update(vm._render(), hydrating)}重新渲染视图,重新执行依赖收集,将收集的依赖数据放在newDeps中。视图依赖还存在一个deps属性,是用来存在上一次视图render时收集的每个数据对应的依赖数组dep。vue在每次依赖收集完成之后都会去清除旧的依赖,即执行cleanupDeps方法,这个过程也叫清除依赖。它会首先遍历deps,移除对dep的订阅,然后把newDeps和deps 交换,并把newDeps清空。那么为什么需要做 deps 订阅的移除呢,在添加 deps的订阅过程,已经能通过 id 去重避免重复订阅了。考虑到一种场景,我们的模板会根据 v-if 去渲染不同子模板,当使用新的子模板时修改了不可见模板的数据,会通知到不可见模板数据的notify,这显然是有浪费的。因此 Vue 设计了在每次添加完新的订阅,会移除掉旧的订阅,这样就保证了在刚才的场景中,如果渲染新模板的时候去修改不可见模板的数据,不可见数据的依赖已经被移除了,所以不会有任何浪费。最后在组件销毁阶段,会移除所有的依赖,即移除deps中的依赖和vm._watchers中的依赖,也叫卸载依赖。整个依赖生命周期的操作都在Watcher的原型上提现,如下代码:

class Watcher{
    constructor(expOrFn){
        this.deps = [];
        this.newDeps = [];
        this.expression = expOrFn;
        this.get()//初次渲染生成视图依赖
    }
    get(){
        value = this.expression.call(vm, vm);//render中依赖收集
        this.cleanupDeps();//再次渲染时清除旧的依赖
        return value
    }
    cleanupDeps(){}//清除旧的依赖
    update(){}//更新依赖
    depend () {}//收集存在当前依赖的所有的dep实例
    teardown () {}//卸载所有依赖
}

实例生命周期

依赖的生命周期是建立在实例的生命周期上,实例生命周期可以对整个Vue运行机制的每个阶段有着精确把握。在new Vue之前vue会将一些全局的API和属性绑定为Vue类的静态方法和实例方法,在new Vue之后首先初始化该实例上的生命周期和事件以及渲染,便开始执行执行beforeCreate钩子函数。紧接着初始化state,InitState将配置上传入的props,methods等数据成为新创建的实例的属性,将data加入侦听系统,将computed和watch中的方法创建成一个个依赖实例。然后执行created钩子函数,宣告实例正式创建,接下来,vue完整版会存在模板编译阶段,该阶段将模板编程渲染函数,而运行时版本直接直接调用render,进入挂载阶段,执行beforeMount钩子函数,在挂载完成之前,即执行mounted钩子函数之前,需要通过render渲染出虚拟DOM并且挂载到页面上,同时生成唯一一份视图依赖并开启对数据的监控,那么这样就可以在数据状态变化时通知依赖更新。伪代码如下:

callhook(vm,'beforeMount')
var updateComponent = function (){vm._update(vm._render(), hydrating)};
var before = function before () {callHook(vm, 'beforeUpdate')}
class Watcher{
    constructor(updateComponent,before){
        updateComponent.call(vm)
         this.before = before
    }
}
new Watcher()
callHook(vm, 'mounted')

new Watcher驱动vue渲染出虚拟DOM并完成挂载的过程中,render函数中会访问到data中的数据,触发getter收集每个数据对应的依赖,当该数据发生变化时就会通知与之相关的依赖,依赖接收到通知后就会逐个调用beforeUpdate钩子函数去更新视图,视图更新完成之后,开始逐个的调用updated钩子函数。伪代码如下:

function flushSchedulerQueue(){
    for (index = 0; index < queue.length; index++) {
        watcher = queue[index];
        callHook(vm, 'beforeUpdate')
         updateComponent.call(vm)
    }
     callUpdatedHooks(queue.slice(0));
}
function callUpdatedHooks (queue) {
      var i = queue.length;
      while (i--) {
          var watcher = queue[i];
          callHook(vm, 'updated');
      }
}

最后的销毁阶段beforeDestroy把自己从父级实例的子实例列表中删除和删除所有的依赖并驱动视图更新,然后调用destroyed钩子函数,接下来就开始将自己身上的事件监听移除和vue实例指向移除。伪代码如下:

Vue.prototype.$destroy = function () {
    callHook(vm, 'beforeDestroy');
    remove(parent.$children, vm);//从父实例上移除
    while (i--) {
          vm._watchers[i].teardown();//删除所有依赖
    }
    vm.__patch__(vm._vnode, null);//驱动视图更新
    callHook(vm, 'destroyed');
    vm.$off();//移除监听
    vm.$el.__vue__ = null;//实例置空
}

如果存在父子组件,以上分析可以很简单得出父子组件之间的生命周期顺序,可以分为四个阶段:加载渲染阶段、子组件更新阶段、父组件更新阶段和销毁阶段。加载渲染阶段:父beforeCreate ---> 父created ---> 父beforeMount ---> 子beforeCreate ---> 子created ---> 子beforeMount ---> 子mounted ---> 父mounted。子组件更新阶段:父beforeUpdate ---> 子beforeUpdate ---> 子updated ---> 父updated。父组件更新阶段:父beforeUpdate ---> 父updated。销毁阶段:父beforeDestroy ---> 子beforeDestroy ---> 子destroyed ---> 父destroyed。

异步更新队列

可能你还没有注意到,Vue 在更新 DOM 时是异步执行的。只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。如果同一个 watcher 被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作是非常重要的。然后,在下一个的事件循环“tick”中,Vue 刷新队列并执行实际 (已去重的) 工作。Vue 在内部对异步队列尝试使用原生的 Promise.then、MutationObserver 和 setImmediate,如果执行环境不支持,则会采用 setTimeout(fn, 0) 代替。

例如,当你设置 vm.someData = 'new value',该组件不会立即重新渲染。当刷新队列时,组件会在下一个事件循环“tick”中更新。多数情况我们不需要关心这个过程,但是如果你想基于更新后的 DOM 状态来做点什么,这就可能会有些棘手。虽然 Vue.js 通常鼓励开发人员使用“数据驱动”的方式思考,避免直接接触 DOM,但是有时我们必须要这么做。为了在数据变化之后等待 Vue 完成更新 DOM,可以在数据变化之后立即使用 Vue.nextTick(callback)。这样回调函数将在 DOM 更新完成后被调用。例如:

<div id="example">{{message}}</div>
var vm = new Vue({
  el: '#example',
  data: {
    message: '123'
  }
})
vm.message = 'new message' // 更改数据
vm.$el.textContent === 'new message' // false
Vue.nextTick(function () {
  vm.$el.textContent === 'new message' // true
})

运行机制总览图

最后看看运行机制总览图。
image.png


桥本
1 声望0 粉丝

为学日益,为道日损