vue3 实现 select 下拉选项

呃哼~ 第一次发帖. 写不好请见谅

本人学生 , 平时在外面没事接点小项目小赚一笔补贴生活费. 之前一直都是使用Vue2.x的版本做项目, 暑假刚刚学习了Vue3想着新项目就直接用Vue3上手.

效果展示

好了, 话不多说先给大佬们看看效果样式:


bs470-ngit5.gif

组件难点

因为下拉框可能会在某些情况下被挡住, 所以这里的下拉框被挂载到了body标签上, 并且下拉框中的选项往往是以<slot>插槽的形式编写, 这里就会困扰到很多小白, 搞不明白怎么样才能在 下拉框触发下拉按钮 之间关联响应式事件与数据.

组件的使用

<tk-select selected="请选择">
    <template #selectDropDown>
        <tk-select-item value="最新案例">最新案例</tk-select-item>
        <tk-select-item value="最热案例">最热案例</tk-select-item>
    </template>
</tk-select>

<hr>

<tk-select>
    <template #selectDropDown>
        <tk-select-item value="扬州市">扬州市</tk-select-item>
        <tk-select-item value="南京市">南京市</tk-select-item>
        <tk-select-item value="无锡市">无锡市</tk-select-item>
        <tk-select-item value="徐州市">徐州市</tk-select-item>
        <tk-select-item value="苏州市">苏州市</tk-select-item>
        <tk-select-item value="镇江市">镇江市</tk-select-item>
    </template>
</tk-select>

参数说明

tk-selectselect下选项父标签, 必须含有插槽 #selectDropDown 才能正常使用

Attribute Description Accepted Values Default
selected 默认选中的值,如果不填或为空则默认选中插槽中的第一个 tk-select-item 中的值 - -

tk-select-item 为**select
**下选项子标签(选项标签), tk-select-item 内可以继续写入其他 HTML 内容, 每项的具体值由props value 决定

Attribute Description Accepted Values Default
value 词选项默认返回的数据 (必须设置) - -

v-modal

可以使用 v-modal 实时获取到 下拉选项 选取到的值

注意:这里的 v-modal并没有做成双向绑定, 这里只用于获取到 select 中选中的值, 只能用于获取, 主动修改其值并无效果, 并且不支持 v-model 修饰符

<tk-select v-model="selectValue">
    ...
</tk-select>

<script>
import { ref } from 'vue';
export default {
    setup(){
        // 接收select选中的值
        const selectValue = ref();
        
        return {
            selectValue
        }
    }
}
</script>

实现思路

首先看看目录结构

src
 |
 |
 |-- components
 |      |
 |      |-- select
 |            |
 |            |-- select.vue
 |            |-- select-item.vue
 |            |-- selectBus.js
 |
 |
 |-- utils
 |    |-- token.js

两个 .vue 文件用来的干嘛的没什么好说的, selectBus.js 解决 Vue3 中无法安装 eventBus 的问题, token.js 用于给每组 select 与 select-item 相互绑定.

首先我们看看 selectBus.js 里面的内容

我们先看看 vue3 官网怎么说的 进入官网. 说人话的意思就是不可以像 vue2 那样愉快的安装Bus, 需要自己实现事件接口或者使用第三方插件. 这里官网也给出了具体实现方案.

// selectBus.js
import emitter from 'tiny-emitter/instance'

export default {
    $on: (...args) => emitter.on(...args),
    $once: (...args) => emitter.once(...args),
    $off: (...args) => emitter.off(...args),
    $emit: (...args) => emitter.emit(...args)
}

select.vue 文件是我们的父组件

vue3 新增 <teleport> 标签, 可以将标签内的元素挂载到任意位置, 查看官方文档

// teleport 用法
// 将<h1>挂载到body上

<teleport to="body">
    <h1>标题</h1>
</teleport>

select 主要有触发下拉按钮tk-select-button和下拉列表tk-select-dropdown组成, 下拉框中的选项未来将由插槽插入.

