用Naive UI、Vue3搭建城市下拉选择组件

920 阅读2分钟

前言

vue3项目,在选择UI框架时,我比较倾向于Naive UI,因为它的文档很有意思,且无需导入lesssass等样式插件,即可实现主题切换功能,重点是vue3官方推荐它,若大家感兴趣的话,可以查看ant design vue VS native UI以及它们npm包的下载数量

城市下拉组件最终效果,如下图所示:

inputs_select_city.png

源码地址

选择框

我们先实现下拉选择框,代码如下所示:

<template>
  <div class="select-city-container">
    <n-popover :show="popoverVisible" class="go-input-select-city" trigger="click" placement="bottom-start">
      <template #trigger>
        <n-button icon-placement="right" @click="changeVisible">
          {{ selectedItem.name }}
          <template #icon>
            <n-icon>
              <arrow-up-icon v-if="popoverVisible"></arrow-up-icon>
              <arrow-down-icon v-else></arrow-down-icon>
            </n-icon>
          </template>
        </n-button>
      </template>
    </n-popover>
  </div>
</template>
<script setup lang="ts">
export type CityAreaItem = {
  name: string,
  adcode: string
}

const popoverVisible = ref(false)
const selectedItem = reactive<CityAreaItem>({
  name: '请选择城市',
  adcode: ''
})

const changeVisible = () => {
  popoverVisible.value = !popoverVisible.value
}

ArrowUpIconArrowDownIcon这俩图标,引自另一组件库@vicons/ionicons5

注意:这里需要注意的是n-popover必须用div包裹,若它为根节点,则会抛出警告信息Runtime directive used on component with non-element root node

初步效果如下图所示:

24ccee5b8a4941c4aad9203fbb0d4fd4.png

初始化省份城市数据

省市数据,我分别存放在provinces.jsoncity.json内,下拉界面默认显示省份数据,部分代码如下所示:

<template>
    <div class="select-city-container">
    <n-popover :show="popoverVisible" class="go-input-select-city" trigger="click" placement="bottom-start">
      <template #trigger>
        <n-button icon-placement="right" @click="changeVisible">
          {{ selectedItem.name }}
          <template #icon>
            <n-icon>
              <arrow-up-icon v-if="popoverVisible"></arrow-up-icon>
              <arrow-down-icon v-else></arrow-down-icon>
            </n-icon>
          </template>
        </n-button>
      </template>
      <div class="city-select-popover">
        <div class="city-groups">
          <div wrap-class="scrollbar-wrapper">
            <div class="city-group" v-for="cityGroup in currentCityGroups" :key="cityGroup.letter">
              <div :ref="el => (scrollbarWrapperRef[cityGroup.letter] = el)" class="letter">{{ cityGroup.letter }}</div>
              <template v-if="searchType === 'province'">
                <div class="list-container">
                  <div class="province-list" v-for="(items, key) in cityGroup.cityLists" :key="key">
                    <div class="province">
                      <n-button quaternary type="primary" @click="handleProvinceClick(items.province)">{{
                        getProvinceName(items.province.name)
                      }}</n-button>
                    </div>
                    <div class="city-list">
                      <span class="city" v-for="item in items.cityList" :key="item.adcode">
                        <n-button quaternary type="primary" @click="handleCityClick(item)">{{ item.name }}</n-button>
                      </span>
                    </div>
                  </div>
                </div>
              </template>
              <template v-else>
                <div class="list-container city-list">
                  <span class="city" v-for="item in cityGroup.cityList" :key="item.adcode">
                    <n-button quaternary type="primary" @click="handleCityClick(item)">{{ item.name }}</n-button>
                  </span>
                </div>
              </template>
            </div>
          </div>
        </div>
      </div>
    </n-popover>
  </div>
            
</template>
<script setup lang="ts">
import { cities } from './config'
import provinces from './json/provinces.json'

const scrollbarWrapperRef = ref<any[]>([])
const currentCityGroups = ref<any[]>([])
const searchType = ref('province')

