乐趣区

关于vue.js:VUE2再不记一笔就快忘了

渲染流程

模板编译的过程大抵是:Vue 会把用户在 <template></template> 标签中写的相似于原生 HTML 的内容进行编译,通过一系列的逻辑解决生成渲染函数,也就是 render 函数,而 render 函数会将模板内容生成对应的 VNode,而 VNode 再通过 patch 过程从而失去将要渲染的视图中的 VNode,也就是 DOM-DIFF 的过程,最初依据 VNode 创立实在的 DOM 节点并插入到视图中,最终实现视图的渲染更新。

所谓渲染流程,就是把用户写的相似于原生 HTML 的模板通过一系列解决最终反馈到视图中称之为整个渲染流程。整个流程图如下:

从图中咱们也能够看到,模板编译过程就是把用户写的模板通过一系列解决最终生成 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})

运行机制总览图

最初看看运行机制总览图。

退出移动版