第二次来梳理Vue源码逻辑了。第一次因为不熟悉,梳理的很细致才弄懂。第二次就更有大局观一些了,这次我主要抓住流程的重点顺利走完流程就好了。主路线是按执行顺序进行排列的。
入口是哪里开始的?
init()。合并配置,初始化事件中心、初始化生命周期、初始化数据响应化、初始化渲染等等。
render函数是什么时候生成的?
vm.$mount。把el或者 template字符串或.vue文件dom文档,解析成render函数(我们使用webpack的话,直接解析成render函数,此步骤我们在webapck中完成)。
render函数-》Vnode-》真实DOM 的步骤?
mountComponent(主要代码:updateComponent = () => { vm._update(vm._render(), hydrating)} 和 vm._watcher = new Watcher(vm, updateComponent, noop))。其核心就是先调用 vm._render 方法,生成虚拟 Node。再实例化一个渲染Watcher,在它的回调函数中会调用 updateComponent 方法,最终调用 vm._update更新 DOM。
render函数是如何变成Vnode的?
vm._render,把render函数渲染成vnode。模板编译成的 render 函数使用vm._c 方法渲染,用户手写 render函数用vm.$createElement方法渲染。 这俩个方法都在入口的初始化渲染中进行了定义。它们传入的参数相同,并且内部都调用了 createElement 方法。createElement,创建vnode并返回。该方法有5个参数,也就是创建vnode的原材料,context (VNode 的上下文环境);tag (标签,是一个字符串或者一个 Component);data (VNode的数据);children( VNode 的子节点,是任意类型的);normalizationType(子节点规范的类型)。
注意:
1、编译生成的render函数的children数组中内容(包括普通节点和组件节点)已经是vnode类型了,在编译阶段已经完成了,只是对某些情况需要规范数据格式。所以下面的render函数转化成vnode数据格式,是对根节点做的操作,而对children只是做了规范化操作。
2、createElement方法执行了多少次?tag标签又是什么?在编译成render函数时,children已经是vnode类型怎么理解,学到编译阶段就明白了。
createElement主要做了那些事情?
1、规范vnode的子节点children。render 函数如果是编译生成的,理论上编译生成的 children 都已经是 VNode 类型了(除了functional component 函数式组件返回的是一个数组而不是一个根节点)。children是一个数组,组员是一个个vnode,它们也有自己的children数组,这样就形成了一个 VNode Tree,它很好的描述了我们的 DOM Tree。其中对children的处理有2个方法simpleNormalizeChildren(针对render函数是编译生成且函数式组件返回的是一个数组而不是一个根节点)和normalizeArrayChildren(针对v-solt / v-for编译生成有嵌套数据的情况或者用户手写render函数),打平children数组。
2、根据tag不同类型,以不同方式创建vnode。1、若tag是string 类型,若是内置节点,就直接创建vnode,若是已注册的组件名,就createComponent,创建组件类型vnode,否则创建未知标签vnode。2、若tag是Component类型(比如
注意:这里对不同tag类型用不同方式生成vnode节点,是对main.js中new Vue({options})中options中的模版传入方式做判断。1、直接写模版。2、注册component,写component。3、直接写render函数。来用不同方式生成vnode节点。
组件是如何通过createComponent生成vnode的?
createComponent(Ctor: Class
1、Vue.extend,构造子类构造函数,init()执行initGlobalAPI(初始化全局API)时定义了Vue.options_base = Vue,init()也通过mergeOptions把Vue中一些option扩展到了vm.$options上,所以在组件实例中我们能通过vm.$options._base取到Vue。在Vue.prototype上有Vue.extend方法,其作用是构造一个Vue的子类。它使用原型继承(a.prototype = object.create(b.prototype))把一个纯对象转换成一个继承于 Vue 的构造器 Sub 并返回,然后对 Sub 这个对象本身扩展属性(如扩展 options、添加全局 API 等),并且对配置中的 props 和 computed 做了初始化;最后对于这个 Sub 构造函数做了缓存,避免多次执行 Vue.extend 的时候对同一个子组件重复构造(比如多个组件引用同一个组件的情况)。当我们去实例化 Sub 的时候,就会执行 this._init 逻辑再次走到了 Vue 实例的初始化逻辑。
2、installComponentHooks,安装组件勾子函数。 Vue.js 使用的 Virtual DOM 参考的是开源库 snabbdom,它的一个特点是在 VNode 的 patch 流程中对外暴露了各种时机的钩子函数,方便我们做一些额外的事情,Vue.js 也是充分利用这一点,在初始化一个 Component 类型的 VNode 的过程中实现了几个钩子函数。installComponentHooks 的过程就是把 componentVNodeHooks 的钩子函数合并到 data.hook中,在 VNode 执行 patch 的过程中执行相关的钩子函数。
3、实例化VNode,通过 new VNode 实例化一个 vnode 并返回。需要注意的是和普通元素节点的 vnode 不同,组件的 vnode 是没有 children 的。
注意:这个vnode是组件vnode(主要作用是传递数据),后面组件实例执行_inite->$mount->mountComponent->_render(),_render时还会生成一个vnode,是组件的渲染vnode(即组件内部的真正要挂载的内容的vnode)
vnode是如何变成真实DOM的?
update,把 VNode 渲染成真实的 DOM。它被调用的时机有 2 个,一个是首次渲染,一个是数据更新的时候。其核心就是调用vm.__patch__,它通过nodeOps(操作dom标签)和modules(操作dom标签属性)来个模块来生成真实DOM。因为vue可以跑在不同平台(web/weex),他通过函数柯理化实现,在createPatchFunction方法中 ....(一系列判断逻辑) return patch(),一方面两个模块参数通过闭包固化,不用重复传入,其二一系列判断逻辑固化,不用二次执行,代码更精练逻辑更清晰。
首次渲染,vm.$el(index.html中id为app的Dom对象)作为初始节点vnode的父节点,执行createElm方法。1、把当前vnode创建真实DOM(如果 vnode 节点不包含 tag,则它有可能是一个注释或者纯文本节点,可以直接插入到父元素中)。2、通过createChildren创建子节点(其中就是对children数组中每一个值执行creatElm创建真实DOM)。3、执行insert把节点挂载到父节点上。如此循化下去,节点的创建顺序是父->子,节点挂载顺序是子->父。
组件是如何从vnode-》真实DOM的?
patch方法遇到组件时发生了什么?
patch的过程会执行createComponent,是组件节点的话会执行组件生成vnode时安装的组件勾子函数init,init方法会执行:
1、createComponentInstanceForVnode(vnode(当前正在执行createElm的组件vnode),activeInstance(在patch的入口_update方法中申明中,当前vm实例,也是当前执行上下文)))。在其内定义了options对象{ __isComponent: true, _parentVnode: vnode, parent: activeInstance}。最后执行“生成vnode时构造的Vue子类实例”并把options传入Ctor(options),子组件的实例化在其内执行(重新执行_init方法),_init(options(第一步定义的options))。
2、用$mount 方法挂载子组件。
组件是如何执行_init方法的?
首先,合并option变为执行initInternalComponent(vm(当前子组件vm实例),options(上一步定义的options)),方法:opts= vm.$options,opts.parent = options.parent(在patch的入口_update方法中申明,父级vm实例,也是父级执行上下文)、opts._parentVnode = parentVnode(当前正在执行createElm的组件vnode,组件的占位节点vnode),它们是把之前我们通过 createComponentInstanceForVnode 函数传入的几个参数合并到内部的选项 $options 里了。
其次,在初始化生命周期initLifecycle时:const options=vm.$options。let parent=options.parent。parent=parent.$parent。parent.$children.push(vm)。vm.$parent=parent。确定了子组件实例和父组件实例的关系。
其他的部分也和Vue实例一样,初始化事件中心、初始化生命周期、初始化数据响应化、初始化渲染等等。
组件是如何挂载的?
因为组件options中没有el属性,所以它自己接管了$mount过程,也就是init方法的第二步。它最终会调用 mountComponent 方法,进而执行 vm._render() 方法:
1、vm.$vnode=_parentVnode(把在createComponent生成组件vnode赋给_parentVnode,作为当前组件的占位节点)。
2、vnode=render.call(vm._renderProxy,vm.$createElement)(这一步得到组件的渲染vnode,是真正要替换占位节点,要去挂载的vnode)。
3、vnode.parent=_parentVnode,确定组件占位节点vnode和渲染vnode的父子关系。
接下来组件要通过vm._update(vnode(这里是_render生成的组件渲染vnode))去挂载了:
1、vm._vnode = vnode(渲染vnode),也就是说:vm._vnode.parent = vm.$vnode。
2、const prevActiveInstance = activeInstance,activeInstance = vm。activeInstance是一个init时初始化的全局变量,它储存了当前子组件vm的实例作为当前执行上下文(它是组件渲染vnode的上下文),prevActiveInstance储存了父级vm实例(它是组件占位节点vnode的上下文)。
3、_patch_的过程还是执行createElm方法,这次我们传入的 vnode 是组件渲染的 vnode,也就是我们之前说的 vm._vnode。如果组件的根节点是个普通元素,那么 vm._vnode 也是普通的 vnode,那么这里 createComponent(vnode, insertedVnodeQueue, parentElm, refElm) 的返回值是 false。接下来的过程就和我们开头一样了,先创建一个父节点占位符,然后再遍历所有子 VNode 递归调用 createElm,在遍历的过程中,如果遇到子 VNode 是一个组件的 VNode,则重复本节开始的过程,这样通过一个递归的方式就可以完整地构建了整个组件树。
Vue的合并配置是什么?
Vue中合并配置有两种场景:1、外部调用new Vue(options)。2、创建组件vnode时通过Vue.extend(options)来构造继承自Vue的子类构造函数。两种场景构造函数都会执行实例的_init(options)方法,其中包含合并配置。
外部调用new Vue(options)如何进行配置合并?
1、_init()中执行vm.$options=mergeOptions(Vue.options,options,vm)来合并。Vue.options是在src/core/global-api/index.js(initGlobalAPI(Vue))中定义。
initGlobalAPI(Vue)它主要是干了2件事情:1、在Vue.options对象下创建属性名为(component(注册组件), directive(注册指令), filter(数据过滤))的空对象。2、把一些内置组件(keep-alive等)扩展到Vue.options.component中。
回到mergeOptions(),它的主要功能就是把parent和child两个对象根据一些合并策略,合并成一个对象并返回。1、递归把child.extends和child.mixins合并到parent上。2、分别遍历parent和child调用mergeField(key),对于不同的key由不同的合并策略,例如对于生命周期勾子函数,如果Vue.options和options都定义了相同生命周期勾子函数,则把两个勾子函数合并成数组。
总结就是,把Vue实例默认配置和new Vue(options)中options配置,配置合并。
组件场景如何进行配置合并?
1、src/core/global-api/extend.js(Vue.extend(options)),其中会把子组件传入对象(data,created等)和Vue.options一起合并到 子组件实例的 vm.options中。
2、在组件vnode进行patch时,会执行 创建vnode时挂载的勾子函数 即“patch方法遇到组件时发生了什么?”这一步。初始化子组件实例,再次进行配置合并(initInternalComponent(vm, options) 逻辑)。先把上一步合并得到的options赋给vm.$options,再把实例化子组件时传入的options(父实例,组件vnode占位节点parentVnode)保存到vm.$options 即"组件是如何执行_init方法的?"这一步的配置合并,另外还保留了 parentVnode 配置中的如 propsData 等其它的属性。
总结就是,把子组件实例和父组件实例配置 合并,把子组件占位vnode和子组件实例 配置合并。
大部分库、框架的设计都是类似的,自身定义了一些默认配置,同时又可以在初始化阶段传入一些定义配置,然后去merge默认配置,来达到定制化不同需求的目的。只不过在Vue场景下,会对merge的过程做一些精细化控制。
生命周期是怎么样的?
每个Vue实例在被创建之前都要经过一系列的初始化过程。例如需要设置数据监听、模版编译、挂载实例到DOM、在数据变化时更新DOM等。同时在这个过程中也会运行一些叫生命周期的钩子函数,给予用户机会在一些特定的场景下添加他们自己的代码。
源码中最终执行生命周期的函数都是调用callHook(hook)方法(src/core/instance/lifecycle),根据传入的hook字符串去拿到vm.$options[hook]对应的回调函数数组,然后遍历执行,执行时候把vm作为函数执行的上下文。vm.$options[hook]的相关生命周期钩子函数的内容定义在“配置合并”的时候完成了,因此 callhook 函数的功能就是调用某个生命周期钩子注册的所有回调函数。
各个生命周期钩子函数的调用时机?
beforeCreate & created(src/core/instance/init.js中_init方法)。beforeCreate 和 created 的钩子调用是在 initState 的前后,initState 的作用是初始化 props、data、methods、watch、computed 等属性。
那么显然 beforeCreate 的钩子函数中就不能获取到 props、data 中定义的值,也不能调用 methods 中定义的函数。
如果组件在加载的时候需要和后端有交互,放在这俩个钩子函数执行都可以。
如果是需要访问 props、data 等数据的话,就需要使用 created 钩子函数。
之后我们会介绍 vue-router 和 vuex 的时候会发现它们都混合了 beforeCreatd 钩子函数。
beforeMount & mounted(src/core/instance/lifecycle.js中mountComponent方法)。在mountComponent一开始就执行了beforeMount钩子。
mounted执行分两种情况:
1、期间通过render生成vnode和patch挂载真实Dom,最后如果Vue实例为根实例(vm.$vnode=null,组件占位节点vnode为null),则执行mounted勾子函数。
2、如果是子组件实例,在从父-》子生成真实DOM,子-》父 进行真实DOM挂载的过程中,挂载完毕就会执行insert方法(子-》父),其中包含执行mounted勾子函数。
总结:beforeMount的执行顺序是先父后子,mounted执行顺序是先子后父。
beforeUpdate & updated。在mountComponent中会new Watcher()创建渲染watcher,在监听到组件响应式数据变化时,会nextTick异步执行beforeUpdate。之后再判断如果 watcher是渲染watcher 且 已经完成mounted步骤,就会执行updated钩子函数。
beforeDestroy & destroyed(src/core/instance/lifecycle.js)。beforeDestroy 和 destroyed 钩子函数的执行时机在组件销毁的阶段。
beforeDestroy 钩子函数的执行时机是在 $destroy 函数执行最开始的地方,接着执行了一系列的销毁动作,包括从 parent 的 $children 中删掉自身,删除 watcher,当前渲染的 VNode 执行销毁钩子函数等,执行完毕后再调用 destroy 钩子函数。
在 $destroy 的执行过程中,它又会执行 vm.__patch__(vm._vnode, null) 触发它子组件的销毁钩子函数,这样一层层的递归调用,所以 destroy 钩子函数执行顺序是先子后父,和 mounted 过程一样。
activated & deactivated。activated 和 deactivated 钩子函数是专门为 keep-alive 组件定制的钩子。
组件注册是怎么样的?
组件注册分为全局注册和局部注册。
要注册一个全局组件,可以使用 Vue.component(tagName, options)(src/core/global-api/assets.js)。
全局注册Vue.component()方法会执行Vue.options.components[id] = Vue.extend(options),把组件实例挂载到Vue.options.components.id(即组件名)上。那么全局注册的组件实例就挂载在根Vue配置上了。
在Vue.extend时,会把“开发人员写的子组件配置”和“父组件默认配置” 合并。那么全局组件实例就挂载在当前子组件实例配置上了。所有组件都会经历这一步,所以都可以取到全局组件的实例进行初始化。
局部组件,在子组件创建vnode时,执行Vue.extend(options)会把“开发人员写的子组件配置”和父组件默认配置 合并传入当前子组件实例配置上,其中子组件配置options包含了我们注册的components组件对象。那么子组件就可以取到局部组件的实例进行初始化。
注意:我们知道组件vnode生成总共3步。1、“构造子类构造函数”。2、“挂载组件钩子函数(patch时候执行)”。3、"返回组件vnode实例"。全局组件的1在vue.component()方法中完成了,23在createElement中的creatComponent中完成。局部组件123都在createElement中的creatComponent中完成。至于一个组件会执行几次createElement,在"render函数是如何变成Vnode的?"中的注意中已经说明。
总结:当前组件实例中要能够拿到注册组件的实例,必须要先把注册组件实例放入当前组件实例配置中,而后在patch阶段中才能取到它进行初始化。不管全局还是局部,都在当前组件创建vnode构造子类构造函数中的配置合并阶段,把注册组件实例传给了当前组件实例作为配置。全局是Vue.options配置传过来的,局部是当前组件开发人员传入的配置传过来的。
异步组件是怎么样的?
在我们平时的开发工作中,为了减少首屏代码体积,往往会把一些非首屏的组件设计成异步组件,按需加载。区别就是第二个参数由 对象 变成了 函数。它进入createElement后,也会取执行createComponent。他通过resolveAsyncComponent(asyncFactory, baseCtor, context)得到子类构造函数Ctor。
异步组件的注册三种方式:
普通异步组件:
1、vue.component('name', func)传入一个函数,结果是vue.options.components[name]=func。
2、生成组件vnode执行createElement-》createComponent。createComponent中因为ctor不存在,会执行Ctor=resolveAsyncComponent(asyncFactory(异步函数),baseCtor(Vue),context(当前执行createElement的上下文))。
2.1、第一次执行resolveAsyncComponent:
把当前上下文(vm)放入asyncFactory.contexts中(哪个组件中有该异步组件,就会执行这个过程,把有该异步组件的组件实例收集起来)
(这个过程是异步的,会先执行下面完同步代码)执行异步加载去加载组件对象res,加载到后会把组件拿去构造子类构造函数Vue.extends(res) 得到ctor,把ctor放入asyncFactory.resolved中。再执行forceRender方法,它遍历每一个asyncFactory.contexts中的组件实例vm,去拿到vm._watcher(组件渲染watcher)执行updata()来重新渲染,之后再次进入createElement,进入到4。
2.2、resolveAsyncComponent返回一个undefined。
3、(到这里第一轮同步代码执行结束)回到createComponent,因为resolveAsyncComponent返回undefined,会去执行createAsyncPlaceholder。它创建了一个空的vnode节点(即注释节点)。
4、(第二轮同步代码),生成组件vnode执行createElement-》createComponent。createComponent中因为ctor不存在,会执行Ctor=resolveAsyncComponent()。此时发现asyncFactory.resolved中有ctor,返回ctor。接下来和普通组件流程一样,去“挂载勾子函数”和“返回组件vnode”。
Promise异步组件:
和普通异步组件基本相同。在2.1去执行异步加载res对象后,它不用回调函数来处理异步加载的返回结果,而是同步直接判断res是否是对象(import()异步加载会直接同步返回promise对象),是promise对象就由promise(resolve,reject)去处理返回结果。其实就是一个语法糖。
高级异步组件注册:
传入一个函数,这个函数返回一个配置对象。我们可以定义加载组件(component)对象,加载中(loading)组件对象,加载失败(err)组件对象,过A时间开始(前提是未加载到组件)显示加载中组件,过B时间(前提是未加载到组件)显示加载失败组件。
一开始若定义了相关组件都会给 构造子类构造函数 并赋值给asyncFactory.xxx中。
先判断delay,若为0直接显示loading组件,否则过A时间 且 加载结果没有出来,就设置asyncFactory.loading=true执行forceRender重新渲染,再次createElement-》createComponent执行到这里面的时候会把loading组件构造器返回出去。
再判断timeout,若timeout存在,则过B时间 且 加载结果没有出来或者加载结果为失败,就设置asyncFactory.error=true执行forceRender重新渲染,再次createElement-》createComponent执行到这里面的时候会把error组件构造器返回出去。
Vue的响应式原理是什么?
前面分析了在组件化的前提下,Vue如何把初始化数据渲染成DOM。接下来会通过分析“数据变化触发DOM重新渲染”来深入了解 Vue的响应式原理。
一、创建响应式对象
_init初始化时,initState(vm)完成了响应式对象的创建,主要是对 props、methods、data、computed 和 wathcer 等属性做了初始化操作,这里我们重点分析 props 和 data。
initProps初始化过程主要2件事:
1、调用defineReactive方法把每个prop对应的值变成响应式。
2、通过defindProperty的set和get方法把对vm.key(即我们在组件中用的this.key)的访问代理到vm._props.key。
initData初始化过程主要2件事:
1、调用 observe 方法观测整个 data 的变化,把 data 也变成响应式。
2、通过defindProperty的set和get方法把对vm.key(即我们在组件中用的this.key)的访问代理到vm._props.key。
observe方法作用:主要就是取Observer实例。根据 "data.__ob__" 判断是否实例化过Observe,若已经实例化过Observe,直接取ob = data.__ob__,并返回ob。否则实例化Observe,ob = new Observe(data),并返回ob。
Observer方法作用:
1、const dep = new Dep() 实例化Dep对象。
2、给data.__ob__赋值 this(即当前Observer实例)。
3、判断data是否是数组,是就循环每个子data调用observe方法。否则就对data每个属性调用defineReactive方法。
defineReactive方法作用:主要功能就是定义响应式对象。
1、const dep = new Dep() 实例化dep做为容器,get中用来收集依赖,set中用来对依赖派发更新。
2、get,用来收集依赖(收集订阅该数据的渲染_watcher)
3、set,用来派发更新(当该数据发生变化时,通知订阅该数据的_watcher重新渲染)
二、依赖收集
组件执行$mount调用的mountComponent中定义了updateComponent方法,并实例化了一个渲染Watcher(vm, updateComponent, noop)。
new Watcher(vm, updateComponent, noop)的流程:
1、vm._watcher=this(把this(当前Watcher类)赋值给当前vm实例的渲染_watcher)。
2、定义一个deps和一个newDeps数组,用来收集该渲染Watcher订阅了的响应式数据的dep对象。
3、this.getter = updateComponent(如果传入的第二个参数是函数,把函数赋值给 this.getter)。
4、this.value=this.get()。
4.1、pushTarget(this)。让当前watcher实例 成为 全局watcher(同一时间内, 只能有一个渲染_watcher成为全局watcher)。
注意:这里有一个“压栈”机制。一个targetStack储存历史渲染Watcher。一个Dep.target储存全局Watcher。当一个渲染_watcherA要成为全局Watcher时,发现现在_watcherB是全局Watcher,则把_watcherB推入targetStack,_watcherA上位。_watcherB想出来上位,也可以通过Dep.target=targetStack.pop()把_watcherA挤掉。
这个机制是为了解决组件嵌套组建情况,comA中有一个comB。_watcherA在位时,执行到patch阶段,comB的vm实例化开始,_watcherB上位,_watcherA被推入targetStack。当comB挂载完毕,又把_watcherA请出来上位,继续渲染下面的内容。
4.2、let value=this.getter.call(vm,vm)。相当于执行vm._update(vm._render(), hydrating)。vm_render()生成vnode时,会对vm上数据进行访问,触发数据对象的 getter。
4.3、进入defineReactive的get函数内,如果当前Dep.target存在(4.1已经赋值),就执行Dep.target.addDep(dep)。1、把dep收集进Dep.target(全局Watcher)的newDeps中。2、把(全局Watcher)收集进dep.subs。完成依赖收集,数据变更要派发更新就去dep.subs中找订阅该数据的渲染_watcher。所以在 vm._render() 过程中,会触发所有数据的 getter,这样实际上已经完成了一个依赖收集的过程。
4.4、traverse,这个是要递归去访问 value,触发它所有子项的 getter。
4.5、popTarget(),即4.1中“压栈”机制中的targetStack.pop()。就是把 Dep.target 恢复成上一个状态,因为当前 vm 的数据依赖收集已经完成,那么对应的渲染Dep.target 也需要改变。
4.6、this.cleanupDeps(),依赖清空。因为Vue 是数据驱动的,所以每次数据变化都会重新 render,那么 vm._render() 方法又会再次执行,并再次触发数据的 getters,所以 Wathcer 在构造函数中会初始化 2 个 Dep 实例数组,newDeps 表示新添加的 Dep 实例数组,而 deps 表示上一次添加的 Dep 实例数组。
在执行 cleanupDeps 函数的时候,会首先遍历 deps,移除对 dep.subs 数组中 Wathcer 的订阅,然后newDeps 和 deps 交换,并把 newDepIds 和 newDeps 清空。那么为什么需要做 deps 订阅的移除呢,在添加 deps 的订阅过程,已经能通过 id 去重避免重复订阅了。
考虑到一种场景,我们的模板会根据 v-if 去渲染不同子模板 a 和 b,当我们满足某种条件的时候渲染 a 的时候,会访问到 a 中的数据,这时候我们对 a 使用的数据添加了 getter,做了依赖收集,那么当我们去修改 a 的数据的时候,理应通知到这些订阅者。那么如果我们一旦改变了条件渲染了 b 模板,又会对 b 使用的数据添加了 getter,如果我们没有依赖移除的过程,那么这时候我去修改 a 模板的数据,会通知 a 数据的订阅的回调,这显然是有浪费的。因此 Vue 设计了在每次添加完新的订阅,会移除掉旧的订阅,这样就保证了在我们刚才的场景中,如果渲染 b 模板的时候去修改 a 模板的数据,a 数据订阅回调已经被移除了,所以不会有任何浪费,真的是非常赞叹 Vue 对一些细节上的处理。
三、派发更新
当响应式数据发生变化,执行dep.notify()。遍历所有的 subs(渲染_watcher 的实例数组),然后调用每一个 _watcher的 update 方法(src/core/observer/watcher.js)。对于 _watcher 的不同状态,会执行不同的逻辑,computed 和 sync 等状态先不分析,在一般组件数据更新的场景,会走到最后一个 queueWatcher(this) 的逻辑( src/core/observer/scheduler.js )。
这里引入了一个队列的概念,这也是 Vue 在做派发更新的时候的一个优化的点,它并不会每次数据改变都触发 watcher 的回调,而是把这些 watcher 先添加到一个队列里,然后在 nextTick 后执行 flushSchedulerQueue(src/core/observer/scheduler.js)。这里有几个细节要注意一下:1、首先用 has 对象保证同一个 Watcher 只添加一次。2、接着对 flushing的判断,else 部分的逻辑稍后讲。3、最后通过 waiting 保证对 nextTick(flushSchedulerQueue)的调用只有一次flushSchedulerQueue异步执行。
一、队列排序
queue.sort((a, b) => a.id - b.id) 对队列做了从小到大的排序,这么做主要有以下要确保以下几点:
1.组件的更新由父到子;因为父组件的创建过程是先于子的,所以 watcher 的创建也是先父后子,执行顺序也应该保持先父后子。
2.用户的自定义 watcher 要优先于渲染 watcher 执行;因为用户自定义 watcher 是在渲染 watcher 之前创建的。
3.如果一个组件在父组件的 watcher 执行期间被销毁,那么它对应的 watcher 执行都可以被跳过,所以父组件的 watcher 应该先执行。
二、队列遍历
在对 queue 排序后,接着就是要对它做遍历,拿到对应的 watcher,执行 watcher.run()。这里需要注意一个细节,在遍历的时候每次都会对 queue.length 求值,因为在 watcher.run() 的时候,很可能用户会再次添加新的 watcher,这样会再次执行到 queueWatcher。
三、状态恢复
这个过程就是执行 resetSchedulerState 函数(src/core/observer/scheduler.js)。逻辑非常简单,就是把这些控制流程状态的一些变量恢复到初始值,把 watcher 队列清空。
接下来我们继续分析 watcher.run() (src/core/observer/watcher.js)
run 函数实际上就是执行 this.getAndInvoke 方法,并传入 watcher 的回调函数。getAndInvoke 函数逻辑也很简单,先通过 this.get() 得到它当前的值,然后做判断,如果满足新旧值不等、新值是对象类型、deep 模式任何一个条件,则执行 watcher 的回调,注意回调函数执行的时候会把第一个和第二个参数传入新值 value 和旧值 oldValue,这就是当我们添加自定义 watcher 的时候能在回调函数的参数中拿到新旧值的原因。
对于渲染 watcher 而言,它在执行 this.get() 方法求值的时候,会执行 getter 方法(updateComponent),这就是当我们去修改组件相关的响应式数据的时候,会触发组件重新渲染的原因,接着就会重新执行 patch 的过程,但它和首次渲染有所不同。
组件如何更新的?
现在我们知道,响应式数据发生变化时候,会触发订阅该数据的渲染Watcher的回调函数(updateComponent),进而执行组件更新。
首先进行sameVNode(oldVnode, vnode)判断新旧节点是否相同,来走到不通的逻辑。
若新旧节点不同:直接替换节点。其步骤分3步:
1、createElm()创建新节点并插入Dom。
2、更新父的占位符节点。找到当前 vnode 的父的占位符节点,先执行各个 module 的 destroy 的钩子函数,如果当前占位符是一个可挂载的节点,则执行 module 的 create 钩子函数。
3、删除旧节点。
若新旧节点相同,会调用 patchVNode 方法,它是把新的 vnodepatch 到旧的 vnode 上,我把它拆成四步骤:
1、执行 prepatch 钩子函数。当更新的 vnode 是一个组件 vnode 的时候,会执行 prepatch 的方法拿到新的 vnode 的组件配置以及组件实例,去执行 updateChildComponent 方法,updateChildComponent 的逻辑也非常简单,由于更新了 vnode,那么 vnode 对应的实例 vm 的一系列属性也会发生变化,包括占位符 vm.$vnode 的更新、slot 的更新,listeners 的更新,props 的更新等等。
2、执行 update 钩子函数。回到 patchVNode 函数,在执行完新的 vnode 的 prepatch 钩子函数,会执行所有 module 的 update 钩子函数以及用户自定义 update 钩子函数,对于 module 的钩子函数,之后我们会有具体的章节针对一些具体的 case 分析。
3、完成 patch 过程。如果 vnode 是个文本节点且新旧文本不相同,则直接替换文本内容。如果不是文本节点,则判断它们的子节点,并分了几种情况处理:1、oldCh 与 ch 都存在且不相同时,使用 updateChildren 函数来更新子节点,这个后面重点讲。2.如果只有 ch 存在,表示旧节点不需要了。如果旧的节点是文本节点则先将节点的文本清除,然后通过 addVnodes 将 ch 批量插入到新节点 elm 下。3.如果只有 oldCh 存在,表示更新的是空节点,则需要将旧的节点通过 removeVnodes 全部清除。4.当只有旧节点是文本节点的时候,则清除其节点文本内容。
4、执行 postpatch 钩子函数。再执行完 patch 过程后,会执行 postpatch 钩子函数,它是组件自定义的钩子函数,有则执行。