<!-- select.vue -->
<template>
  <!-- 下拉框 -->
  <div class="tk-select"> 
      <div ref="select_button" class="tk-select-button" @click="selectOpen = !selectOpen">
          <!-- 选中内容 -->
          <span>{{selctValue}}</span>
          <!-- 右侧小箭头 -->
          <div class="select-icon" :class="{'selectOpen':selectOpen}">
              <i class="fi fi-rr-angle-small-down"></i>
          </div>
      </div>
      <!-- 下拉框 -->
      <teleport to="body">
          <transition name="select">
            <div ref="select_dropdown" v-show="selectOpen" :style="dropdownStyle" class="tk-select-dropdown">
                <ul>
                    <slot name="selectDropDown"></slot>
                </ul>
            </div>
          </transition>
      </teleport>
  </div>
</template>

首先解决下拉列表打开&关闭和定位的问题

import { ref, onDeactivated } from 'vue';
export default {
    // 获取按钮
    const select_button = ref(null);
    // 获取下拉框
    const select_dropdown = ref(null);
    
    // 下拉框位置参数
    const dropdownPosition = ref({x:0,y:0,w:0})

    // 下拉框位置
    const dropdownStyle = computed(()=>{
        return {
            left: `${dropdownPosition.value.x}px`,
            top:  `${dropdownPosition.value.y}px`,
            width: `${dropdownPosition.value.w}px`
        }
    })
    
    // 计算下拉框位置
    function calculateLocation(){
        var select_button_dom = select_button.value.getBoundingClientRect()
        dropdownPosition.value.w = select_button_dom.width
        dropdownPosition.value.x = select_button_dom.left
        dropdownPosition.value.y = select_button_dom.top + select_button_dom.height + 5
    }
    
    // 每次下拉框打开时重新计算位置
    watch(selectOpen,(val)=>{
        if(val)
            // 计算位置
            calculateLocation();
    })
    
    // ---------------------------------增加一点修饰---------------------------------------
    // 点击非按钮或下拉框区域也会收起下拉框
    window.addEventListener('click',(event)=>{
        if(!select_button.value.contains(event.target) && !select_dropdown.value.contains(event.target) ){
            selectOpen.value = false
        }
    })
    
    // 当页面滚动或改变大小时重新计算位置
    window.addEventListener('resize',()=>{
        // 计算面板位置
        calculateLocation();
    })
    window.addEventListener('scroll',()=>{
        // 计算面板位置
        calculateLocation();
    })
    
    // 当组件卸载时释放这些监听
    onDeactivated(() => {
        window.removeEventListener('resize')
        window.removeEventListener('scroll')
        window.removeEventListener('click')
    })
    
    return {
        select_button,
        select_dropdown,
        dropdownPosition,
        dropdownStyle,
        calculateLocation
    }
}

让我们继续看看select-item.vue , 这是我们的子组件

<!-- select-item.vue -->
<template>
  <li class="tk-select-item" :class="{'active':active}" @click="chooseSelectItem">
      <slot></slot>
  </li>
</template>

<script>
// 引入Bus
import Bus from './selectBus'
export default {
    setup(props){
        // 当选项被点击时
        function chooseSelectItem(){
            // 将被点击项目的value返回给select
            Bus.$emit('chooseSelectItem',props.value);
        }
    }
}
</script>

select.vue 中接收事件

setup(){
    // 选中内容
    const selctValue = ref('');
    ...
    onMounted(()=>{
        Bus.$on('chooseSelectItem',(res)=>{
            // 修改显示值
            selctValue.value = res.value
            // 关闭下拉框
            selectOpen.value = false
        })
    })
    ...
}

到这里下拉选项框基本就完成了. 我们像页面添加第一个下拉选项时非常完美,但是如果页面上有两个select存在时问题来了. 我们发现当控制其中一个选项被选中是, 另外一个select显示的值也随之改变. 我们需要将一组 select & select-item 进行绑定,让Bus在接受时知道事件来自于哪个里面的 select-item.

vue2中我们通常获取实例的parent然后一层一层寻找父类select, 但是在 vue3 setup中并不能获取到正确的parent, 所以我想到了可以在 select 创建时派发一个 token 在讲此令牌传给所有子类, 好了理论存在, 开始实践.

provide & inject

在vue中使用provide可以向子类、孙类等等后代传输数据, 后代使用inject接收数据.查看官网

components_provide.png

派发token令牌

这里可以模仿Java中的UUID

// token.js
function code() {
    return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1);
}

export function tokenFun() {
    return (code() + code() + "-" + code() + "-" + code() + "-" + code() + "-" + code() + code() + code());
}

select 创建时生成 token 并派发给后代

