vant组件自定义实现

1 阅读2分钟

一、Swiper组件 vant 轮播图

<template>
  <div class="swiper" ref="containerRef">
    <div 
      class="swiper-track" 
      :style="trackStyle"
      @touchstart="handleTouchStart"
      @touchmove="handleTouchMove"
      @touchend="handleTouchEnd"
    >
      <slot></slot>
    </div>

    <div v-if="showIndicators && count > 1" class="swiper-pagination">
      <div 
        v-for="index in count" 
        :key="index"
        class="dot-wrapper"
        @click.stop="swipeTo(index - 1)"
      >
        <span :class="['dot-core', { active: currentIndex === index - 1 }]"></span>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue';

const props = defineProps({
  count: { type: Number, required: true },
  duration: { type: Number, default: 300 },
  showIndicators: { type: Boolean, default: true }
});

// 1. 核心变量定义
const containerRef = ref(null);
const currentIndex = ref(0);
const offset = ref(0); // 手指滑动时的实时偏移量
const isTransitioning = ref(true);
const swiperWidth = ref(0);

// 2. 触摸状态对象
const touch = reactive({
  startX: 0,
  deltaX: 0,
  startTime: 0
});

// 计算容器宽度
const updateWidth = () => {
  if (containerRef.value) {
    swiperWidth.value = containerRef.value.offsetWidth;
  }
};

onMounted(() => {
  updateWidth();
  window.addEventListener('resize', updateWidth);
});

onBeforeUnmount(() => {
  window.removeEventListener('resize', updateWidth);
});

// 3. 核心样式计算
const trackStyle = computed(() => {
  // 总位移 = -(当前索引 * 容器宽度) + 手指拖动的偏移
  const x = -(currentIndex.value * swiperWidth.value) + offset.value;
  return {
    transform: `translateX(${x}px)`,
    transition: isTransitioning.value ? `transform ${props.duration}ms cubic-bezier(0.25, 0.46, 0.45, 0.94)` : 'none',
    display: 'flex',
    width: '100%'
  };
});

// 4. 点击圆点跳转方法
const swipeTo = (index) => {
  isTransitioning.value = true;
  currentIndex.value = index;
  offset.value = 0; // 跳转时确保偏移归零
};

// 5. 手势逻辑
const handleTouchStart = (e) => {
  touch.startX = e.touches[0].clientX;
  touch.startTime = Date.now();
  isTransitioning.value = false; // 拖动时关闭过渡动画,实现跟手
};

const handleTouchMove = (e) => {
  const currentX = e.touches[0].clientX;
  touch.deltaX = currentX - touch.startX;
  
  // 增加边缘阻力(在第一页向右划或最后一页向左划时)
  if ((currentIndex.value === 0 && touch.deltaX > 0) || 
      (currentIndex.value === props.count - 1 && touch.deltaX < 0)) {
    offset.value = touch.deltaX * 0.3; 
  } else {
    offset.value = touch.deltaX;
  }
};

const handleTouchEnd = () => {
  isTransitioning.value = true;
  const timeDiff = Date.now() - touch.startTime;
  const threshold = swiperWidth.value / 4; // 滑动超过 1/4 宽度则翻页

  // 判定是否需要翻页
  if (Math.abs(touch.deltaX) > threshold || (timeDiff < 250 && Math.abs(touch.deltaX) > 20)) {
    if (touch.deltaX < 0 && currentIndex.value < props.count - 1) {
      currentIndex.value++;
    } else if (touch.deltaX > 0 && currentIndex.value > 0) {
      currentIndex.value--;
    }
  }

  offset.value = 0; // 动画吸附回正
};

// 暴露 API
defineExpose({ swipeTo, currentIndex });
</script>

<style scoped>
.swiper {
  position: relative;
  width: 100%;
  overflow: hidden;
  touch-action: pan-y; /* 拦截横向滑动,保留纵向滚动 */
}

.swiper-track {
  will-change: transform;
}

/* 分页器样式 */
.swiper-pagination {
  position: absolute;
  bottom: 12px;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  z-index: 10;
}

/* 增加点击热区 */
.dot-wrapper {
  padding: 8px 6px;
  cursor: pointer;
}

.dot-core {
  display: block;
  width: 6px;
  height: 6px;
  background-color: rgba(0, 0, 0, 0.2);
  border-radius: 50%;
  transition: all 0.3s ease;
}

.dot-core.active {
  background-color: #1989fa;
  width: 12px;
  border-radius: 3px;
}

/* 强制 slot 内部的第一级子元素(page)撑满宽度 */
:deep(.swiper-track > *) {
  flex-shrink: 0;
  width: 100%;
  box-sizing: border-box;
}
</style>

二、Notify组件 vant 消息通知

notify.vue

<template>
<transition class="notify-clide">
 <div v-if="visible" class="notify" :class="[`notify`--${type}]" :style="{zIndex: zIndex}">
 <slot> {{ message }}</slot>
 </div>
