效果:
引用:
<!-- 常规 -->
<drop-menu v-model="date">
<drop-item v-for="value,key in dateObj" :key="key" :value="key" :label="value"></drop-item>
</drop-menu>
<!-- 前置内容 -->
<drop-menu v-model="date">
<drop-item v-for="value,key in dateObj" :key="key" :value="key" :label="value">
<template #prepend>
<svg width="6" height="6">
<circle cx="3" cy="3" r="3" :class="'dot'+key"></circle>
</svg>
</template>
</drop-item>
</drop-menu>
准备工作,分析组件
仔细观察dropDown这个组件,发现组件是由上下两部分组成的:
上面那部分是一个div(dropLabel),包含了文字以及图标
下面那部分是一个绝对定位的div(dropMenu),包含下拉选项每一个dropItem,并可以控制展示和隐藏.
进一步思考:展示和隐藏的时机是什么时候呢?
仔细分析后可以知道:
- 当点击dropLabel的时候可以是切换显示和隐藏
- 原来dropMenu如果是隐藏的,点击dropLabel时就显示
- 原来dropMenu如果是显示的,点击dropLabel时就隐藏
- 当点击dropMenu中的某一项完成后要隐藏掉dropMenu
- 当点击整个dropDown以外的部分时要要隐藏dropMenu
这样分析下来dropDown组件内部的结构就可以是
<template>
<div class="dropDown">
<span class="droplabel" @click="show=!show">
{{ label }}
<i class="iconfont icon-arrowdown"></i>
</span>
<div class="dropMenu" v-show="show">
<div class="dropMenu_box">
<slot></slot>
</div>
</div>
</div>
</template>
<script setup>
import {ref} from "vue"
const show=ref(false)
const label=ref('')//label
</script>
结构分析完之后我们再来理一理逻辑.
现在有的:
dropDown组件上面用了v-model,所以dropDown组件上有modelValue和update:modelValue
dropItem组件接收两个值,分别是value和label.
动作: 当我们点击一个dropItem时
- 需要拿到这个dropItem组件上的
value,然后传递给dropDrow组件用update:modelValue更新 - 需要拿到这个dropItem组件上的
label,然后更新dropDrow组件上的label
dropDown
经过上面的一通分析,可以得出结论:
dropDown组件上应该定义两个方法,分别是updateValue和updateLabel.
updateValue用来更新v-model绑定的值,updateLabel用来更新自身的label.
<script setup>
import {ref} from "vue"
const show=ref(false)
const label=ref('')//label
// 接收props和emit
const props=defineProps({
modelValue:{
required:true
}
})
const emit=defineEmits(['update:modelValue'])
// 更新v-model绑定的值
function updateValue(val){
emit('update:modelValue',val)
show.value=false
}
// 更新自身的label
function updateLabel(val){
label.value=val
}
</script>
并且这两个方法应该传递到dropItem组件,用于点击dropItem时触发.那怎么传递这两个方法呢?
方法有很多,我这里用的是mitt
安装miit
npm i miit -S
新建一个bus.js文件,内容:
import mitt from "mitt"
const bus = {}
const emitter = mitt()
bus.$on = emitter.on
bus.$off = emitter.off
bus.$emit = emitter.emit
export default bus
然后引入在dropDown.vue中引入bus.js,dropDown.vue的代码就是:
<script setup>
import {ref,onBeforeUnmount} from "vue"
import bus from "@/utils/bus"//新增
const show=ref(false)
const label=ref('')//label
// 接收props和emit
const props=defineProps({
modelValue:{
required:true
}
})
const emit=defineEmits(['update:modelValue'])
// 更新v-model绑定的值
function updateValue(val){
emit('update:modelValue',val)
show.value=false
}
// 更新自身的label
function updateLabel(val){
label.value=val
}
bus.$on('updateValue',updateValue)//新增
bus.$on('updateLabel',updateLabel)//新增
onBeforeUnmount(()=>{//新增
bus.$off('updateValue')
bus.$off('updateLabel')
})
</script>
这样dropDown组件的逻辑基本就好了,下面就是dropItem组件了
dropItem
首先分析一下dropItem的结构应该是怎样的。 通过了解需求,dropItem主要是展示传递的label,传递的方式主要有两种:
<drop-item :value="key" :label="value"></drop-item>这样传递<drop-item :value="key">全部</drop-item>或者这样传递
针对上面两种情况只需要做一个简单的判断就可以了
<template>
<div class="dropItem">
<div class="label">
<template v-if="label">{{ label }}</template>
<slot v-else></slot>
</div>
</div>
</template>
<script setup>
import {toRefs} from "vue"
const props=defineProps({
label:{
required:true
},
})
const {label}=toRefs(props)
</script>
此外,dropDrop组件应该还有前置插槽和后置插槽,用来自定义内容,所以dropDrop组件完整结构应该是这样:
<template>
<div class="dropItem">
<slot name="prepend"></slot>
<div class="label">
<template v-if="label">{{ label }}</template>
<slot v-else></slot>
</div>
<slot name="append"></slot>
</div>
</template>
<script setup>
import {toRefs} from "vue"
const props=defineProps({
label:{
required:true
},
})
const {label}=toRefs(props)
</script>
这样dropItem的结构就理清了,还挺简单的,下面就是逻辑了,主要是几个点:
- 接收
value和label - 点击dropItem要调用
updateValue方法和updateLabel方法,并将value和label传递过去
所以dropItem代码应该是这样的:
<template>
<div @click="selectItem" class="dropItem">
<slot name="prepend"></slot>
<div class="label">
<template v-if="label">{{ label }}</template>
<slot v-else></slot>
</div>
<slot name="append"></slot>
</div>
</template>
<script setup>
import bus from "@/utils/bus"
import {toRefs} from "vue"
const props=defineProps({
value:{
required:true
},
label:{
required:true
},
})
const {value,label}=toRefs(props)
function selectItem(){
bus.$emit('updateValue',value.value)
bus.$emit('updateLabel',label.value)
}
存在初始值
到这大致的组件功能就已经完成了,但是作为优秀的前端需求师,我们应该能想到额外的东西:
如果dropDown组件上的v-model有初始值,是不是应该根据初始值渲染对应的label。
解决这个问题的思路很简单,只需要在dropDown组件中将v-model初始的值拿到,然后传递给dropItem, 在dropItem中做一个匹配,拿到对应的label,再传递给dropDown,并更新dropDrow组件上的label
传递和接收的方式可以用provide和inject
这样,在dropDrow增加一段内容:
<script setup>
import {ref,onBeforeUnmount,toRef,provide} from "vue"//新增
import bus from "@/utils/bus"
const show=ref(false)
const label=ref('')
// 接收props和emit
const props=defineProps({
modelValue:{
required:true
}
})
const select=toRef(props,'modelValue')//新增
provide('select',select)//新增
const emit=defineEmits(['update:modelValue'])
// 更新v-model绑定的值
function updateValue(val){
emit('update:modelValue',val)
show.value=false
}
// 更新自身的label
function updateLabel(val){
label.value=val
}
bus.$on('updateValue',updateValue)
bus.$on('updateLabel',updateLabel)
onBeforeUnmount(()=>{
bus.$off('updateValue')
bus.$off('updateLabel')
})
</script>
再在dropItem中接收
<script setup>
import bus from "@/utils/bus"
import {toRefs,inject} from "vue"//新增
const props=defineProps({
value:{
required:true
},
label:{
required:true
},
})
const {value,label}=toRefs(props)
function selectItem(){
bus.$emit('updateValue',value.value)
bus.$emit('updateLabel',label.value)
}
const select=inject('select')//新增
if(value.value==select.value){//新增
bus.$emit('updateLabel',label.value)
}
最后
点击dropdown外部区域隐藏下拉菜单
相信大家一开始想到的就是contains,但是要怎么用呢,这样吗
function handlerClick(e){
const el=document.querySelector('.dropDown')
if(!el.contains(e.target)){
show.value=false
}
}
onMounted(()=>{
window.addEventListener('click',handlerClick)
})
beforeUnmount(el){
window.removeEventListener('click',weakMap.get(el))
}
这样用的话,如果页面只使用了一个dropDown组件还好,如果有多个dropDown组件,只会隐藏第一个dropDown组件的下拉菜单,因为document.querySelector('.dropDown')获取的永远是第一个dropDown组件
为此,我们需要封装一个v-clickoutside指令,在dropDown组件挂载时添加监听,移除前移除监听:
const weakMap=new WeakMap()
const vClickoutside={
handlerClick:null,
mounted(el,binding){
const handler=binding.value
const handlerClick=(e)=>{
if(!el.contains(e.target)){
handler()
}
}
weakMap.set(el,handlerClick)
window.addEventListener('click',handlerClick)
},
beforeUnmount(el){
window.removeEventListener('click',weakMap.get(el))
}
}
document.querySelector('')
export default vClickoutside
最后引入到dropDown组件里面使用:
<div class="dropDown" v-clickoutside="clickOutside"></div>
bus调用的问题
看这段代码:
bus.$on('updateValue',updateValue)
bus.$on('updateLabel',updateLabel)
同样的如果是有多个dropDown组件,这样向bus中添加势必会造成问题,因为每一个dropDown组件监听的都是同一个键。我们应该用symbol让键名具有唯一性,然后通过provide传递到dropItem中,通过唯一的键调用updateValue和updateLabel方法
dropDown组件:
<script setup>
import {ref,onBeforeUnmount,toRef,provide} from "vue"
import bus from "@/utils/bus"
const show=ref(false)
const label=ref('')
// 接收props和emit
const props=defineProps({
modelValue:{
required:true
}
})
const select=toRef(props,'modelValue')
provide('select',select)
const emit=defineEmits(['update:modelValue'])
// 更新v-model绑定的值
function updateValue(val){
emit('update:modelValue',val)
show.value=false
}
// 更新自身的label
function updateLabel(val){
label.value=val
}
//注入给子组件
const value_symbol=Symbol('updateValue')//新增
const label_symbol=Symbol('updateLabel')//新增
provide('value_symbol',value_symbol)//新增
provide('label_symbol',label_symbol)//新增
bus.$on(value_symbol,updateValue)//修改
bus.$on(label_symbol,updateLabel)//修改
onBeforeUnmount(()=>{
bus.$off('updateValue')
bus.$off('updateLabel')
})
</script>
dropItem组件:
<script setup>
import bus from "@/utils/bus"
import {toRefs,inject} from "vue"//新增
const props=defineProps({
value:{
required:true
},
label:{
required:true
},
})
const {value,label}=toRefs(props)
const value_symbol=inject('value_symbol')//新增
const label_symbol=inject('label_symbol')//新增
function selectItem(){
bus.$emit(value_symbol,value.value)//修改
bus.$emit(label_symbol,label.value)//修改
}
const select=inject('select')
if(value.value==select.value){
bus.$emit(label_symbol,label.value)//修改
}