// select.vue
import {tokenFun} from '@/utils/token'
import {provide, getCurrentInstance} from 'vue';

...

setup(){

    ...
    
    // 获取实例
    const page = getCurrentInstance()

    var token = 'select-' + tokenFun();
    // 缓存token
    page.token = token
    // 给子元素派发token
    provide('token',token)
  
  return {
      token
  }
}

这样我们在子类接收后每次使用bus发送数据时带上token

// select-item.vue
import {ref, getCurrentInstance, inject} from 'vue';

...

setup(){

    ...
    
    // 获取实例
    const page = getCurrentInstance();
    
    // 接收token
    const token = inject('token');
    // 缓存token
    page.token = token
    
    // 选择下拉
    function chooseSelectItem(){
        // 在使用Bus发送数据时带上token
        Bus.$emit('chooseSelectItem',{token: token,value: props.value});
    }
}

select.vue 监听Bus后先验证token

onMounted(()=>{
    Bus.$on('chooseSelectItem',(res)=>{
        // 判断发送数据的子孙携带的token是否和实例一样
        if(res.token === page.token){
            // 修改显示值
            selctValue.value = res.value
            // 关闭下拉框
            selectOpen.value = false
        }
    })
}) 

大功告成, 这样我们就做好了一个select下拉选项, 下拉部分挂于body标签

全部代码

select.vue

<template>
  <!-- 下拉框 -->
  <div class="tk-select"> 
      <div ref="select_button" class="tk-select-button" @click="selectOpen = !selectOpen">
          <!-- 选中内容 -->
          <span>{{selctValue}}</span>
          <div class="select-icon" :class="{'selectOpen':selectOpen}">
              <i class="fi fi-rr-angle-small-down"></i>
          </div>
      </div>
      <!-- 下拉框 -->
      <teleport to="body">
          <transition name="select">
            <div ref="select_dropdown" v-show="selectOpen" :style="dropdownStyle" class="tk-select-dropdown">
                <ul>
                    <slot name="selectDropDown"></slot>
                </ul>
            </div>
          </transition>
      </teleport>
  </div>
</template>

<script>
import {tokenFun} from '@/utils/token'
import Bus from './selectBus'
import {ref,onMounted,computed,watch,onDeactivated,provide,getCurrentInstance} from 'vue';
export default {
    name: 'TkSelect',
    props: {
        selected: String
    },
    setup(props,ctx){

        const page = getCurrentInstance()

        // 获取按钮
        const select_button = ref(null);
        const select_dropdown = ref(null);

        // 打开状态
        const selectOpen = ref(false);

        // 选中内容
        const selctValue = ref('');

        // 下拉框位置
        const dropdownPosition = ref({x:0,y:0,w:0})

        // 下拉框位置
        const dropdownStyle = computed(()=>{
            return {
                left: `${dropdownPosition.value.x}px`,
                top:  `${dropdownPosition.value.y}px`,
                width: `${dropdownPosition.value.w}px`
            }
        })

        watch(selectOpen,(val)=>{
            if(val)
                // 计算位置
                calculateLocation();
        })

        watch(selctValue,()=>{
            ctx.emit('update:modelValue', selctValue.value)
        })

        // 计算位置
        function calculateLocation(){
            var select_button_dom = select_button.value.getBoundingClientRect()
            dropdownPosition.value.w = select_button_dom.width
            dropdownPosition.value.x = select_button_dom.left
            dropdownPosition.value.y = select_button_dom.top + select_button_dom.height + 5
        }

        window.addEventListener('click',(event)=>{
            if(!select_button.value.contains(event.target) && !select_dropdown.value.contains(event.target) ){
                selectOpen.value = false
            }
        })
         window.addEventListener('touchstart',(event)=>{
            if(!select_button.value.contains(event.target) && !select_dropdown.value.contains(event.target) ){
                selectOpen.value = false
            }
        })

        window.addEventListener('resize',()=>{
            // 计算面板位置
            calculateLocation();
        })
        window.addEventListener('scroll',()=>{
            // 计算面板位置
            calculateLocation();
        })

        onDeactivated(()=>{
            window.removeEventListener('resize')
            window.removeEventListener('scroll')
            window.removeEventListener('click')
            window.removeEventListener('touchstart')
            Bus.$off('chooseSelectItem');
        })

        var token = 'select-' + tokenFun();
        // 获取生成的token
        page.token = token
        // 给子元素派发token
        provide('token',token)

        onMounted(()=>{
             Bus.$on('chooseSelectItem',(res)=>{
                 if(res.token === page.token){
                    selctValue.value = res.value
                    selectOpen.value = false
                    Bus.$emit('chooseActive',{token:token,value:selctValue.value})
                 }
            })
            if(props.selected){
                selctValue.value = props.selected
                Bus.$emit('chooseActive',{token:token,value:selctValue.value})
            }else{
                selctValue.value = ctx.slots.selectDropDown()[0].props.value
                Bus.$emit('chooseActive',{token:token,value:selctValue.value})
            }
        })

        return {
            selectOpen,
            selctValue,
            select_dropdown,
            select_button,
            dropdownStyle,
            dropdownPosition,
            calculateLocation,
            token
        }
    }
}
</script>