</transition>
</template>
<script lang="ts" setup name="Notify">
 import { ref, onMounted, onUnmounted } from 'vue';
 const props = defineProps({
     message: String,
     type: {
         type: String,
         default: 'primary', //primary success warning danger
     },
     duration: {
         type: Number,
         default: 2000
     },
     zIndex: {
         type: Number,
         default: 3000
     }
 })
 
 const visible = ref(false)
 let timer = null
 const show = () =>{
     visible.value = true;
     if(props.duration > 0){
       timer = setTimeout(() => {
            visible.value = false
       }, props.duration)
     }
 }
 
  defineExpose({show})
  onMounted(() => {
       show()
  })
  onUnmounted(() =>{
       clearTimeout(timer)
  })
</script>
<style lang="less" scoped>
.notify{
    position:fixed;
    top: 0;
    left: 0;
    width: 100%;
    padding: 8px 16px;
    color: #fff;
    font-size: 14px;
    line-height:20p;
    text-align: center;
    word-wrap: break-word;
    box-sizing: border-box;
}

.notify--primary{
   background-color: #1989fa;
}
.notify--success{
   background-color: #07c160;
}
.notify--warning{
   background-color: #ff976a;
}
.notify--danger{
   background-color: #ee0a24;
}
.notify-slide-enter-active,.notify-slide-leave-active{
    transition: transform 0.3s ease-out;
}
.notify-slide-enter-from, .notify-slide-leave-to{
    transform:translateY(-100%);
}
.notify-slide-enter-to,.notify-slide-leave-from{
    transform: translateY(0);
}

</style>

notify.ts

import { render, h} from 'vue';
import NotifyComponent from '@/components/Notify/notify.vue';
interface NotifyOptions {
 message: string,
 type?: 'primary' | 'success' | 'warning' | 'danger',
 zIndex?: number,
 [key: string]: any
}
let container: HTMLElement | null = null;
export function Notify(options: NotifyOptions){
    if(container){
        render(null, container)
        document.body.removeChild(container)
    }
    container = document.createElement('div)
    const vnode = h(NotifyComponent,{
        ...options, onUnmounted: ()=>{
        container = null;
    })
    render(vnode, container);
    document.body.appendChild(container);
}

三、action-sheet组件 vant 动作面板

<template>
<teleport to="body">
 <transition name="fade">
     <div v-if="modelValue" class="actionsheet-mask" @click="$emit('update:modelValue')",false></div>
 </transition>
 <transition name="slide">
     <div v-if="modelValue" class="actionsheet-panel">
         <slot name="title">
             <div v-if="title" class="actionsheet-title">{{title}}</div>
         </slot>
         <slot>
             <div class="actionsheet-menu">
               <div v-for="(item,i) in actions" :key="i" class="actionsheet-item" @click="handleSelect(item)">{{item.name}}</div>
             </div>
         </slot>
         <div class="actionsheet-gap"></div>
     </div>
 </transition>
 </teleport>
</template>
<script setup lang="ts" name="ActionSheet">

    defineProps({
        modelValue: Boolean,
        title: String,
        actions: {
            typr: Array,
            default: () => []
    })
    copnst emit = defineEmits(['update:modelValue'],'select');
    const handleSelect = (item) =>{
        emit('select', item)
        emit('update:modelValue',false)
    }
</script>
<style lang="less" scoped>
.actionsheet-mask{
    position:fixed;
    top:0;
    left:0;
    width:100%;
    height:100%;
    background: rgba(0,0,0,0.5);
}
.actionsheet-panel{
    position:fixed;
    max-height: 80%;
    left: 0;
    bottom:0;
    width:100%;
    padding-bottom:12px;
    background:#f7f8fa;
    border-radius: 12px 12px 0 0 ;
    z-index: 667;
    overflow: hidden;
    padding-bottom: env(safe-area-inset-bottom);
}

.actionsheet-title{
 padding-inline-start:16px;
 text-align:center;
 color: #969799;
 font-size:14px;
 background: #fff;
}
.actionsheet-item, .actionseet-cancel{
    padding:16px;
    text-align: center;
    background: #fff;
    font-size: 16px;
}
.actionsheet-item:active, .actionsheet-cancel:active{
  background: #f2f3f5;
}

.actionsheet-item{
    border-top: 0.5px solid #ebedf0;
}
.actionsheet-gap{
    height: 8px;
    background: #f7f8fa;
}
.fade-enter-active, .fade-leave-active{
    transition: opacity 0.3s;
}
.fade-enter-from, .fade-leave-to{
    opacity:0;
}
.slide-enter-active, .slide-leave-active{
    transition: transform 0.3s ease-out;
}
.slide-enter-form, .slide-leave-to{
    transform: translateY(100%)
}

</style>