最近在使用vue2来进行业务开发,在开发过程中遇见了一些需要对组件库进行二次封装的场景,举例:在Dialog对话框中进行上传的场景,而这个上传对话框又在好多场景下是可复用的,刚好将这其中的踩坑过程给记录下来,也为大家提供一些开发思路。
我的需求只有四个点,实现这四点,对任何形式的组件都能随心所欲的应对了:
- 能够直接通过直接传递默认插槽的形式打开Dialog,不用在外部频繁的控制visible
- 自定义Dialog底部操作区的代码能够在组件中处理,不在外面
- 支持所有原生组件库的属性
- 支持传递插槽的形式打开对话框
控制组件visible逻辑
如果一个vue文件中有多个类似于Dialog这样的组件,都需要写一个关闭打开的方法,那会是非常的闹心,成倍的出现毫无意义的方法。控制状态的变化,肯定是会有一个状态值和改变状态的方法的,而v-model和sync则可以实现以上问题,它们也是此类场景的语法糖。主要有几点需要注意:
- props是单项传递的,在内部绑定el-dialog的visible时候,不能直接绑定,需要通过组件内部维护一个变量去绑定,自定义footer的时候,里面的取消按钮也是同理。
- 当用户不需要v-model或者sync的时候,只给一个visible是true,也需要固定展示出Dialog,而普通的监听在props第一次传值的时候是不起作用的,需要设置immediate: true。
sync修饰符相比于v-model来说,格局就要大一点,因为v-model只能声明一次,而sync可以给任意个属性赋予双向绑定的效果,当然在vue3中,统一了这两点,都是使用v-model来代替。sync不需要声明model属性,只需要在更新XXX属性值的时候,向外抛出方法,this.$emit('update:XXX', val);,使用的时候加上visible.sync就行了;
<template>
<el-dialog title="上传文件" :visible.sync="innerVisible">
<template #footer>
<el-button @click="exit">取消</el-button>
<el-button type="primary" @click="uploadFiles">上传</el-button>
</template>
</el-dialog>
</template>
<script>
export default {
name: "CustomDialog",
model: {
event: "onChange",
prop: "visible",
},
props: {
visible: {
type: Boolean,
default: false,
},
},
data() {
return {
innerVisible: false,
};
},
watch: {
visible: {
immediate: true,
handler(val) {
this.innerVisible = val;
},
},
innerVisible: {
handler(val) {
this.$emit("onChange", val);
// sync修饰符的用法
this.$emit('update:visible', val);
},
},
},
methods: {
uploadFiles() {
// 上传逻辑
},
exit() {
this.innerVisible = false;
},
},
};
</script>
透传组件全部能力
作为一个合格的打工人,为了不坑害后来的工友,在二次封装一个组件的时候,最好想着去继承原有组件的全部能力,二次封装的东西是作为锦上添花的。透传属性、方法可以用$attrs和$listeners,想要继承原有的插槽能力可以通过遍历$scopedSlots对象做到。
<template>
<el-dialog v-bind="$attrs" v-on="$listeners">
<template v-for="(_, name) in $scopedSlots" v-slot:[name]="data">
<slot :name="name" v-bind="data" />
</template>
</el-dialog>
</template>
但传一个原有组件的能力真的有那么简单么?上述方法是目前的通解,其中有不少坑等着踩。
传递了插槽,但是没有生效
我们遍历的是$scopedSlots,在vue2.6版本之前,它是用来访问作用域插槽的,2.6之后将所有的$slots都作为函数暴露在$scopedSlots中。而我们现在用到的vue2的基础组件库(比如Element UI),内部大多数用的都是$slots,$slots和$scopedSlots是不同步的,也就是一个包含关系,这个问题可以参考Vue的issues。
例如Element中渲染底部footer的代码,通过v-if来判断插槽是否含有footer,有的话就渲染。而外层遍历时使用插槽的时候,是v-slot指令(2.6新增),$slots根本找不到传入的插槽。
<div class="el-dialog__footer" v-if="$slots.footer">
<slot name="footer"></slot>
</div>
应该替换为:
<template v-for="(_, name) in $scopedSlots" :slot="name">
<slot :name="name" />
</template>
总而言之,基础组件库用的是$slots,我们使用基础组件库的插槽的时候也应该按照旧方法定义,抛出去给我们自己使用的时候,就可以使用$scopedSlots了,vue2.6以上的版本都使用$scopedSlots来代替。
组件更新问题
在我们使用了$attrs和$listeners进行属性透传的之前,如果你更改了兄弟组件的状态,我们二次封装的组件并不会更新,因为组件状态值都没发生过变化,但是在透传之后,你会发现,即使你更新了一个毫不相关的兄弟组件的状态,我们这个二次封装的组件也会发生更新,具体可以在组件的updated钩子上观察到。
<el-dialog :visible.sync="innerVisible" v-bind="$attrs" v-on="$listeners" />
这是因为Vue会在更新组件的data和props去触发与组件对应的watcher进行渲染,源码里将$attrs和$listeners都定义成为了响应式对象,挂载在vm实例(this)上。
defineReactive(
vm,
'$attrs',
(parentData && parentData.attrs) || emptyObject,
null,
true
)
defineReactive(
vm,
'$listeners',
options._parentListeners || emptyObject,
null,
true
)
defineReactive通过Object.defineProperty来的get方法调用depend来收集依赖,会将访问$attrs所在的Watcher收集到dep对象的subs数组里面,在设置$attrs的时候,调用notify进行依赖的更新。
function defineReactive(obj, key, val, customSetter, shallow, mock) {
const dep = new Dep();
......
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter() {
const value = getter ? getter.call(obj) : val;
if (Dep.target) {
......
dep.depend({
target: obj,
type: "get" /* TrackOpTypes.GET */,
key¡
});
......
}
return isRef(value) && !shallow ? value.value : value;
},
set: function reactiveSetter(newVal) {
......
dep.notify({
type: "set" /* TriggerOpTypes.SET */,
target: obj,
key,
newValue: newVal,
oldValue: value
});
}
});
return dep;
}
而对$attrs进行v-bind,在经过vue-template-compiler编译之后就会访问到$attrs,从而导致Watcher被收集。然后组件在调用updateChildComponent的时候,会对$attrs进行set,这样产生了最终的更新。
const attrs = parentVnode.data.attrs || emptyObject
vm.$attrs = attrs
在了解到上述更新的问题后,在github的issues上也找到了该问题的解法。你需要注意的是去删除不必要的attrs属性,对于$listeners也一样。
data: () => ({
$_attrs: {},
$_listeners: {},
}),
watch: {
$attrs: {
handler (val) {
const oldKeys = Object.keys(this.$data.$_attrs)
for (const attr in val) {
this.$set(this.$data.$_attrs, attr, val[attr])
const index = oldKeys.indexOf(attr)
if (index > -1) {
oldKeys.splice(index, 1)
}
}
for (const attr of oldKeys) {
this.$delete(this.$data.$_attrs, attr);
}
},
immediate: true
},
},
支持传递插槽的形式打开对话框
插槽的形式传递就相当于一个触发器trigger,要给这个trigger绑定上click的事件,一开始我能想到的办法是通过作用域插槽从内部传递一个方法到外面来,直接供外部的触发器绑定上事件,第二种则是在看到element UI中源码popver的处理,为通过插槽传递进来的dom绑定上原生的click事件(直呼过瘾,又学到了一招)。
作用域插槽
相对于element UI的解法来说,使用作用域插槽是多了一步使用者给dom绑定操作的步骤,稍微复杂了一点。
组件内部模板添加一个具名插槽的占位元素和toggleUpload方法:
<slot name="trigger" :toggleUpload="toggleUpload"></slot>
toggleUpload() {
this.innerVisible = !this.innerVisible;
}
外部使用:
<custom-dialog v-model="visible" title="上传文件">
<template #trigger="{ toggleUpload }">
<el-button @click="toggleUpload" type="primary">上传文件</el-button>
</template>
</custom-dialog>
添加原生事件监听器
这里对Element UI的作了简化,主要展示了如何通过事件监听器去实现打开Dialog的,我们需要拿到插槽处的dom,然后在mounted生命周期中进行点击事件的添加,在destroyed生命周期中进行事件的销毁。获取插槽处的dom可以通过外面包一层ref节点,使用this.$refs访问到外层节点的dom结构,然后通过$children找到传递进来的元素。
<template>
<div>
<el-dialog :visible.sync="innerVisible">
<template #footer>
<el-button @click="exit">取消</el-button>
<el-button type="primary" @click="uploadFiles">上传</el-button>
</template>
</el-dialog>
<span class="custom-dialog__trigger-wrapper" ref="wrapper">
<slot name="trigger"></slot>
</span>
</div>
</template>
<script>
export default {
name: "CustomDialog",
data() {
return {
innerVisible: false,
reference: null,
};
},
......
methods: {
toggleUpload() {
this.innerVisible = !this.innerVisible;
},
on(element, event, handler) {
if (element && event && handler) {
element.addEventListener(event, handler, false);
}
},
off(element, event, handler) {
if (element && event && handler) {
element.removeEventListener(event, handler, false);
}
},
},
mounted() {
const reference = this.$refs.wrapper.children[0];
this.reference = reference;
this.on(this.reference, "click", this.toggleUpload);
},
destroyed() {
this.off(this.reference, "click", this.toggleUpload);
},
};
</script>
总结
在业务开发中,才能把自己之前学习到的一些知识给运用上去,凡是问题都会有解。一个好的业务组件不仅仅能够提高业务开发的效率,也能让自己的想法得到实施并且充分印证。遇到问题,多去搜索下资源,多去看看现有别人做得好的思路,能灵活运用上去就是属于自己的东西。同样,一个问题会有N种解,你认为最好的方式,可能往往还不够好。