<style lang="scss" scoped>
// 下拉框
.tk-select-button{
    width: 100%;
    height: 48px;
    padding: 0 16px;
    border-radius: 12px;
    font-size: 14px;
    font-weight: 500;
    line-height: 48px;
    display: flex;
    align-items: center;
    justify-content: space-between;
    border: #E6E8EC 2px solid;
    background-color: #FCFCFD;
    cursor: pointer;
    transition: border .2s;
}
.tk-select-button:hover{
    border: #23262F 2px solid;
}
.tk-select-button span{
    font-weight: 500;
    user-select: none;
}

// icon
.select-icon{
    width: 32px;
    height: 32px;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 50%;
    border: #E6E8EC 2px solid;
    transition: all .2s;
}
.select-icon.selectOpen{
    transform: rotate(180deg);
}

// 下拉框
.tk-select-dropdown{
    position: fixed;
    background-color: #FCFCFD;
}
.tk-select-dropdown ul{
    overflow: hidden;
    border-radius: 12px;
    border: #E6E8EC 2px solid;
    box-shadow: 0 4px 12px rgba(35,38,47 ,0.1);
}

.select-enter-from, .select-leave-to{
    opacity: 0;
    transform: scale(0.9);
}
.select-enter-active, .select-leave-active{
    transform-origin: top center;
    transition: opacity .4s cubic-bezier(0.5, 0, 0, 1.25), transform .2s cubic-bezier(0.5, 0, 0, 1.25);
}
</style>

select-item.vue

<template>
  <li class="tk-select-item" :class="{'active':active}" @click="chooseSelectItem">
      <slot></slot>
  </li>
</template>

<script>
import Bus from './selectBus'
import {ref, getCurrentInstance, inject, onDeactivated} from 'vue';
export default {
    name: "TkSelectItem",
    props: ['value'],
    setup(props){

        const page = getCurrentInstance();

        const active = ref(false);
       
        // 接收token
        const token = inject('token');
        page.token = token
        Bus.$on('chooseActive',(res)=>{
            if(res.token !== page.token)
                return
            if(res.value == props.value)
                active.value = true
            else
                active.value = false
            })

        // 选择下拉
        function chooseSelectItem(){
            Bus.$emit('chooseSelectItem',{token: token,value: props.value});
        }

        onDeactivated(()=>{
            Bus.$off('chooseActive')
        })

        return {
            chooseSelectItem,
            active,
            token
        }
    }
}
</script>

<style lang="scss" scoped>
.tk-select-item.active{
    color: #3772FF;
    background-color: #F3F5F6;
    user-select: none;
}
</style>

token.js

function code() {
    return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1);
}

export function tokenFun() {
    return (code() + code() + "-" + code() + "-" + code() + "-" + code() + "-" + code() + code() + code());
}

selectBus.js

import emitter from 'tiny-emitter/instance'

export default {
    $on: (...args) => emitter.on(...args),
    $once: (...args) => emitter.once(...args),
    $off: (...args) => emitter.off(...args),
    $emit: (...args) => emitter.emit(...args)
}

GitHub源码地址

github.com/18651440358/vue3-select

第一次写帖子几分激动几分不知所措, 请各位大佬指点错误或可以优化的地方, 欢迎大家讨论.

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,590评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 86,808评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,151评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,779评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,773评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,656评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,022评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,678评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,038评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,659评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,756评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,411评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,005评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,973评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,053评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,495评论 2 343

推荐阅读更多精彩内容