呃哼~ 第一次發(fā)帖. 寫不好請見諒
本人學生 , 平時在外面沒事接點小項目小賺一筆補貼生活費. 之前一直都是使用Vue2.x的版本做項目, 暑假剛剛學習了Vue3想著新項目就直接用Vue3上手.
效果展示
好了, 話不多說先給大佬們看看效果樣式:

組件難點
因為下拉框可能會在某些情況下被擋住, 所以這里的下拉框被掛載到了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-select為select下選項父標簽, 必須含有插槽 #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)

派發(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)化的地方, 歡迎大家討論.