用vue3实现dropDown组件

1,342 阅读3分钟

效果:

dragdiv.gif

引用:

<!-- 常规 -->
<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这个组件,发现组件是由上下两部分组成的:

微信截图_20230429204645.png

上面那部分是一个div(dropLabel),包含了文字以及图标

下面那部分是一个绝对定位的div(dropMenu),包含下拉选项每一个dropItem,并可以控制展示和隐藏.

进一步思考:展示和隐藏的时机是什么时候呢?

仔细分析后可以知道:

  1. 当点击dropLabel的时候可以是切换显示和隐藏
    • 原来dropMenu如果是隐藏的,点击dropLabel时就显示
    • 原来dropMenu如果是显示的,点击dropLabel时就隐藏
  2. 当点击dropMenu中的某一项完成后要隐藏掉dropMenu
  3. 当点击整个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组件上有modelValueupdate:modelValue dropItem组件接收两个值,分别是valuelabel.

动作: 当我们点击一个dropItem时

  1. 需要拿到这个dropItem组件上的value,然后传递给dropDrow组件用update:modelValue更新
  2. 需要拿到这个dropItem组件上的label,然后更新dropDrow组件上的label

dropDown

经过上面的一通分析,可以得出结论: dropDown组件上应该定义两个方法,分别是updateValueupdateLabel. 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,传递的方式主要有两种:

  1. <drop-item :value="key" :label="value"></drop-item>这样传递
  2. <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的结构就理清了,还挺简单的,下面就是逻辑了,主要是几个点:

  1. 接收valuelabel
  2. 点击dropItem要调用updateValue方法和updateLabel方法,并将valuelabel传递过去

所以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

传递和接收的方式可以用provideinject

这样,在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中,通过唯一的键调用updateValueupdateLabel方法

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)//修改
}