const provinceGroups: any = []
const cityGroupByProvince = () => {
  if (provinceGroups.length) return provinceGroups
  if (provinceOptions.value?.length && cityOptions.value?.length) {
    let provinceOfLetter: any = {}
    provinceOptions.value.forEach(provinceOption => {
      let letter = provinceOption.letter
      let cityLists: any = []
      if (provinceOfLetter[letter]) {
        cityLists = provinceOfLetter[letter]
      } else {
        cityLists = []
        provinceOfLetter[letter] = cityLists
        provinceGroups.push({
          letter,
          cityLists
        })
      }
      cityLists.push({
        province: provinceOption,
        cityList: getCityList(provinceOption.adcode)
      })
    })
    provinceGroups.sort((previous: any, next: any) => {
      return previous.letter.charCodeAt(0) - next.letter.charCodeAt(0)
    })
  }
  return provinceGroups
}

const cityGroups: any = []
const cityGroupByCity = () => {
  if (cityGroups.length) return cityGroups
  if (cityOptions.value?.length) {
    let cityOfLetter: any = {}
    cityOptions.value.forEach(cityOption => {
      let letter = cityOption.letter
      let cityList: any = []
      if (cityOfLetter[letter]) {
        cityList = cityOfLetter[letter]
      } else {
        cityList = []
        cityOfLetter[letter] = cityList
        cityGroups.push({
          letter,
          cityList
        })
      }
      cityList.push(cityOption)
    })
    cityGroups.sort((previous: any, next: any) => {
      return previous.letter.charCodeAt(0) - next.letter.charCodeAt(0)
    })
  }
  return cityGroups
}
const initList = () => {
  if (searchType.value === 'province') {
    currentCityGroups.value = cityGroupByProvince()
  } else {
    currentCityGroups.value = cityGroupByCity()
  }
}
onMounted(() => {
  initList()
})

b6a42ff0552e404ab37fbbf812478eaf.png

默认显示省份下面的城市数据,若切换到红框内的城市,则按各个城市的首字母进行排序

由于点击字母,需要滑动到对应的区域,故我在代码内定义const scrollbarWrapperRef = ref<any[]>([]),在大写字母模板内,将各个el按照letter索引存储到scrollbarWrapperRef内,<div :ref="el => (scrollbarWrapperRef[cityGroup.letter] = el)"></div>

滚动到对应区域的代码为:

//点击字母
const handleLetterClick = (letter: any) => {
  scrollbarWrapperRef.value[letter].scrollIntoView({ behavior: 'smooth', block: 'start' })
}

热门城市

<template>
  <div class="select-city-container">
    <n-popover :show="popoverVisible" class="go-input-select-city" trigger="click" placement="bottom-start">
      <template #trigger>
        <n-button icon-placement="right" @click="changeVisible">
          {{ selectedItem.name }}
          <template #icon>
            <n-icon>
              <arrow-up-icon v-if="popoverVisible"></arrow-up-icon>
              <arrow-down-icon v-else></arrow-down-icon>
            </n-icon>
          </template>
        </n-button>
      </template>
      <div class="city-select-popover">
        <div class="current-city">
          当前城市:
          <span>{{ selectedItem.name === NOTICE_MSG ? '' : selectedItem.name }}</span>
        </div>
        <div class="hot-cities">
          <div class="hot-city-title">热门城市:</div>
          <div style="flex: 1; margin-top: -5px">
            <span class="hot-city" v-for="item in hotCities" :key="item.adcode">
              <n-button quaternary type="primary" @click="clickHotCity(item)">{{ item.name }}</n-button>
            </span>
          </div>
        </div>
    </div>
  </n-popover>
</div>

我在city.json内的各个城市数据内,添加hot字段,若hot0,则表示其为热门城市,并按照hot值大小对热门城市进行排序,代码如下所示:

const hotCities = computed(() => {
  let hotCities: any = []
  if (cityOptions.value?.length) {
    cityOptions.value.forEach(cityOption => {
      cityOption.hot = +cityOption.hot
      if (cityOption.hot) {
        hotCities.push(cityOption)
      }
    })
  }
  hotCities.sort((previous: any, next: any) => {
    return previous.hot - next.hot
  })
  return hotCities
})

尾声

借助于Naive UI、Vue3编写组件固然方便,但一旦变换框架,又需要重新编写组件,这极其不便,后期,我打算用webcomponent对其进行重构。若大家觉得有什么好的意见,欢迎在评论区留言~