问题场景
当使用组件的时候,我们希望我们开发的组件parent,能获取插槽内组件child的数据,比如label... 可能有开发者认为直接把child的数据在传入的时候给一份一样的给parent的props就好了,但是这样开发的时候心智负担就比较大了。因为你需要手动维护额外的变量,以便你的父组件能够正确渲染。我们看一个例子以便明确这个问题。
<template>
// 如果能让parent组件获取插槽内的组件的label,那么可以这么写
<parent>
<child label="one"/>
<child label="two"/>
</parent>
// 否则你只能通过额外的变量,比如计算属性childLabels来控制父组件的渲染。
<parent :labels="childLabels">
<child label="one"/>
<child label="two"/>
</parent>
</template>
思路:
在实现parent组件的时候,通过方法假如叫(calcChilds)来访问插槽里的组件实例,以下是需要注意的几个问题。
- 访问时机:第一次渲染的时候,应该在mounted的阶段去访问,才能获取到插槽内的组件的实例。
- 判断条件:vnode.tag && vnode.componentOptions && vnode.componentOptions.Ctor.options.name
- vnode.tag获取标签名字的这种方式判断不稳定,主要是用来判断空字符,空字符会插在两个独立的标签之间,比如和h1和h2之间就会有一个空字符,当然应该是要有回车才有的。
- componentOptions是用于判断是否是一个组件,如果插槽内不是组件,componentOptions是获取不到的。
- vnode.componentOptions.Ctor.options.name我想应该不用解释,只是你在编写组件的时候就定义好的
这样就能获取你想要的组件对应的vnode了,紧接着从这个vnode里获取实例componentInstance。就可以获取组件实例,从而获取数据。
同步更新:
尽管这样能解决获取插槽组件里的数据问题,但是child组件数据更新时,parent组件内部却无法获取到最新数据。
具体问题:如果你有一个calcChilds方法在parent组件中,用于获取child组件的数据。但是现在页面上的child组件发生变化,parent组件中的这个方法需要再一次调用。
解决方法:
- 可以通过在child组件的updated生命周期中抛出一个事件,由于是插槽内的组件跟parent组件的通信,所以无法通过平常的emit和v-on来做。而是需要通过$on以及$parent.$emit来进行处理。也就是在child组件中的updated方法中,通过this.$parent.$emit('child-update')触发事件,$parent获取到的就是parent实例,然后在父组件的created生命周期方法中,监听这个事件,this.$on('child-update',this.caclChilds.bind(null)。
- 当然child组件在updated周期才给出事件还是不够的,因为如果在第一次加载过程中,child组件渲染完毕,parent组件紧接着渲染完成,但是都没有发生变更的话,那此时也获取不到child组件中的数据,所以需要在parent组件的mounted中去调用caclChilds方法
- 这样看似完善了许多,但是还是不够。因为如果你是在使用的地方,通过v-if切换child的渲染和销毁,child是无法发出updated事件的,同时parent组件也无法将child已经销毁的状态同步到parent内部。所以此时我们仍需要在parent组件的updated生命周期方法中去调用caclChilds方法
例子
千言万语,不如一个例子
MyParent组件如下:
<template>
<div>
<p>
child Count:{{childs.length}}
</p>
<p>
labels:{{this.labels.join()}}
</p>
<slot></slot>
</div>
</template>
<script>
export default {
data() {
return {
childs: [],
labels: [],
};
},
methods: {
setLabels(label) {
this.labels.push(label);
},
// 更新childs,并且从child实例中获取label
calcChilds(isForceUpdate = false) {
const slots = this.$slots.default;
if (slots) {
const childsSlots = slots.filter(
(vnode) =>
vnode.tag &&
vnode.componentOptions &&
vnode.componentOptions.Ctor.options.name === "MyChild"
);
const childs = childsSlots.map(
({ componentInstance }) => componentInstance
);
const Changed = !(
childs.length === this.childs.length &&
childs.every((child, index) => child === this.childs[index])
);
// 视图上的更新会触发这个方法,试想一下,调用这个方法之后会往labels中插入child中的label属性,从而更新视图。
接着这个方法再一次调用,又插入child中的label属性,接着又触发更新。如果没有以下这个判断,那么会导致无限循环。
if (isForceUpdate || Changed) {
this.childs = childs;
this.setLabels(childs.map((item) => item.label));
}
} else if (this.childs.length === 0) {
this.childs = [];
}
},
},
created() {
this.$on("child-update", this.calcChilds.bind(null, true));
},
mounted() {
this.calcChilds();
},
updated() {
this.calcChilds();
},
};
</script>
Mychild组件如下:
<template>
<div>
{{label}}
</div>
</template>
<script>
export default {
name: "MyChild",
componentName: "MyChild",
props: {
label: {
type: String,
default: "",
},
},
updated() {
this.$parent.$emit("child-update");
},
};
</script>
<style lang="scss" scoped>
</style>
最后是使用的App组件
<template>
<div id="app">
<img alt="Vue logo"
src="./assets/logo.png">
<MyParent>
<MyChild label="firstLabel"></MyChild>
<MyChild label="secondLabel"></MyChild>
</MyParent>
</div>
</template>
<script>
import MyParent from "./components/MyParent.vue";
import MyChild from "./components/MyChild.vue";
export default {
name: "App",
components: {
MyParent,
MyChild,
},
};
</script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 60px;
}
</style>