vue源码阅读—12—扩展之slot
1.前言:插槽的一些功能:
name
的 <slot>
出口会带有隐含的名字“default”,所以还是会使用组件的默认插槽;
2.作用域插槽:
让父组件分发的内容,可以得到子组件的数据;
3.问题:父组件通过插槽分发的内容,为什么会变成了子组件的子元素?
比如往AppLayout插入的child组件,按理来说,应该是App根组件的子组件,但实际上变成了Applayout组件的子组件,child.$parent 指向了Applyaout实例;
那么,为什么?
一句话:父组件给子组件分发的内容,实际上会被子组件的插槽,也就是子组件的子组件(<slot></slot>组件)也就是孙子组件替换,所以,父组件分发的内容实际上是孙子元素或孙子组件;
详细解释:
(1)子组件实例化阶段调用了this.initRender方法,所以肯定调用了vm.$slots = resolveSlots(options._renderChildren, renderContext);
因为vm.$options._renderChildren = vm.$vnode.componentOptions.children = 插槽vnode;(通过父组件的render函数,我们可以清楚的看到,插槽元素成为了占位符vnode的h函数的第三个参数即children的位置上)
所以,this.$slot = { 具名key:[具名插槽元素的vnode], default:[没有写名字的插槽元素的vnode都在这里] }
(2)子组件挂载阶段,调用_t方法时,
子组件实例肯定是已经创建出来了,肯定也调用了this.initRender方法,所以肯定调用了vm.$slots = resolveSlots(options._renderChildren, renderContext); 所以this.$slot肯定已经解析出来值了。
所以这个时候我们通过this.$slot[具名插槽名字或者default]是可以取到每个插槽所对应的vnode。然后返回。
所以_t方法就是返回vnode,就是返回父组件在这个子组件里定义的插槽元素vnode。
那么,显而易见,在子组间render函数里,_t方法占据的是h函数的第三个参数,也就是子元素的位置,所以,插槽元素在这里变成了子组件的子元素,也就是孙子组件。
(其实是子组件的子元素header mian footer元素的子组件,即父组件的重孙组件,但是由于重孙父组件在实例话化时activeInstance就是子组件实例,所以在initLiftCycle时重孙组件.$parent就是子组件,所以我们叫重孙组件为孙子组件;其实也好理解,只有组件元素才能建立父子关系,其他的元素比如原生html或者header、main、footer等无法建立;)
那么既然插槽元素变成了本组件的子元素子vnode,那么普通的插槽元素的渲染过程应该发生本组件createChildren方法中,那么组件类型的插槽元素的实例化和mount过程应该发生在本组件decreateChildren过程中,那么,组件插槽元素的实例.$parent就是本组件实例。
所以,我们看el-table-column组件实例的$parent指向的是el-table实例。
总结:插槽元素会在父组件render过程渲染成vnode,但是在子组件render过程中,会通过_t也就是renderSlot方法取到插槽vnode然后作为h函数的第三个参数即子组件的children(父组件的孙子组件),所以根据组件渲染流程,我们知道,插槽元素即孙子组件实例.$parent =子组件实例。
4.问题:比如coopwire系统的v-auth指令,删除this.$parent.$slot.default里的元素,为什么会插槽元素不见?插槽元素不是已经渲染好了吗?毕竟v-auth的insertd钩子函数没有this.$forceUpdate,那就没有重新渲染,
那插槽元素已经渲染好了,怎么会莫名消失?
当我们给el-tab-pane加上key的时候,等到根组件调用invokeInsertHooks时,确定会调用我们自己写得inserted钩子函数,确实可以通过this.$parent.$slot.default.splice()的方式删除子组件插槽的某个vnode,
但是就像我们认为的,孙子组件已经创建好了元素,也已经挂载到子组件上了,那么这个时候删除插槽的某个vnode应该无效,那么为什么还会有效呢?
原因很简单,因为我们的vue-router会导致我们的组件元素重新渲染。
然后会重新调用vm._update(vm._render()),然后因为我们子组件实例$slot.default某个插槽元素自己被删除了,所以在子组件重新render过程中,在调用_t即resolveSlot过程中,就没有这个元素了,所以子组件渲染vnode的children就没有这个元素了,所以这个元素就被隐藏了。
5.问题:实例的this.$children和this.$slot有什么区别?
vm.$chilren和vm.$slot的区别:第一vm.$chilren装的是子组件实例 vm.$slot装的是插槽的vnode;第二:vm.$chilren数组的个数肯定比vm.$slot多,为什么?因为就以el-tab举例,el-tab元素本生除了几个插槽<slot></slot>占位组件,肯定内部还有其他组件比如el-tab-Nav子组件;插槽元素在el-tab渲染过程中,也会通过_t方法resolveSlot方法替换<slot></slot>占位组件vm.$chilren多;
一、普通插槽的实现流程
let AppLayout = { template: '<div class="container">' + '<header><slot name="header"></slot></header>' + '<main><slot>默认内容</slot></main>' + '<footer><slot name="footer"></slot></footer>' + '</div>' } let vm = new Vue({ el: '#app', template: '<div>' + '<app-layout>' + '<h1 slot="header">{{title}}</h1>' + '<p>{{msg}}</p>' + '<p slot="footer">{{desc}}</p>' + '</app-layout>' + '</div>', data() { return { title: '我是标题', msg: '我是内容', desc: '其它信息' } }, components: { AppLayout } })
1.1编译阶段
经过compiler模块编译后
with(this){ return _c('div', [_c('app-layout', [_c('h1',{attrs:{"slot":"header"},slot:"header"}, [_v(_s(title))]), _c('p',[_v(_s(msg))]), _c('p',{attrs:{"slot":"footer"},slot:"footer"}, [_v(_s(desc))] ) ]) ], 1)} with(this) { return _c('div',{ staticClass:"container" },[ _c('header',[_t("header")],2), _c('main',[_t("default",[_v("默认内容")])],2), _c('footer',[_t("footer")],2) ] ) }
1.2运行时阶段
_c我们知道就是$createElement()方法,会在initRender中定义vm.$createElement()。
那么 _t是啥?
我们在导入vue类的时候,会调用renderMixin方法;
然后又会调用installRenderHelpers方法,并发Vue.prototype传递过去;
我们看到,在Vue的原型上定义了_t方法,所以vm实例也可以通过vm._t()的方式使用了。
所以_t就是renderSlot方法;
然后根据this.$slot取出slotNodes并返回;
然后nodes会在_c函数中继续被调用;
1.3this表示子组件applyout实例;那么this.$slot是什么?从哪里来的?
在initRender过程中获得;
那options_renderCHildren是什么?
我们在render阶段,会先创建<AppLyout></AppLyout>占位符的组件vnode;
<AppLyout></AppLyout>组件的子节点是这些: 这里不对!!!应该是插槽元素!!!
'<h1 slot="header">{{title}}</h1>' + '<p>{{msg}}</p>' + '<p slot="footer">{{desc}}</p>' +
然后都会被当做children,传入new Vnode的第七个参数componentOptions里;
然后,在我们patch父组件时会实例化AppLyout子组件,
实例化子组件会调用这个方法
然后定义了一个变量options,并把占位符组件vnode赋值给_parentVnode;
然后开始初始化子组件实例;
然后调用this_init()方法;
在initInternalComponent方法里,
1.把刚刚赋值的parentVnode = options._parentVnode
2.再把parentVnode.componentOptions赋值给vnodeComponentOptions
3.最后把vnodeComponentOptions.children赋值给vm.$options._renderChildren;
这个children就是占位符AppLyout组件vnode的children;
'<h1 slot="header">{{title}}</h1>' + '<p>{{msg}}</p>' + '<p slot="footer">{{desc}}</p>' +
然后开始子组件实例化的this_init()还没结束,继续往下执行,就会执行到 initRender();
然后会执行resolveSlots,函数最终返回一个slot对象,对象的属性都是具名插槽的名字,默认插槽会放到对象的default属性上;
二、作用域插槽的实现流程
let Child = { template: '<div class="child">' + '<slot text="Hello " :msg="msg"></slot>' + '</div>', data() { return { msg: 'Vue' } } } let vm = new Vue({ el: '#app', template: '<div>' + '<child>' + '<template slot-scope="props">' + '<p>Hello from parent</p>' + '<p>{{ props.text + props.msg}}</p>' + '</template>' + '</child>' + '</div>', components: { Child } })
2.1编译阶段
父组件如下:
可以看到它和普通插槽父组件编译结果的一个很明显的区别就是子组件没有 children
了,data
部分多了一个对象,并且执行了 _u
方法,在编译章节我们了解到,_u
函数对的就是 resolveScopedSlots
方法,它的定义在 src/core/instance/render-heplpers/resolve-slots.js
。
with(this){
return _c('div',
{staticClass:"child"},
[_t("default",null,
{text:"Hello ",msg:msg}
)],
2)}
with(this){ return _c('div', [_c('child', {scopedSlots:_u([ { key: "default", fn: function(props) { return [ _c('p',[_v("Hello from parent")]), _c('p',[_v(_s(props.text + props.msg))]) ] } }]) } )], 1) }
2.2运行时阶段
首先我们看到,作用域插槽的child组件和普通插槽的chlid组件的 render函数不一样了。
普通插槽的chlid组件的render函数室友children的,
而作用域插槽灭有,直接在render函数里是一个scopedSlots属性。
父组件在渲染阶段,先渲染占位符child子组件vnode,执行_u方法即resolveScopedSlots方法,把插槽内容赋值给一个对象,对象key是插槽名,对象value是一个包裹了插槽内容的函数;
所以最终返回一个对象res{ defalut:function(props){....}); 所以child占位符vnode的data里有属性scopedSlots了;
在执行子组件的渲染阶段时,注意,已经到了执行子组件的渲染了,会判断如果有子组件有_parentVnode,也就是占位符组件vnode ---- "vue-component-1-child",就会把占位符组件vnode的data的scoptedSlots赋值给this.$scopedSlots。
子组件还是要执行_t方法即renderSlot方法。
然后通过this.$scoptedSlots对象取到作用域插槽的数据。
然后返回一个nodes,给_c方法编译;
(为什么作用于插槽可访问子组件的数据?
因为slot的内容被当保存在scopedSlotFn函数里,然后延迟到子组件作用域里执行,它本来是在父组件渲染过程中被渲染,但是它在子组件实例化后,进行子组件的渲染阶段时才去调用的,它不是在父组件实例阶段,所以它可以访问子组件实例的数据;)
三、插槽更新
我们再父组件里通过一个点击事件把数据改变后, 插槽数据也变化了,可为什么子组件也会重新渲染?
<script> let AppLayout = { template: '<div class="container">' + '<header><slot name="header"></slot></header>' + "<main><slot>默认内容</slot></main>" + '<footer><slot name="footer"></slot></footer>' + "</div>", }; let vm = new Vue({ el: "#app", template: "<div>" + "<app-layout>" + '<h1 slot="header">{{title}}</h1>' + "<p>{{msg}}</p>" + '<p slot="footer">{{desc}}</p>' + "</app-layout>" + "<button @click='change'>按钮</button>"+ "</div>", data() { return { title: "我是标题", msg: "我是内容", desc: "其它信息", }; }, components: { AppLayout, }, methods:{ change(){ this.title = 'change'; } } }); </script>
当我们点击change按钮时,触发click监听事件,
由于父组件模板对title变量有依赖,所以渲染watcher会被title依赖收集,所以title变化后,会通知渲染watcher重新渲染,
所以会父组件会调用patch方法;
因为新旧节点都有子节点layout,所以走updateChildren;
又因为layout节点的第一个节点'<h1 slot="header"></h1>' 都存在且是新旧相同的,故命中这一块;
继续执行patchVnode,然后执行prepatch方法,
然后执行updateChildComponent方法,其中 oldVnode.componentInstance是子组件实例;
然后判断hasChildren是否为true;
因为子组件的实例上即child.$options._renderChldren是为true的 (vm.$options._renderChildren = vnodeComponentOptions.children)
因为hasChildren为true,所以会调用子组件的重新渲染;
四、总结
简单地说,两种插槽的目的都是让子组件 slot
占位符生成的内容由父组件来决定,但数据的作用域会根据它们 vnodes
渲染时机不同而不同。
一个组件分为几个阶段?