vue3 實現(xiàn) select 下拉選項

呃哼~ 第一次發(fā)帖. 寫不好請見諒

本人學生 , 平時在外面沒事接點小項目小賺一筆補貼生活費. 之前一直都是使用Vue2.x的版本做項目, 暑假剛剛學習了Vue3想著新項目就直接用Vue3上手.

效果展示

好了, 話不多說先給大佬們看看效果樣式:


bs470-ngit5.gif

組件難點

因為下拉框可能會在某些情況下被擋住, 所以這里的下拉框被掛載到了body標簽上, 并且下拉框中的選項往往是以<slot>插槽的形式編寫, 這里就會困擾到很多小白, 搞不明白怎么樣才能在 下拉框觸發(fā)下拉按鈕 之間關(guān)聯(lián)響應式事件與數(shù)據(jù).

組件的使用

<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="鎮(zhèn)江市">鎮(zhèn)江市</tk-select-item>
    </template>
</tk-select>

參數(shù)說明

tk-selectselect下選項父標簽, 必須含有插槽 #selectDropDown 才能正常使用

Attribute Description Accepted Values Default
selected 默認選中的值,如果不填或為空則默認選中插槽中的第一個 tk-select-item 中的值 - -

tk-select-item 為**select
**下選項子標簽(選項標簽), tk-select-item 內(nèi)可以繼續(xù)寫入其他 HTML 內(nèi)容, 每項的具體值由props value 決定

Attribute Description Accepted Values Default
value 詞選項默認返回的數(shù)據(jù) (必須設置) - -

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>

實現(xiàn)思路

首先看看目錄結(jié)構(gòu)

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 里面的內(nèi)容

我們先看看 vue3 官網(wǎng)怎么說的 進入官網(wǎng). 說人話的意思就是不可以像 vue2 那樣愉快的安裝Bus, 需要自己實現(xiàn)事件接口或者使用第三方插件. 這里官網(wǎng)也給出了具體實現(xiàn)方案.

// 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> 標簽, 可以將標簽內(nèi)的元素掛載到任意位置, 查看官方文檔

// teleport 用法
// 將<h1>掛載到body上

<teleport to="body">
    <h1>標題</h1>
</teleport>

select 主要有觸發(fā)下拉按鈕tk-select-button和下拉列表tk-select-dropdown組成, 下拉框中的選項未來將由插槽插入.

<!-- select.vue -->
<template>
  <!-- 下拉框 -->
  <div class="tk-select"> 
      <div ref="select_button" class="tk-select-button" @click="selectOpen = !selectOpen">
          <!-- 選中內(nèi)容 -->
          <span>{{selctValue}}</span>
          <!-- 右側(cè)小箭頭 -->
          <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>

首先解決下拉列表打開&關(guān)閉和定位的問題

import { ref, onDeactivated } from 'vue';
export default {
    // 獲取按鈕
    const select_button = ref(null);
    // 獲取下拉框
    const select_dropdown = ref(null);
    
    // 下拉框位置參數(shù)
    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();
    })
    
    // ---------------------------------增加一點修飾---------------------------------------
    // 點擊非按鈕或下拉框區(qū)域也會收起下拉框
    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();
    })
    
    // 當組件卸載時釋放這些監(jiān)聽
    onDeactivated(() => {
        window.removeEventListener('resize')
        window.removeEventListener('scroll')
        window.removeEventListener('click')
    })
    
    return {
        select_button,
        select_dropdown,
        dropdownPosition,
        dropdownStyle,
        calculateLocation
    }
}

讓我們繼續(xù)看看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(){
    // 選中內(nèi)容
    const selctValue = ref('');
    ...
    onMounted(()=>{
        Bus.$on('chooseSelectItem',(res)=>{
            // 修改顯示值
            selctValue.value = res.value
            // 關(guān)閉下拉框
            selectOpen.value = false
        })
    })
    ...
}

到這里下拉選項框基本就完成了. 我們像頁面添加第一個下拉選項時非常完美,但是如果頁面上有兩個select存在時問題來了. 我們發(fā)現(xiàn)當控制其中一個選項被選中是, 另外一個select顯示的值也隨之改變. 我們需要將一組 select & select-item 進行綁定,讓Bus在接受時知道事件來自于哪個里面的 select-item.

vue2中我們通常獲取實例的parent然后一層一層尋找父類select, 但是在 vue3 setup中并不能獲取到正確的parent, 所以我想到了可以在 select 創(chuàng)建時派發(fā)一個 token 在講此令牌傳給所有子類, 好了理論存在, 開始實踐.

provide & inject

在vue中使用provide可以向子類、孫類等等后代傳輸數(shù)據(jù), 后代使用inject接收數(shù)據(jù).查看官網(wǎng)

components_provide.png

派發(fā)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 創(chuàng)建時生成 token 并派發(fā)給后代

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

...

setup(){

    ...
    
    // 獲取實例
    const page = getCurrentInstance()

    var token = 'select-' + tokenFun();
    // 緩存token
    page.token = token
    // 給子元素派發(fā)token
    provide('token',token)
  
  return {
      token
  }
}

這樣我們在子類接收后每次使用bus發(fā)送數(shù)據(jù)時帶上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發(fā)送數(shù)據(jù)時帶上token
        Bus.$emit('chooseSelectItem',{token: token,value: props.value});
    }
}

select.vue 監(jiān)聽Bus后先驗證token

onMounted(()=>{
    Bus.$on('chooseSelectItem',(res)=>{
        // 判斷發(fā)送數(shù)據(jù)的子孫攜帶的token是否和實例一樣
        if(res.token === page.token){
            // 修改顯示值
            selctValue.value = res.value
            // 關(guān)閉下拉框
            selectOpen.value = false
        }
    })
}) 

大功告成, 這樣我們就做好了一個select下拉選項, 下拉部分掛于body標簽

全部代碼

select.vue

<template>
  <!-- 下拉框 -->
  <div class="tk-select"> 
      <div ref="select_button" class="tk-select-button" @click="selectOpen = !selectOpen">
          <!-- 選中內(nèi)容 -->
          <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);

        // 打開狀態(tài)
        const selectOpen = ref(false);

        // 選中內(nèi)容
        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
        // 給子元素派發(fā)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

第一次寫帖子幾分激動幾分不知所措, 請各位大佬指點錯誤或可以優(yōu)化的地方, 歡迎大家討論.

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容