目录
v-bind="$attrs" 和 v-on="$listeners" 是如何生效的
23,vue中的eventEmitter原理($on、$emit、$off、$once)
1,你对vnode的一些误会
首先明确vnode做了什么事情:带来了极大的便利,及不错的性能。
因此需要告诉你的是,“vnode带来了性能上的提升”,这种说法是错误的!vnode不仅仅无法提供性能的一丁点提升,反而其中对vnode的一些操作会导致性能消耗。
vnode所带来的最大优点,是操作上的便利,可以用vnode代理所对应的DOM元素,而需要去做信息比对的时候可以不必通过DOM的接口查询,而之间很便利的用vnode属性实现。比如key,tag,text等。因此你可以看到,vue的一切操作都是先基于vnode比对,如diff算法等。并且数据发生了变化,也是先去生成新的vnode,而非直接拿着数据去DOM文档中更新。
可以说,vnode是vue中数据与页面的一个中间桥梁,它的作用并非减少了从数据到最终页面更新这个必要环节,而是优化了从数据到页面的过程。但是无论过程如何,结果终究是要操作DOM,因此必要操作不会减少,这也就是我为什么说他无法提供性能的提升。而过程的优化,也仅仅是让操作上变的直观和便利,代价就是创建了vnode,遍历了vnode,这也就是为什么我说vnode会带来性能上的消耗。
我看到网上对vnode最普遍的一种说法是:vnode将多次DOM操作集中一次更新,从而优化了性能。
第一点,这种说法本身就是错误的。vnode并不会将DOM集中一次更新,而是在diff过程中,若发现了某个节点需要改变,则操作vnode的同时同样的操作DOM(比如移动位置,比如增删);
第二点,即使前半段说法是正确的,后半段的结论也是错误的。将DOM操作集中一次,并不会优化性能。这或许颠覆很多人的认知,但是事实就是如此。
一次DOM操作,分为JS对DOM接口的操作,以及页面的渲染更新。而页面的渲染更新,本身就是在JS脚本执行完成之后再占用主线程进行的,此时会有一系列的分层、布局、合成的操作,而渲染进程做完这些步骤之后将生成的图片添加至显卡前缓冲区,然后由浏览器主进程最终展示在界面上。而关键点在于:页面的渲染是在JS执行之后的。也就是说无论JS中对DOM操作了多少次,最终都只会渲染更新一次。有疑惑的朋友可以模拟一下在一个for循环中插入一万个div节点,然后打开performance看一眼执行过程就明白了。
当然这种说法也不完全正确,如果插入的同时有读取到DOM的一些特殊属性,那么会导致页面的同步渲染。比如offsetwidth。而这牵涉到另一个概念,叫做强制同步布局及布局抖动。不过vue也必然不会这么去做。同样你也可以试试,在上述for循环内加上 console.log(div.offsetWidth),如果你这么做了的话,要是没猜错你的页面应该已经崩了。
因此无论是vnode还是documentFragment,说统一插入文档带来性能提升其实都是不正确的。因为JS中对DOM的操作一般都会等到JS执行完成以后再计算样式、布局。
言归正传,我们再来回过头看看尤大大的这句话 “来了极大的便利,及不错的性能”。你应该这么去理解:带来了操作上的方便,同时不会浪费太大的性能。
2,vue数据劫持的基本原理
vue的数据劫持分为两大类:
第一种是Object,通过defineReactive函数通过操作defineProperty实现数据的劫持以及依赖的添加。其中每一个属性,以及所对应的引用类型的值,都会添加上一个Dep实例,而在$set或者修改了属性引用、属性值的时候,就会通过Dep通知到相应的Watcher;
第二种是Array,添加依赖是通过暴力的递归遍历实现;触发依赖是通过修改原型链上的原型方法实现,包括 push、pop、shift、unshift、splice、reverse、sort七种方法。通过代理模式,在使用数组的这七种方法的时候就能实现依赖的触发。
本质上来说,vue的响应式数据有如下几类:data、props、inject;而对数据进行监听的有如下几类:watch、computed、组件实例。其中computed比较另类,它更像一个中间层;而watch则是利用回调做一些自定义的逻辑处理,组件实例则是通过patch(具体的说应该是先重新通过render创建vnode,然后通过vnode与oldVnode的patch比对进行更新)。而所谓的响应式,就是一个数据发生变化,通知观察者变化的过程。
3,Vue.extend原理
vue的extend方法本质上来说就是一种寄生式组合继承。首先通过盗用Vue或其他父类的构造函数,实现Sub类的初始化,然后将Sub原型的__proto__指向Vue的prototype;并且同时会在Sub上添加一些额外的属性,如super属性,superOptions属性,extendOptions属性等等。当然,子类的选项合并是在extend中进行的,而非盗用的构造函数中。
4,Vue.nextTick原理
nextTick其本质上就是一种异步回调的实现,类似于node中的process.nextTick。但是会做一些执行环境的兼容性检测,若支持Promise,则优先使用Promise;否则会使用其他的一些接口,其优先级如下:
微任务 > 宏任务
Promise > MutationObserver > setImmediate > setTimeout
注意:setImmediate只存在于node.js,在浏览器环境下无法直接使用。
5,this.$set原理
$set所做的一件事情就是帮助某些无法触发响应式的操作强行触发响应。其对于数组和对象的实现原理如下:
对于Array,调用splice方法设置属性,利用数组的数据劫持原理触发依赖。当然,在这之前会通过比较设置的index及length的大小,修改数组的length为合适的值;
对于Object,则会利用引用类型的observer类中的dep手动调用notify方法,以此触发依赖。(这也就是我在数据劫持原理中说的引用类型会额外多加一个Dep的原因)。
6,this.$delete原理
$delete与上述类似,也是在删除操作的同时去强行触发依赖。
对于Array,还是使用splice方法删除属性;
对于Object,通过delete删除属性,并通过Dep触发依赖。
7,directive原理
directive的基本实现原理,就是通过patch过程中的一系列钩子函数(这里的钩子并不是定义directive的时候用户定义的,而是vue内部的事件钩子)的监听,以此来执行相应的自定义逻辑。
在patch过程中,create、 update、destroy钩子都会调用updateDirectives函数,而在此函数当中,会去判断某个dir是否是新增(是否存在对应的oldDir),选择调用bind钩子还是update钩子;
当对应的vnode创建完成之后,就会触发相应的inserted钩子;
而componentUpdated,则会在执行完patchVnode的时候调用;
unbind则也是对比oldDirs中是否有某个dir不复存在,并对着一些dir调用unbind钩子。
8,filter原理
filter的作用就是对相应的数据做一些格式化处理。
而对filter的处理,其实是在语法分析的时候,对应的bindingAttr(动态属性)、指令属性(v-开头)、或者mustache模板。上述三种情况在解析其内容的时候都会调用parseFilters,而parseFilters函数就是专门用来处理filter的函数。
不能简单地以 | 分割,理由如下:
:key="name | filterName"
以上需要正常处理
:key="'name | filterName'"
:key="`name | filterName`"
:key="name || filterName"
:key="/id|featId/.test(id).toString()"
以上场景都不应该将变量当作过滤器处理
filter的内部其实就是一个for循环遍历字符串,在循环的内部通过正则判断是否是在上述几种情况的包裹之内,如果是则跳过,若不是再去判断是否有 | 符号,若有则将 | 之前作为expression表达式,将后续的每一个 | 右边的内容都添加至filters当中,最终会递归这个filters,生成
_f("filter1")(name)或者_f("filter3")(_f("filter2")(_f("filter1")(name)))形式的字符串,并返回。在vue的运行时环境中,就会通过_f调用对应的filter方法,进行格式化。
9,component原理
父组件中对应组件vnode的创建:
1,在创建vnode的时候,会通过对render函数的第一个参数的简单检查,判断当前正在create的是一个普通节点,还是一个组件,如果是组件,则会调用createComponent方法,进行组件vnode的创建。
2,首先会初始化组件节点的构造函数。原理很简单,就是调用extend:
Ctor = Vue.extend(xxx);xxx即为某个vue组件文件的export值。
3,对异步组件做一些处理(参考第9题)
4,处理子组件节点中绑定的v-model(参考第10题)
5,处理props(参考第11题)
6,处理v-on事件和v-on.native事件(参考第12题)
7,最终生成一个组件vnode,tag为 vue-component-xxx,并且会包含componentOptions属性,该属性中存在构造函数、props、listeners等属性
组件的实例化:
在创建组件节点的同时,还会为其初始化相应的钩子:init、prepatch、insert、destroy。
而组件的实例化就是在init钩子调用中实现。
init钩子在第一次patch的时候(是的,初始化也是利用patch完成的,关于这一点,参考第13题吧。真是一个component给自己挖了无数坑),判断如果当前的vnode中存在data属性,则代表其为组件vnode,就会对其调用init钩子。
在init钩子内部,其实就是首先调用之前初始化好的构造函数就行new操作,再将将生成的实例进行$mount创建为真实的DOM节点,最后将当前生成的真实DOM节点添加至父节点的DOM树当中。
组件的update:
自组件的更新是通过组件vnode的prepatch钩子实现的。
在一次patch中,会去调用组件vnode的prepatch钩子。而该钩子函数内部,就会去通知子组件更新。注意,这里有一个很重要的知识点,就是需要区分子组件和组件vnode。组件vnode可以理解为子组件在父组件的“入口”,而他和子组件有一定联系,可以理解为父子组件的消息通道,但是组件vnode和子组件并不是一回事。最终子组件的更新是通过该组件vnode的事件钩子去触发的。
而对子组件的“通知”,其实是通过对其_props属性重新赋值来实现的。因为_props是子组件中的响应式属性,因此改变_props的值就会触发响应式更新。当然,有可能子组件并没有用到props,这种情况下可能也会需要更新。比如说scopedSlots发生了改变,那么这个时候就会通过$forceUpdate触发更新。而该更新的任务会作为微任务添加至当前正在执行的微任务队列的队尾。
一个冷知识:父组件的更新其实是在子组件更新前就完成了的。至于原因,是因为update并不是一个递归执行的过程,而是一个异步回调的过程,父组件的update会通知子组件update,而子组件的update只会添加至微任务队尾,并不会同步执行。至于为什么生命周期钩子中的子组件updated总是在父组件updated之前,可以看一看如下神奇的代码:
function callUpdatedHooks(queue) {
let i = queue.length;
while (i--) {
const watcher = queue[i];
const vm = watcher.vm;
if (vm._watcher === watcher && vm._isMounted && !vm._isDestroyed) {
callHook(vm, "updated");
}
}
}
也就是说,updated钩子是从后往前调用的!surprise!what‘s a faker.
10,异步组件的实现原理
首先,什么是异步组件:
Vue.component(
'async-webpack-example',
// 该 `import` 函数返回一个 `Promise` 对象。
() => import('./my-async-component')
)
或者高级异步组件:
const AsyncComponent = () => ({
// 需要加载的组件 (应该是一个 `Promise` 对象)
component: import('./MyComponent.vue'),
// 异步组件加载时使用的组件
loading: LoadingComponent,
// 加载失败时使用的组件
error: ErrorComponent,
// 展示加载时组件的延时时间。默认值是 200 (毫秒)
delay: 200,
// 如果提供了超时时间且组件加载也超时了,
// 则使用加载失败时使用的组件。默认值是:`Infinity`
timeout: 3000
})
在异步导入语法中,返回的是一个Promise对象,因此可以定义好resolve函数和reject函数,利用该Promise的回调来做相应的处理。如果设置了loading,则会通过delay设置setTimeout,如果到了delay设定的时间还未加载成功,则会使用该loading作为暂时的展示模板,等到加载完成触发resolve再通过$forceUpdate通知组件更新为正确的内容。而timeout的原理也类似,设置一个setTimeout延时器,如果到了timeout设定的时间依旧为加载完成,则报错。
11,组件中的v-model
首先对于v-model,会在语法分析的时候作为directives处理(存放在AST节点的directives数组中),而在generate生成render函数字符串的时候,会判断当前节点是否为component,如果是,则会将v-model添加至当前节点的model属性中,其结构如下:
astElm.model = {
value: `'information[name]'`,
expression: JSON.stringify("information[name]"),
callback: `function ($$v) {
information[name] =
$set(information,name,typeof $$v === 'string'? $$v.trim():$$v) }`,
};
最终生成render函数的时候,就会将上述对象添加至data属性中。
而在组件当中,则会根据options中的model选项(也就是自定义的model),去找到相应的prop和event属性,如果没有声明,则会默认取'value'和'input',然后将value设置为attrs属性中对应prop的值,最后会将event添加至on上,回调设置为callback。
也就是说现在的render函数中,on中多了event函数体为callback,attrs中多了一个prop且值为value。
最终,on属性都会添加至_event当中,简单理解为可以通过$emit去触发的事件;而attrs在props设置了对应的key则会添加至propsData当中。(这两个知识点在11和12做详解)
然后呢,就可以在相应的表单控件上通过v-on和v-bind绑定相关的值和事件,从而实现组件v-model的实现。
至于为什么可以直接修改value,是因为这里并不是修改的props,而是通过this.$emit操作父组件的callback函数,由callback函数来修改v-model的值。就好比通过v-on绑定的事件,由组件内部通过this.$emit调用,以此操作父组件的数据。
12,父子组件传值:props的处理
对于template模板中的写法,无论是组件元素还是普通的DOM元素,动态绑定属性的方法都是一样的,都是 ` :name="dataName" `的形式,而在template解析为render字符串的过程中,会将所有的这一类属性都处理为render函数中data的attrs属性。
而如果你有使用原生render函数的习惯,你会发现,其实data中不仅仅有attrs,还会有props属性。这二者的区别就在于:对于attrs来说,是会有可能被处理为DOM元素中的attribute的,而props则永远都不会作为attribute。也就是说,在template语法中vue会默认将你所写的所有动态属性都作为可能添加attribute的元素,而你自己写的render则可以做更细的区分。
最终,会在组件vnode创建的时候,就去处理这两种不同的传值形式。处理逻辑很简单,首先判断options选项中有无props(也就是你是否在子组件中接收了传递的值)。如果有,则递归props的每一项去进行匹配,匹配的规则为优先匹配props,然后再是attrs。
如果props匹配上了对应的子组件的props,则不会管attrs中是否有该属性,如有也会依然将其处理为attribute;如果props未能匹配上,则再去匹配attrs,如果匹配上了attrs和props,会从attrs中删除匹配项,也就是说attrs匹配上的项就不会再将其作为attribute了。
这里初始化之后的props,会作为propsData存放在vnode的componentOptions中,供props初始化使用。
13,v-on和v-on.native在组件中的实现原理
在组件vnode创建的时候,会将v-on绑定的事件处理为listeners,将v-on.native处理为vnode中data的on属性。
on属性,会在patch过程中,通过addEventListener的方式添加至DOM元素的事件,对应的是真实的事件,比如click,mousemove等;
listeners属性,会在子组件实例化的时候通过initEvents事件通过$on、$once等创建为_events中的事件。而这个_event呢,其实就是一个eventEmitter的实现而已(会在后面做详细介绍)。
14,patch函数是如何用作初始化的
先说知识点:vue中是没有单独初始化DOM这样的逻辑实现的。无论是第一次创建,还是后续的patch更新,其实都是用patch函数来实现的。
patch函数一般会传递两个主要参数,oldVnode和vnode。而在初始化的时候,传递$el作为oldVnode,$el来自于手动调用$mount传递的第一个参数,或者vue初始化之时的el属性。因此可能有,也可能没有。
对于没有的情况,patch内部判定oldVnode为空,则会走createElm的逻辑,对应的就是递归的创建,包括普通节点、组件节点;
而若oldVnode存在,也会有两种情况:确实是进行patch,oldVnode也的确为上一次创建的vnode;又或者该oldVnode为$el。则会去判断节点是否存在 SSR_ATTR 属性,如果存在,则代表当前节点是一个初始化带挂载的节点,则做hydrate “激活” 处理。简单理解为复用 $el 的同时进行初始化。
15,data原理
1,选项合并。首先在组件初始化的时候会调用mergeOptions进行选项的合并。如果说当前实例继承(extend)自别的组件,那么还会对这个super组件的data也进行相应的合并,合并的规则为:如果子类没有父类的key,则做$set;如果有,并且二者都为Object,则递归该步骤
2,data重置。将data重新赋值为上述合并后的结果。(注意:在这里我做了一个逻辑上的偷换概念,其实返回的并不是合并之后的对象,而是待执行的合并函数,也就是说上述应该只是创建合并函数的过程,最终的合并会在call调用data函数的时候正式进行。这里只是为了表述和理解上的方便,请大家悉知)
3,初始化。在initState函数中对data进行初始化,也就是在这里正式的调用data.call(this)(本质上就是调用data工厂函数),并且赋值给_data
4,defineReactive。然后就是进行一些响应式的处理,会递归的为每一个属性及值都处理为响应式数据(响应式参考第1题)
5,代理。通过defineProperty函数的get和set将_data代理到this上,从而实现通过访问this属性的方式去获取或操作data
16,props原理
1,格式化。将各种写法的props转换成标准的格式。标准格式为对象加type属性(自动转换的标准形式没有default)
2,选项合并。在组件初始化的时候会调用mergeOptions进行选项的合并。同样也是会对super及自身的props进行合并,合并规则类似于Object.assign(Object.create(null), parentVal, childVal)
3,初始化。在initState函数中,完成对props的初始化。本质上就是通过对比props和propsData,如果propsData有值且符合type规则,则将propsData的值作为_props对应key的value;否则,则使用default作为_props对应key的value
4,defineReactive。注意,在_props中,并不会将所有的数据都变为响应式。只会将_props的根属性、以及default的返回值给设置响应式。也就是说如果从父组件传递的值为非响应式数据的化,那么在子组件中它也依旧是非相应式的
5,代理。通过defineProperty函数的get和set将_props代理到this上,从而实现通过访问this属性的方式去获取或操作props
props的具体实现需要结合第11点理解,这里只是初始化的大致流程。
17,methods原理
初始化
1,选项合并。合并规则与props相同
2,初始化。methods的初始化很简单,就是通过bind将函数硬绑定至当前的实例上
3,直接通过属性设置,将当前的methods属性同样设置为组件实例的属性,从而实现this访问
为什么子组件调用父组件方法不需要考虑指针
methods中对bind的使用也就导致了可以对这些方法进行任意的使用,而不用担心作用域。这也就是为什么通过v-on向子组件传递的某个方法可以直接进行使用,而不用考虑函数内部的this指向。
甚至基于这种特性,可以在vue中实现一些发布订阅模式,或者eventEmitter,以此来实现跨组件的通信(其实利用vue创建eventBus就是使用了vue内部的eventEmitter)
18,computed原理
初始化
1,选项合并。合并规则与props相同
2,初始化。computed的初始化,就是一个创建Watcher、代理get函数的过程。computed如果是方法的形式,则其函数体就会被当作computed属性的get方法;若是通过对象get和set的方式,则就是将对应的get或set设置给computed的get。而对于get、set的设置依旧是通过defineProperty,但是不同的式computed并不会用到defineReactive,因为computed本身并不是一个常规的属性。又或者说,它属于中间属性,主要用来对一些常规属性做逻辑处理。因此呢,computed的实现上,更加与watch属性或者组件实例比较相似。
2.1,创建_computedWatchers。该Watcher主要通过get函数,将内部用到的一些数据的依赖绑定至当前computed对应的watcher实例上。
2.2,代理get函数。在最终使用中,并不是直接用到computed所定义的get函数,而是一个经过代理的get函数。而该get函数的作用,就是通过dirty属性判断computed是需要重新计算,还是使用缓存。同时,computed的使用者的依赖就是在此get函数中添加的。
3,通过defineProperty设置在当前实例上,从而实现用this访问。
computed中的依赖处理
computed在依赖的处理中,是作为“中间层”,其上层即为使用者,如组件实例,又或者是watch,甚至是其他computed;而其下层数据,即为props,或者data等一类的响应式数据。
添加依赖:
computed首先在创建Watcher的时候,就会在Watcher中初始化一个get方法,而该get方法对应的就是computed的函数体或者computed对象的get方法。但是此时并不会马上调用,而是会在上层使用者使用了该computed的时候去调用get方法,同时利用该get方法将computed添加上对应数据的依赖;同时在代理的get函数内部,又会为上层使用者同时添加上相同的数据依赖。
触发依赖:
不应该说是computed触发依赖,而是data或props等属性触发了依赖。
最终的computed更新的周期顺序如下(以组件实例作为使用者):
1,数据改变,通过响应式原理先通知通知computed的watcher,再通知到上层使用者组件实例的Watcher
2,comuted的Watcher收到通知,将自身的dirty属性设置为true(该步骤是同步的)
3,组件实例的Watcher收到通知,将自身update添加至schedulerQueue,作为微任务队列中的一个待执行的任务(也就是最终触发patch是异步的)
4,进入微任务的检查点,并执行了任务队列中的所有回调,包括该组件实例patch
5,patch过程中,会使用到computed属性,因此触发computed属性的代理get,发现dirty为true,重新计算获取最新值,计算完成之后将dirty属性值为false,同时返回计算结果
computed缓存原理
在上述的过程中,若后续还有使用到该computed的地方,但是由于dirty为false,因此直接返回第一次的计算结果,而不会重复计算。简单来说,computed通过dirty属性实现了计算结果的缓存。
19,watch原理
watch作为vue三大Wathcer类之一,而它的作用是最单纯的,就是通过响应数据的变化,触发回调。
基本原理
watch的逻辑也不复杂。首先是初始化,也就是利用watch的回调,或者设置的选项最终调用$watch去创建相应的Watcher类,并且返回unwatchFn函数。然后就是Wacher的初始化,对于watch的Wacher类来说,get有可能是函数,也有可能是字符串,如 "someData.name"。最终就会在内部对字符串转换为相应的get函数,然后通过调用get函数设置上相关数据的依赖。而等到相关响应式数据发生变化时,就会通知到当前watch的Wacher,从而将callBack添加至schedulerQueue中,作为微任务等待执行回调。
deep属性原理
在未设置deep属性的时候,get函数只会使用到当前最外层的属性,也就是说对于该属性内部的对象属性,其实是无法添加依赖的;而当使用了deep属性时,就会额外增加一步dfs的过程,将该属性下的所有对象都添加上依赖,从而实现任意一个属性的改变,都会通知当前watcher。
20,钩子原理,及其具体的触发时机
对于vue中各个生命周期的钩子,其实就是通过在相应的时机进行callHook实现的。
beforeCreate:在选项合并完成、initLifecycle(初始化生命周期对应的属性)、initEvents(初始化eventEmitter)、initRender(初始化$slots及$createElement函数)完成之后
created:beforeCreate之后就会进行inject、props、methods、data、computed、watch、provide的初始化,初始化完成之后,就会调用created钩子
beforeMount:上述过程之后,就会在init最后调用$mount去挂载vue,而在mount函数的开始,就会调用beforeMount钩子
mounted:将组件实例注册为Wacher类,并且实例化之后(该实例化过程就包括了生成vnode、patch的全过程),触发mounted钩子
beforeUpdate:在组件实例接收到更新通知时,就会将更新任务添加至schedulerQueue中。而等到下一次的检查点,也就是微任务开始执行,并执行到相应实例的更新操作的时候,触发beforeUpdate
updated:在flushSchedulerQueue中所有任务执行完成之后,也就是所有由vue添加的异步任务执行完成之后,就会调用updated钩子
beforeDestroy:在自动或手动调用$destroy的开始位置调用beforeDestroy钩子
destroyed:在对该组件进行一次vnode为null的patch(也就是销毁oldVnode)完成之后调用
21,插槽的实现原理
详细版
1,语法分析过程中,会首先对template中使用到插槽相关的内容做处理:
slot-scope="value", v-slot:name="value" , #name="value" 等形式会将value添加为slotScope属性;
v-slot:name #name slot="name" 等形式会将name添加为slotTarget属性;
2,在上述步骤之后,若存在slotScope属性,还会将当前AST节点添加至父节点的scopedSlots中;
3,在generate过程中,若当前节点存在scopedSlots属性,则会将scopedSlots的每一项都处理为key value的形式,key为default,或者slotTarget;value为根据插槽内容生成的createElement函数。并将该属性添加至render函数对应data属性的scopedSlots属性中。如下述:
h("template", {
scopedSlots: {
default() {
return h("div");
},
definedName() {
return h("div");
},
},
});
4,在生成vnode的时候,会将相关的data也一并作为组件vnode的属性。此时的组件vnode中就有了scopedSlots属性;
5,等到父组件patch初始化之时,就会调用相应组件vnode的init钩子去初始化相应的子组件;
6,在init函数中就会调用initRender,对$slots和$scopedSlots进行初始化。其中$scopedSlots先初始化为空对象,$slots则是根据对应组件vnode的子元素生成,生成的key为子元素的slot属性,或者default;
7,在子组件调用render函数生成vnode之前,会首先给$scopedSlots赋值;而值的来源,即为父组件中对应的组件vnode的scopedSlots(对应的就是第三步);
8,当子组件使用到相应的插槽之时(也就是使用了slot标签),就会使用this.$scopedSlots去获取到对应slotTarget的createElement函数,通过对该函数的调用获取到插槽内容。同时如果并未从$scopedSlots中找到对应的内容,则会取自身slot的children作默认展示。当然这一步同样是在子组件的generate过程中先预处理的,简单说就是将slot标签处理为一个使用this.$scopedSlots或者默认内容作为返回值的函数。
简略版
总结来说,可以这么理解:
在父组件的generate过程中,会将v-slot相关的内容处理为当前组件节点的scopedSlots,而后续子组件实例化,就是利用该属性去初始化this.$scopedSlots的值;
在子组件的generate过程中,会将slot标签节点预处理为一个返回this.$scopedSlots(或者默认值)的待执行函数,而在调用render生成vnode之时,就会利用该函数去调用this.$scopedSlots生成插槽内容。
插槽是vue中最绕最难理解的知识点之一,如果这里大家看不懂也不要着急,的确是我表述的不清楚。因为逻辑很多,很绕。很难去比较精简的将如此复杂的知识整理为短短几句话。我后续会拎出来单独作为一个知识点做介绍。
22,$attrs及$listener原理
基本原理
$attrs的实现,就是在组件初始化的过程中,从父组件中对应的组件节点中的data中去取attrs;
$listener则指向的当前组件的listeners。listeners我们之前介绍过,对应 NO.12。可以理解为$listener中包含当前组件实例的所有事件。
v-bind="$attrs" 和 v-on="$listeners" 是如何生效的
正常来说,v-bind和v-on会在语法分析的时候就做单独处理,但是对于v-bind=""和v-on=""这种形式则会当作directives在generate的时候再做处理。
而他们的处理也很简单,因为出现这种形式的指令声明则代表参数一定要为Object,不是则报错,若是则遍历添加。对于v-on来说会添加至render参数的data中的on上;对于v-bind来说会判断key是否为class或者style,再判断是否使用了prop修饰符,最终可能会添加为style、class、attrs或者domProps。
23,vue中的eventEmitter原理($on、$emit、$off、$once)
vue中的$on、$emit等方法,本质上来说就是一种eventEmitter,一种发布订阅模式。通过$on订阅某个事件,通过$off取消订阅,通过$emit发布消息。并且在vue中,eventEmitter的载体为实例对象,这也就是为什么在任意组件文件中都可以使用this.$emit或this.$on,而相互之间互不干扰。
在一个vue实例对象中,会存在一个叫做_event的容器,而该容器存放的就是通过$on注册的事件及其回调。比如在父组件中使用某个子组件的时候,通过组件节点挂载了一些v-on或者@,那么此时对于子组件来说,在实例化的时候就会将这些挂载的事件注册为相应的_event,本质上也就是通过$on进行添加。因此,就可以在子组件内部通过this.$emit去触发这些事件。
而对于$once来说,实现上就是一个经过特殊代理的回调,然后再通过$on添加该代理函数。在一次执行之后,就会自动从相应的事件中删除相关回调。
24,$mount原理
$mount的作用就是将Vue进行实例化,并挂载在真实的DOM节点上。mount具体的步骤如下:
1,生成render。由template模板进行一系列词法分析、语法分析、generate,最终通过new Function的形式生成render。在实际运行中,这一步骤会进行预处理。也就是说并不是在mount的时候才会生成render,而是提前由vue-loader在webpack打包的时候就处理完成。如果用户并未使用template而是用了render函数,则这一步会跳过;
(callHook("beforeMount")在这里)
2,注册Watcher。将当前组件实例注册为Watcher类,而该Watcher类的get方法,就对应的是第3,4步;
(若是patch的话,callHook("beforeUpdate")在这里)
3,生成vnode。由之前生成(或许是用户自己写的)的render函数,通过createElement进行深度优先的遍历,创建一个个相互依赖的vnode;
4,创建或patch。无论是第一次创建还是后续的patch,其实都是由patch函数实现的。如果是创建,则就是简单的遍历vnode,然后创建对应的DOM元素;如果是patch,则就是对vnode与oldVnode进行一个个比对,也就是diff算法比对,将变动项的vnode与对应的DOM都相应的进行修改。
(callHook("mounted")在这里)
(若是patch的话,callHook("updated")在这里)
在$mount中,也就将组件实例注册成为Watcher实例,而后续相关的响应式数据发生了边动,则会通过Watcher的get(其实就是调用上述3,4步),从而实现vnode的刷新、以及patch修改。
注意上述的mount相关的事件钩子不会和update的钩子同时出现,因为第一次get并不是通过update回调来触发,因此只会有mount钩子被调用;而后续的patch并不会重新调用mount函数,而是通过update重复3,4步,因此只会有update钩子被调用。
25,$forceUpdate原理
$forceUpdate核心原理就是手动去触发Watcher类的update方法,而正常情况下该方法是由响应式数据的Dep去触发。在update方法中,就会将当前Watcher的更新添加至schedulerQueue中,等待执行。具体的执行函数,就是$mount的3,4步。
26,$destory原理
$destory的作用就是销毁当前组件实例,而实例相关的一些东西也需要一并被销毁。所做的事情按照顺序如下:
(callHook(vm, "beforeDestroy")在这里)
从父实例中的$children移除当前实例、移除组件实例中包括自己在内的所有Watcher类、自身的_isDestroyed属性值为true、通过patch一个为null的参数删除所有相关DOM、(callHook(vm, "destroyed")在这里)通过$off注销所有挂载的事件
27,v-text原理
v-text是通过textContent原生属性实现的。
v-text指令会在template模板解析的时候作为directives处理,在generate的过程中会将该节点上的props属性添加一个textContent。最终在patch的时候,首先判断vnode节点是否存在textContent属性,若是则还会删除该vnode的其它children,然后通过DOM节点的textContent属性设置文本内容。
28,v-html原理
v-html是通过innerHTML原生属性实现的。
实现上与v-text类似,只不过一个是textContent,一个是innerHTML。在此不作赘述。
29,v-show原理
v-show是通过display属性实现的。
v-show指令会在template模板解析的时候作为directives处理,在vue内部,会为show指令像其他自定义的指令一样,添加上bind、update、unbind钩子。而其中的处理逻辑,简单来说就是:如果v-show的值为true,则去根据DOM原本的display作展示,如果v-show为false,则直接display为none。
30,v-if、v-else、v-else-if原理
1,在语法分析中:
1.1,如果是v-if,则会为当前AST节点添加一个if属性,值为v-for的表达式;除此之外,还会 为当前节点添加一个ifConditions数组属性,并且将自身添加至该数组中;
1.2,如果是v-else-if或者v-else,则会添加else(值为Boolean)或者elseif(值为表达式)属性
1.3,如果AST节点存在elseif或者else属性,则不会将其添加至父节点的children属性中,而 是添加至ifConditions中。ifConditions指的是parent节点下相邻最近的previousSibling 如果该元素不存在if属性,则报错;否则,会将自身添加至ifConditions中。
2,在generate中,会通过if属性判断是否将该AST节点做genIf处理。将conditions从前往后取出节点,然后依次凭借成为一长串的三目表达式。最终的返回形式,就是三目表达式加上_c函数的render函数片段。
3,render函数调用时,运行至此处代码,就通过三目将不符合if判断的元素给忽略,从而只会生成符合if条件的vnode。
至于为什么不要将v-if指令用在v-for中,是因为在generate函数中,先进行genFor然后进行genIf,因此生成的表达式中,三目表达式是在for生成的表达式之内的,也就是说会先进行循环,然后再判断if else。
31,v-for原理
1,在语法分析中,会对v-for做相应的预处理。在一个v-for指令中,会进行如下转换:
v-for="(item,key,index) in arr"
{
for:'arr',
alias:'item'
iterator1:'key'
iterator2:'index'
}
最终将上述属性添加至相应的AST节点
2,在generate中,判断若节点存在for属性,则做genFor处理。而最终的返回值,就是一个待执行的通用循环(兼容number、string、Array、Object,或其他可迭代对象)的render函数片段
3,在render函数调用的时候会同时调用该函数,在循环体内部将相应的参数传递给对应的_c函数,_c函数再根据参数生成依赖于v-for的vnode节点
32,v-on原理
首先声明,此处的v-on指的是 v-on:name=""这种形式的。对于v-on="",在第22题已经做过简介。
v-on原理
1,在语法分析中,会将v-on指令所绑定的事件名和事件添加至AST节点的events属性上;
2,在generate中,会将events属性处理为render函数data中的on属性;
3,如果是组件节点,则会将on处理为eventEmitter;如果是普通节点,则会通过addEventListener添加事件监听。
修饰符原理
.native:对于native修饰符,会在词法分析中额外添加nativeEvents属性存放,并且在generate的时候会生成为nativeOn属性,最终nativeOn并不会作为eventEmitter,而是作为addEventListener的事件添加至子组件的DOM元素中;
.right:会将click.right转换为contextmenu事件处理;
.middle:将click事件转换为mouseup,然后通过该事件的event对象中的button属性判断是否为滚轮点击;
.capture:语法分析过程中将事件名加上"!"前缀,在patch给DOM添加事件的时候会解析出capture,然后addEventListener的时候就会将第三个参数设置为true;
.once:语法分析过程中将事件名加上"~"前缀,在patch给DOM添加事件的时候会解析出once,在addEventListener时候注册的是特殊代理的函数,会在第一次调用之后removeEventListener;
.passive:语法分析过程中将事件名加上"&"前缀,在patch给DOM添加事件的时候会解析出passive,并在浏览器支持passive的情况下将addEventListener的第三个参数变成options的形式,并额外加上passive: true;
其他的修饰符,如.enter,.ctrl等,是一种vue提供的语法糖,其内部也是通过操作事件的event对象来实现的。简单来说,就是vue做了一层代理,帮你做好了这些事情。
$event原理
这个代理函数,也就是为什么我们可以在事件中使用$event获取事件的event对象的根本原因所在:因为代理函数的参数形式就是$event。如下所示:
<div v-on.ctrl:enter="callBack($event)"></div>
div.addEventListener('click',function($event){
if(!$event.type.indexOf('key') && $event.keyCode!==13) return null
callBack($event)
})
32,v-bind原理
在render函数生成的过程中,分为new Function阶段和generate阶段。new Function就是通过将generate生成的render函数字符串作为参数,生成最终真实的render函数。那么明确了这一点,就知道generate生成的普通形式的字符串,是会作为js代码去执行的。比如说 "getName()" 这个字符串就会作为函数调用,再比如 "name" 这个字符串会作为属性访问。
因此,与其说vue如何处理动态属性,更正确的说法应该是如何处理非动态属性。对于v-model等指令来说,默认就是不处理,也就是都当作动态属性;对于v-bind绑定的属性,也是同样的逻辑,不需要处理即可;而对于普通属性,也就是没有使用v-bind或者 “: ” 的属性,则会将表达式做JSON.stringify处理,这样在new Function 之后才会是正常的值。
33,v-model原理
实现原理
1,在语法分析中,会将v-model指令也作为directives处理,添加至directives属性中;
2,在generate的directives的解析过程中,会对v-model做具体的处理:
2.1,如果当前节点是组件节点,则做组件v-model相关处理。参考第11题;
2.2,如果当前节点是select,则向DOM元素添加一个change事件,而在该事件回调中就会处 理将options中的选择项如何赋值给v-model的表达式。如果设置了多选择为value数组, 如果不为多选,则为选中项的value值。在select中只是添加了一个change事件,而没有 v-model表达式对应的表单属性值,这是因为在select元素中,选中属性的改变并不是通 过设置响应式属性实现的,而是在model中添加了类似directives的钩子,当发生变化时 触发事件钩子,在钩子函数中设置具体的选中或未选中项;
2.3,如果当前节点是checkBox,则会为DOM元素添加一个checked属性,以及一个change 事件,该事件的回调函数会处理选中项即未选中项,最终将v-model的表达式赋值为与 选项对应的值。这里是一对多的关系,即一个v-model对应多个checkBox,也就是多个 事件和checked属性,而每一个checkBox的操作都会触发相同的逻辑;
2.4,如果当前节点是Radio,处理逻辑与checkBox类似,只不过checkBox可以是数组,而 Radio只能是对应value的值;
2.5,如果当前节点是文本框,则向DOM元素添加一个value属性,并根据是否声明了lazy, 选择添加change事件或input事件,在事件回调中将value的值赋给v-model的表达式
概括一下:
实现数据到页面的刷新:对于checkBox和Radio,是通过添加checked属性;对于文本框是通过value;对于select是通过directives的钩子处理;
实现页面到数据的刷新:select、checkBox和Radio是通过change事件;文本框根据lazy修饰符选择change事件或者input事件。
双向数据绑定原理
如果说响应式数据完成了从数据到页面刷新的实现,那么v-model中的事件处理就是完成了从页面变化到数据改变的实现。
其实并不复杂,同样可以理解为vue的语法糖:帮你做了事件的绑定和处理,以及对应值的绑定。
所介绍的属性顺序来源于官方文档,因为我很懒,不愿意去整理知识点的key,只能直接扒下来了。如果有想要了解又不在这里面的,可以联系我添加。这篇博客会做长时间的维护更新。