vue源码学习2(learnVue)
参考:https://github.com/answershuto/learnVue
5、VNode节点
在刀耕火种的年代,我们需要在各个事件方法中直接操作DOM来达到修改视图的目的。但是当应用一大就会变得难以维护。那我们是不是可以把真实DOM树抽象成一棵以JavaScript对象构成的抽象树,在修改抽象树数据后将抽象树转化成真实DOM重绘到页面上呢?于是虚拟DOM出现了,它是真实DOM的一层抽象,用属性描述真实DOM的各个特性。当它发生变化的时候,就会去修改视图。可以想象,最简单粗暴的方法就是将整个DOM结构用innerHTML修改到页面上,但是这样进行重绘整个视图层是相当消耗性能的,我们是不是可以每次只更新它的修改呢?所以Vue.js将DOM抽象成一个以JavaScript对象为节点的虚拟DOM树,以VNode节点模拟真实DOM,可以对这颗抽象树进行创建节点、删除节点以及修改节点等操作,在这过程中都不需要操作真实DOM,只需要操作JavaScript对象后只对差异修改,相对于整块的innerHTML的粗暴式修改,大大提升了性能。修改以后经过diff算法得出一些需要修改的最小单位,再将这些小单位的视图进行更新。这样做减少了很多不需要的DOM操作,大大提高了性能。Vue就使用了这样的抽象节点VNode,它是对真实DOM的一层抽象,而不依赖某个平台,它可以是浏览器平台,也可以是weex,甚至是node平台也可以对这样一棵抽象DOM树进行创建删除修改等操作,这也为前后端同构提供了可能。
var VNode = function VNode (tag, data, children, text, elm, context, componentOptions, asyncFactory) { this.tag = tag; this.data = data; this.children = children; this.text = text; this.elm = elm; this.ns = undefined; this.context = context; this.fnContext = undefined; this.fnOptions = undefined; this.fnScopeId = undefined; this.key = data && data.key; this.componentOptions = componentOptions; this.componentInstance = undefined; this.parent = undefined; this.raw = false; this.isStatic = false; this.isRootInsert = true; this.isComment = false; this.isCloned = false; this.isOnce = false; this.asyncFactory = asyncFactory; this.asyncMeta = undefined; this.isAsyncPlaceholder = false; };
一些常用的构造VNode的方法:createEmptyVNode 创建一个空VNode节点;createTextVNode 创建一个文本节点;createComponent 创建一个组件节点;cloneVNode 克隆一个VNode节点。
// 创建虚拟节点 function _createElement (context, tag, data, children, normalizationType) { // 如果传递data参数且data的__ob__已经定义(代表已经被observed,上面绑定了Oberver对象),那么创建一个空节点 if (isDef(data) && isDef((data).__ob__)) { warn( "Avoid using observed data object as vnode data: " + (JSON.stringify(data)) + "\n" + 'Always create fresh vnode data objects in each render!', context ); return createEmptyVNode() } if (!tag) { // 如果tag不存在也是创建一个空节点 // in case of component :is set to falsy value return createEmptyVNode() } var vnode, ns; if (typeof tag === 'string') { var Ctor; ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag); if (config.isReservedTag(tag)) { // 如果是保留的标签则创建一个相应节点 // platform built-in elements vnode = new VNode( config.parsePlatformTagName(tag), data, children, undefined, undefined, context ); } else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) { // 从vm实例的option的components中寻找该tag,存在则就是一个组件,创建相应节点,Ctor为组件的构造方法 // component vnode = createComponent(Ctor, data, context, children, tag); } } else { // tag不是字符串的时候则是组件的构造方法 // direct component options / constructor vnode = createComponent(tag, data, context, children); } if (Array.isArray(vnode)) { return vnode } else if (isDef(vnode)) { if (isDef(ns)) { applyNS(vnode, ns); } return vnode } else { // 如果vnode没有成功创建则创建空节点 return createEmptyVNode() } }
_createElement用来创建一个虚拟节点。当data上已经绑定__ob__的时候,代表该对象已经被Oberver过了,所以创建一个空节点。tag不存在的时候同样创建一个空节点。当tag不是一个String类型的时候代表tag是一个组件的构造类。当tag是String类型的时候,如果是保留标签,则用new VNode创建一个VNode实例,如果在vm的option的components找得到该tag,代表这是一个组件。
6、Virtual DOM与diff(Vue.js实现)
Vue通过数据绑定来修改视图,当某个数据被修改的时候,set方法会让闭包中的Dep调用notify通知所有订阅者Watcher,Watcher通过get方法执行vm._update(vm._render(), hydrating)。
Vue.prototype._update = function (vnode, hydrating) { var vm = this; var prevVnode = vm._vnode; if (!prevVnode) { // initial render vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */); } else { // updates vm.$el = vm.__patch__(prevVnode, vnode); } };
patch将新老VNode节点进行比对,然后将根据两者的比较结果进行最小单位地修改视图,而不是将整个视图根据新的VNode重绘。patch的核心在于diff算法,这套算法可以高效地比较virtual DOM的变更,得出变化以修改视图。首先说一下patch的核心diff算法,diff算法是通过同层的树节点进行比较而非对树进行逐层搜索遍历的方式,所以时间复杂度只有O(n),是一种相当高效的算法。
这两张图代表旧的VNode与新VNode进行patch的过程,他们只是在同层级的VNode之间进行比较得到变化(第二张图中相同颜色的方块代表互相进行比较的VNode节点),然后修改变化的视图,所以十分高效。
function patchVnode (oldVnode, vnode, insertedVnodeQueue, ownerArray, index, removeOnly) { var oldCh = oldVnode.children; var ch = vnode.children; if (isDef(data) && isPatchable(vnode)) { for (i = 0; i < cbs.update.length; ++i) { cbs.update[i](oldVnode, vnode); } if (isDef(i = data.hook) && isDef(i = i.update)) { i(oldVnode, vnode); } } if (isUndef(vnode.text)) { if (isDef(oldCh) && isDef(ch)) { // 新老节点均有children子节点,则对子节点进行diff操作,调用updateChildren if (oldCh !== ch) { updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly); } } else if (isDef(ch)) { // 如果老节点没有子节点而新节点存在子节点,先清空elm的文本内容,然后为当前节点加入子节点 if (isDef(oldVnode.text)) { nodeOps.setTextContent(elm, ''); } addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue); } else if (isDef(oldCh)) { // 当新节点没有子节点而老节点有子节点的时候,则移除所有elm的子节点 removeVnodes(elm, oldCh, 0, oldCh.length - 1); } else if (isDef(oldVnode.text)) { // 当新老节点都无子节点的时候,只是文本的替换,因为这个逻辑中新节点text不存在,所以直接去除elm的文本 nodeOps.setTextContent(elm, ''); } } else if (oldVnode.text !== vnode.text) { // 当新老节点text不一样时,直接替换这段文本 nodeOps.setTextContent(elm, vnode.text); } }
patchVnode的规则是这样的:
1.如果新旧VNode都是静态的,同时它们的key相同(代表同一节点),并且新的VNode是clone或者是标记了once(标记v-once属性,只渲染一次),那么只需要替换elm以及componentInstance即可。
2.新老节点均有children子节点,则对子节点进行diff操作,调用updateChildren,这个updateChildren也是diff的核心。
3.如果老节点没有子节点而新节点存在子节点,先清空老节点DOM的文本内容,然后为当前DOM节点加入子节点。
4.当新节点没有子节点而老节点有子节点的时候,则移除该DOM节点的所有子节点。
5.当新老节点都无子节点的时候,只是文本的替换。
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) { var oldStartIdx = 0; var newStartIdx = 0; var oldEndIdx = oldCh.length - 1; var oldStartVnode = oldCh[0]; var oldEndVnode = oldCh[oldEndIdx]; var newEndIdx = newCh.length - 1; var newStartVnode = newCh[0]; var newEndVnode = newCh[newEndIdx]; var oldKeyToIdx, idxInOld, vnodeToMove, refElm; while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { if (isUndef(oldStartVnode)) { oldStartVnode = oldCh[++oldStartIdx]; // Vnode has been moved left } else if (isUndef(oldEndVnode)) { oldEndVnode = oldCh[--oldEndIdx]; } else if (sameVnode(oldStartVnode, newStartVnode)) { patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx); oldStartVnode = oldCh[++oldStartIdx]; newStartVnode = newCh[++newStartIdx]; } else if (sameVnode(oldEndVnode, newEndVnode)) { patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx); oldEndVnode = oldCh[--oldEndIdx]; newEndVnode = newCh[--newEndIdx]; } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left } else { } } if (oldStartIdx > oldEndIdx) { refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm; addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue); } else if (newStartIdx > newEndIdx) { removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx); } }
首先,在新老两个VNode节点的左右头尾两侧都有一个变量标记,在遍历过程中这几个变量都会向中间靠拢。当oldStartIdx > oldEndIdx或者newStartIdx > newEndIdx时结束循环。索引与VNode节点的对应关系: oldStartIdx => oldStartVnode oldEndIdx => oldEndVnode newStartIdx => newStartVnode newEndIdx => newEndVnode。在遍历中,如果存在key,并且满足sameVnode,会将该DOM节点进行复用,否则则会创建一个新的DOM节点。
首先,oldStartVnode、oldEndVnode与newStartVnode、newEndVnode两两比较一共有2*2=4种比较方法。当新老VNode节点的start或者end满足sameVnode时,也就是sameVnode(oldStartVnode, newStartVnode)或者sameVnode(oldEndVnode, newEndVnode),直接将该VNode节点进行patchVnode即可。
循环已经结束了,那么剩下我们还需要处理多余或者不够的真实DOM节点。当结束时oldStartIdx > oldEndIdx,这个时候老的VNode节点已经遍历完了,但是新的节点还没有。说明了新的VNode节点实际上比老的VNode节点多,也就是比真实DOM多,需要将剩下的(也就是新增的)VNode节点插入到真实DOM节点中去,此时调用addVnodes(批量调用createElm的接口将这些节点加入到真实DOM中去)。同理,当newStartIdx > newEndIdx时,新的VNode节点已经遍历完了,但是老的节点还有剩余,说明真实DOM节点多余了,需要从文档中删除,这时候调用removeVnodes将这些多余的真实DOM删除。
现在又出现了一个问题,我们只是将虚拟DOM映射成了真实的DOM。那如何给这些DOM加入attr、class、style等DOM属性呢?这要依赖于虚拟DOM的生命钩子。虚拟DOM提供了如下的钩子函数,分别在不同的时期会进行调用。
var patch = createPatchFunction({ nodeOps: nodeOps, modules: modules }); var hooks = ['create', 'activate', 'update', 'remove', 'destroy']; function createPatchFunction (backend) { var i, j; var cbs = {}; var modules = backend.modules; var nodeOps = backend.nodeOps; for (i = 0; i < hooks.length; ++i) { cbs[hooks[i]] = []; for (j = 0; j < modules.length; ++j) { if (isDef(modules[j][hooks[i]])) { cbs[hooks[i]].push(modules[j][hooks[i]]); } } } }
调用钩子函数如invokeCreateHooks、invokeDestroyHook
7、template编译
Vue.prototype.$mount = function (el, hydrating) { el = el && query(el); var options = this.$options; // resolve template/el and convert to render function if (!options.render) { var template = options.template; if (template) { } else if (el) { template = getOuterHTML(el); } if (template) { var ref = compileToFunctions(template, { outputSourceRange: "development" !== 'production', shouldDecodeNewlines: shouldDecodeNewlines, shouldDecodeNewlinesForHref: shouldDecodeNewlinesForHref, delimiters: options.delimiters, comments: options.comments }, this); var render = ref.render; var staticRenderFns = ref.staticRenderFns; options.render = render; options.staticRenderFns = staticRenderFns; } } };
通过mount代码我们可以看到,在mount的过程中,如果render函数不存在(render函数存在会优先使用render)会将template进行compileToFunctions得到render以及staticRenderFns。譬如说手写组件时加入了template的情况都会在运行时进行编译。而render function在运行后会返回VNode节点,供页面的渲染以及在update的时候patch。
<div id="app"> <button type="button" @click="testConcat">测试concat</button> </div>
如果render函数不存在会将template进行compileToFunctions得到render:
(function() { with(this){return _c('div',{attrs:{"id":"app"}},[_c('button',{attrs:{"type":"button"},on:{"click":testConcat}},[_v("测试concat")])])} })
在下面的代码里调用render方法
// vue.js Vue.prototype._render = function () { var vm = this; var ref = vm.$options; var render = ref.render; var _parentVnode = ref._parentVnode; var vnode; vnode = render.call(vm._renderProxy, vm.$createElement); vnode.parent = _parentVnode; return vnode };
_c,_v
installRenderHelpers(Vue.prototype); function installRenderHelpers (target) { // ... target._v = createTextVNode; // 创建一个文本节点 // ... } vm._c = function (a, b, c, d) { return createElement(vm, a, b, c, d, false); }; // 创建VNode节点
8、Vue.js异步更新DOM策略
当某个响应式数据发生变化的时候,它的setter函数会通知闭包中的Dep,Dep则会调用它管理的所有Watch对象。触发Watch对象的update实现。
Watcher.prototype.update = function update () { /* istanbul ignore else */ if (this.lazy) { this.dirty = true; } else if (this.sync) { this.run(); } else { queueWatcher(this); // 异步推送到观察者队列中,下一个tick时调用 } };
Vue.js默认是使用异步执行DOM更新。 当异步执行update的时候,会调用queueWatcher函数。
为什么要异步更新视图
<template> <div>{{test}}</div> </template> <script> export default { data () { return { test: 0 }; }, mounted () { for(let i = 0; i < 1000; i++) { this.test++; } } } </script>
现在有这样的一种情况,mounted的时候test的值会被++循环执行1000次。 每次++时,都会根据响应式触发setter->Dep->Watcher->update->patch。 如果这时候没有异步更新视图,那么每次++都会直接操作DOM更新视图,这是非常消耗性能的。 所以Vue.js实现了一个queue队列,在下一个tick的时候会统一执行queue中Watcher的run。同时,拥有相同id的Watcher不会被重复加入到该queue中去,所以不会执行1000次Watcher的run。最终更新视图只会直接将test对应的DOM的0变成1000。 保证更新视图操作DOM的动作是在当前栈执行完以后下一个tick的时候调用,大大优化了性能。
访问真实DOM节点更新后的数据
我们需要在修改data中的数据后访问真实的DOM节点更新后的数据,只需要使用Vue.js的global API的$nextTick方法,即可在回调中获取已经更新好的DOM实例了
<template> <div> <div ref="test">{{test}}</div> <button @click="handleClick">测试</button> </div> </template> <script> export default { data () { return { test: 'begin' }; }, methods () { handleClick () { this.test = 'end'; this.$nextTick(() => { console.log(this.$refs.test.innerText);//打印"end" }); console.log(this.$refs.test.innerText);//打印“begin” } } } </script>