vue3实现漫游式导航

985 阅读5分钟

有些系统在首次进入时需要引导用户怎样操作,效果参考ant.design的组件 ant.design/components/… ,但是这个组件只有react版本,却没有vue3的版本,所以打算自己实现一个。

已上传npm:npm install -s y-tour

封装Tour组件

分析下导航窗口,大致有以下几个部分: 灰色遮罩层、箭头、标题、内容、当前步骤、下一步按钮、关闭按钮

image.png

1、遮罩层

参考ant.design,我们发现灰色的遮罩层笼罩了整个页面,只有被导航的元素是镂空的,并且还可以点击。灰色背景容易实现,但是镂空效果有点困难,所以直接照搬了ant.design的代码

<svg v-if="maskRect && props.mask && _show" class="mask">
  <mask id="ant-tour-mask-:r0:">
    <rect x="0" y="0" width="100%" height="100%" fill="white"></rect>
    <rect fill="black" rx="2"
          :x="maskRect.center.left" :y="maskRect.center.top"
          :width="maskRect.center.width" :height="maskRect.center.height"></rect>
  </mask>
  <rect x="0" y="0" width="100%" height="100%" pointer-events="auto"
        fill="rgba(0,0,0,0.5)" mask="url(#ant-tour-mask-:r0:)"></rect>
  <!--  上  -->
  <rect fill="transparent" pointer-events="auto"
        :x="maskRect.top.left" :y="maskRect.top.top"
        :width="maskRect.top.width" :height="maskRect.top.height"></rect>
  <!--  下  -->
  <rect fill="transparent" pointer-events="auto"
        :x="maskRect.bottom.left" :y="maskRect.bottom.top"
        :width="maskRect.bottom.width" :height="maskRect.bottom.height"></rect>
  <!--  左  -->
  <rect fill="transparent" pointer-events="auto"
        :x="maskRect.left.left" :y="maskRect.left.top"
        :width="maskRect.left.width" :height="maskRect.left.height"></rect>
  <!--  又  -->
  <rect fill="transparent" pointer-events="auto"
        :x="maskRect.right.left" :y="maskRect.right.top"
        :width="maskRect.right.width" :height="maskRect.right.height"></rect>
</svg>

ant.design是用svg实现的,分为上下左右和中间5个部分,这5个部分的宽高位置需要自己计算出来,这里先将变量存在maskRect里了。

2、窗口内容

窗口部分就比较简单,直接上代码

<div v-if="_show" class="tour"
     :style="{top:(infoWindowRect?.top||0)+'px',left:(infoWindowRect?.left||0)+'px'}">
  <slot>
    <div class="tour-inner">
      <div class="tour-top">
        <!-- 标题 -->
        <slot name="title" v-bind="{...stepInfo}">
          <div>{{stepInfo.title}}</div>
        </slot>
        <!-- 关闭按钮 -->
        <slot name="close" v-bind="{...stepInfo}">
          <svg @click="close" t="1672734903811" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2908" width="200" height="200"><path d="M0 0h1024v1024H0z" fill="#FF0033" fill-opacity="0" p-id="2909"></path><path d="M240.448 168l2.346667 2.154667 289.92 289.941333 279.253333-279.253333a42.666667 42.666667 0 0 1 62.506667 58.026666l-2.133334 2.346667-279.296 279.210667 279.274667 279.253333a42.666667 42.666667 0 0 1-58.005333 62.528l-2.346667-2.176-279.253333-279.253333-289.92 289.962666a42.666667 42.666667 0 0 1-62.506667-58.005333l2.154667-2.346667 289.941333-289.962666-289.92-289.92a42.666667 42.666667 0 0 1 57.984-62.506667z" fill="#111111" p-id="2910"></path></svg>
        </slot>
      </div>
      <!-- 描述 -->
      <slot name="description" v-bind="{...stepInfo}">
        <div class="tour-description">{{stepInfo.description}}</div>
      </slot>
      <div class="tour-bottom">
        <!-- 底部 -->
        <slot name="footer" v-bind="{...stepInfo}">
          <!-- 步骤 -->
          <div class="step-box">
            <span>{{_current+1}}/{{ props.steps.length }}</span>
            <span style="margin-left: 5px;cursor: pointer" @click="close">跳过</span>
          </div>
          <!-- 按钮 -->
          <div class="step-but">
            <div class="step-but-item" v-show="tourHandler.current>0" @click="last">上一步</div>
            <div class="step-but-item" v-show="!isLast" @click="next">下一步</div>
            <div class="step-but-item" v-show="isLast" @click="close">完成</div>
          </div>
        </slot>
      </div>
    </div>
  </slot>
</div>
.tour{
  position: fixed;
  min-width: 400px;
  min-height: 150px;
  background: #ffffff;
  border-radius: 5px;
  z-index: 1010;
  transition: all 0.2s;
  box-shadow: rgba(0,0,0,0.2) 1px 1px 5px 2px;
  .tour-inner{
    display: flex;
    flex-direction: column;
    justify-content: space-between;
    padding: 10px 20px;
    min-width: 400px;
    min-height: 150px;
    box-sizing: border-box;
    .tour-top{
      display: flex;
      justify-content: space-between;
      align-items: center;
      width: 100%;
      height: 40px;
      font-size: 16px;
      font-weight: 500;
      .icon{
        width: 15px;
        height: 15px;
        cursor: pointer;
      }
    }
    .tour-bottom{
      display: flex;
      justify-content: space-between;
      align-items: center;
      .step-box{
        font-weight: 600;
        color: #757575;
      }
      .step-but{
        display: flex;
        .step-but-item{
          padding: 2px 5px;
          background: #3f9eff;
          box-sizing: border-box;
          border-radius: 4px;
          color: #ffffff;
          cursor: pointer;
          user-select: none;
          &:active{
            background: #54a7ff;
          }
        }
        .step-but-item + .step-but-item{
          margin-left: 5px;
        }
      }
    }
  }
}

整个窗口使用_show来控制显示,infoWindowRect来控制位置。并且标题、描述、关闭按钮、底部以及整个窗口都放在了slot里,方便使用组件时自定义样式。

3、箭头

<div v-if="_show" class="arrow" :style="{top:(arrowRect?.top||0)+'px',left:(arrowRect?.left||0)+'px'}"></div>
.arrow{
  position: fixed;
  display: flex;
  justify-content: center;
  align-items: center;
  top: 100px;
  left: 100px;
  width: 30px;
  height: 30px;
  z-index: 1020;
  transition: all 0.2s;
  &:after{
    content: '';
    position: absolute;
    width: 15px;
    height: 15px;
    background: #ffffff;
    border-radius: 3px;
    transform: rotate(45deg);
    box-shadow: v-bind(arrowShadow);
  }
}

箭头的位置使用arrowRect设置

4. Tour组件逻辑控制

  • 定义props
const props = withDefaults(defineProps<{
  current?: number,
  show?: boolean,
  steps: Step[],
  mask?: boolean
}>(), {
  current: 0,
  show: false,
  steps: ()=>[],
  mask: true
})
  • 定义一些事件

current、show进行双向绑定

const emit = defineEmits([
    'update:current',
    'update:show',
    'open',
    'close',
    'change',
    'next',
    'last',
    'skip'
])
  • 定义响应式变量
// 当前步骤
const _current = ref(props.current)
// 控制整个组件的显示
const _show = ref(props.show)

// 当前步骤的信息
const stepInfo: ComputedRef<Step> = computed(()=>props.steps[_current.value])

// 计算是否为最后一步
const isLast = computed(()=>_current.value >= (props.steps?.length||0)-1)

// 初始遮罩层信息
const maskRect: Ref<MaskRect|null>  = ref(null)
// 初始化导航窗口信息
const infoWindowRect: Ref<Rect> = ref({
  width: 0,
  height: 0,
  left: 0,
  top: 0
})
// 初始化被导航元素信息
const targetRect: Ref<TargetRect|null> = ref(null)
// 初始化尖头位置信息
const arrowRect: Ref<ArrowRect|null> = ref(null)

// 通过arrowRect计算尖头样式
const arrowShadow = computed(()=>{
  switch (arrowRect.value?.direction||''){
    case 'top':
      return 'rgba(0,0,0,0.2) 1px 1px 0px 0px;'
    case 'bottom':
      return 'rgba(0,0,0,0.2) -1px -1px 0px 0px;'
  }
  return 'rgba(0,0,0,0.2) -1px -1px 0px 0px;'
})
  • 创建TourHandler类,并监听位置改变
const tourHandler = new TourHandler(props.steps)
// 
tourHandler.onNext = (rect)=>{
  _current.value = tourHandler.current
  maskRect.value = rect.maskRect
  infoWindowRect.value = rect.infoWindowRect
  targetRect.value = rect.targetRect
  arrowRect.value = rect.arrowRect
  emit('update:current', tourHandler.current)
  emit('change', tourHandler.current)
}
  • 定义组件事件回调,调用tourHandler对象对应的方法,并暴露到组件外
function open() {
  _show.value = true
  tourHandler.start()
  emit('update:show', true)
  emit('open')
}

function last(){
  tourHandler.last()
  emit('last')
}

function next(){
  tourHandler.next()
  emit('next')
}

function close(){
  _show.value = false
  tourHandler.close()
  emit('update:show', false)
  emit('close')
}

defineExpose({
  open, last, next, close
})
  • 监听props.show控制组件显示、监听props.current切换对应的步骤
// 监听props.show,并调用open或close方法
watch(()=>props.show,(newVal)=>{
  newVal?open():close()
})
// 监听props.current的变化,调用tourHandler的toStep方法调整对应步骤
watch(()=>props.current,(newVal)=>{
  _current.value = props.current
  tourHandler.toStep(newVal)
})

四、核心代码-TourHandler类

  1. 创建一个TourHandler类,包含全部步骤的数组steps和current当前步骤
export class TourHandler{
    // 存放全部步骤
    private readonly steps: Step[]
    // 记录当前步骤
    public current = 0

    // 构造函数初始化步骤数组steps
    constructor(steps: Step[]) {
        this.steps = steps
    }
}
  • beforeActive钩子函数的参数为 run函数和getRect函数。
  • 调用getRect可以获得这次窗口渲染的位置信息RectMap
  • 调用run函数才能渲染本次步骤
  • run函数接受RectMap作为参数
  • 可以调用getRect获取RectMap,并对其进行修改,最后调用run函数即可实现对渲染的结果的影响
  1. 还要有以下基本的方法 开始、停止、上一步、下一步、跳转到指定步数
// 开始
start(){
    this.current = 0
    this.run()
}
// 停止
close(){
    this.current = 0
}
// 下一步
next(){
    if(this.current<this.steps.length-1){
        this.current++
        this.run()
    }
}
// 上一步
last(){
    if(this.current>0){
        this.current--
        this.run()
    }
}
toStep(current: number){
    if(current>=0&&current<this.steps.length-1){
        this.current = current
        this.run()
    }
}

start和close时将当前步骤current重置为0,next、last、toStep方法在校验后修改current,并调用run方法

  1. run函数通过current取出steps中当前要渲染的步骤信息
private run(){
    const step = this.steps[this.current]
    // 调用el方法,拿到被导航的元素
    const el = step.el()
    if(!el) throw 'el is '+el
    // 如果有beforeActive回调,则调用它
    if(step.beforeActive){
        // beforeActive接受run和getRect两个函数作为参数
        step.beforeActive((rect)=>{
            // run函数接受RectMap作为参数,如果没有则主动调用getRect方法
            rect = rect || this.getRect(el, step.placement)
            // 最终将位置信息传递给onNext函数
            this.onNext && this.onNext(rect)
        }, this.getRect.bind(this, el, step.placement))
    }else{
        const rect = this.getRect(el, step.placement)
        this.onNext && this.onNext(rect)
    }
}
  1. onNext函数

调用getRect函数获取到位置信息,将位置信息传递给onNext函数

export class TourHandler{
    public onNext: ((rectMap: RectMap)=>void)|null = null
}

组件创建TourHandler对象后需要覆盖onNext方法,来监听位置变化并修改响应式变量,从而重新渲染导航窗口的位置。

const tourHandler = new TourHandler(props.steps)

tourHandler.onNext = (rect)=>{
  maskRect.value = rect.maskRect
  infoWindowRect.value = rect.infoWindowRect
  targetRect.value = rect.targetRect
  arrowRect.value = rect.arrowRect
}
  1. getRect函数

类型:getRect: (el: HTMLElement, placement: Step['placement'] = 'bottom')=>RectMap

getRect函数通过当前步骤的el元素和placement位置,计算出目标元素位置和大小信息targetRect、窗口位置infoWindowRect、尖头位置信息arrowRect、遮罩层位置大小信息maskRect

getRect(el: HTMLElement, placement: Step['placement'] = 'bottom'): RectMap{
    let _targetRect = el.getBoundingClientRect()
    // 目标元素位置信息
    let targetRect: TargetRect = {
        width: _targetRect.width,
        height: _targetRect.height,
        left: _targetRect.left,
        right: window.innerWidth - _targetRect.left - _targetRect.width,
        top: _targetRect.top,
        bottom: window.innerHeight - _targetRect.top - _targetRect.height
    }
    // 箭头位置
    let arrowRect: ArrowRect = {
        top: 0,
        left: targetRect.left+(targetRect.width/2)-15,
        direction: placement,
        size: 30
    }
    const arrowCenter = arrowRect.left + arrowRect.size/2
    // 计算步骤窗口横向位置
    arrowCenter < this.infoWindowRect.width/2 ?
        (this.infoWindowRect.left = 0):
        (window.innerWidth-arrowCenter) < this.infoWindowRect.width/2 ?
            (this.infoWindowRect.left = window.innerWidth - this.infoWindowRect.width):
            (this.infoWindowRect.left = arrowCenter - this.infoWindowRect.width/2)


    // 计算步骤窗口纵向位置
    switch (arrowRect.direction){
        case "bottom":
            this.infoWindowRect.top = targetRect.top + targetRect.height + this.padding + arrowRect.size/2
            arrowRect.top = this.infoWindowRect.top - arrowRect.size/2
            break
        case "top":
            this.infoWindowRect.top = targetRect.top - arrowRect.size/2 - this.infoWindowRect.height
            arrowRect.top = targetRect.top - arrowRect.size
            break
    }

    const verify = (num: number)=>num<0?0:num
    // 遮罩层
    const maskRect: MaskRect = {
        center: {
            top: targetRect.top - this.padding,
            left: targetRect.left - this.padding,
            width: targetRect.width + this.padding*2,
            height: targetRect.height + this.padding*2
        },
        top: {
            top: 0,
            left: 0,
            width: window.innerWidth,
            height: verify(targetRect.top - this.padding)
        },
        bottom: {
            top: targetRect.top + targetRect.height + this.padding,
            left: 0,
            width: window.innerWidth,
            height: verify(targetRect.bottom - this.padding)
        },
        left: {
            top: targetRect.top - this.padding,
            left: 0,
            width: verify(targetRect.left - this.padding),
            height: targetRect.height + this.padding*2
        },
        right: {
            top: targetRect.top - this.padding,
            left: targetRect.left + targetRect.width + this.padding,
            width: verify(targetRect.right - this.padding),
            height: targetRect.height + this.padding*2
        }
    }


    return{
        infoWindowRect: this.infoWindowRect,
        targetRect,
        arrowRect,
        maskRect
    }
}

五、使用组件

<template>
  <!-- ...
    模版里随便写些内容,加上class或id
  ... -->
  <!-- 使用组件 -->
  <Tour ref="tourRef" v-model:show="showTour" :steps="steps">
      <!-- 使用插槽 -->
      <template #title="data">
        {{data.title}}
      </template>
  </Tour>
</template>
<script setup lang="ts">
import {onMounted, reactive, Ref, ref} from "vue";
import {Step} from "./Tour/src/TourHandler";
// 引入组件
import Tour from "./Tour";

// 控制导航是否展示
const showTour = ref(false)
// 导航组件实例
const tourRef = ref()

// 定义步骤信息
const steps: Step[] = [{
  el: ()=>document.getElementsByClassName('first-but')[0] as HTMLElement,
  beforeActive:(run)=>{
    // 有些情况dom元素不在窗口内,需要先滚动滚动条,然后调用run方法
    document.getElementsByClassName('tour-text')[0].scrollTo({top: 0})
    run()
  },
  title: '下一步',
  description: '点击这里可进行下一步操作'
},{
  el: ()=>document.getElementsByClassName('step-two')[0] as HTMLElement,
  placement: 'top',
  beforeActive:(run, getRect)=>{
    const {targetRect} = getRect()
    let scroll = targetRect.bottom<0? 10-targetRect.bottom:targetRect.bottom
    document.getElementsByClassName('tour-text')[0].scrollTo({top: scroll})
    run()
  },
  title: '第二部',
  description: '点击这个进入第二步'
},{
  el: ()=>document.getElementsByClassName('logo')[0] as HTMLElement,
},{
  el: ()=>document.getElementsByClassName('step-aaa')[0] as HTMLElement,
  beforeActive:(run, getRect)=>{
    const {targetRect} = getRect()
    let scroll = targetRect.top<0? -targetRect.top-10:targetRect.top
    document.getElementsByClassName('tour-text')[0].scrollTo({top: scroll})
    run()
  },
  title: '取消',
  description: '点击这里可取消操作'
},{
  el: ()=>document.getElementsByClassName('four-but')[0] as HTMLElement,
  beforeActive:(run)=>{
    document.getElementsByClassName('tour-text')[0].scrollTo({top: 0})
    run()
  }
},{
  el: ()=>document.getElementsByClassName('five-step')[0] as HTMLElement,
  beforeActive:(run)=>{
    document.getElementsByClassName('tour-text')[0].scrollTo({top: 0})
    run()
  }
},]

// 必须等页面挂在完成后才可以开始导航,否则获取不到dom节点
onMounted(()=>{
  // 打开导航可以使用v-model:show,也可以直接调用组件的open方法
  showTour.value = true
  // tourRef.value.open()
})
</script>

类型参考

Step类型

export interface Step{
    // el函数应该返回被导航的元素
    el: ()=>HTMLElement,
    // 标题
    title?: string,
    // 描述
    description?: string,
    // 窗口在被导航的元素的位置
    placement?: 'top'|'bottom'|'left'|'right',
    // 导航前的回调函数,可影响渲染的结果
    beforeActive?: (run: (rectMap?: RectMap)=>void, getRect: ()=>RectMap)=>void,
    // 导航结束后的回调函数
    afterActive?: ()=>void
}

RectMap类型

export interface RectMap{
    infoWindowRect: Rect,
    targetRect: TargetRect,
    arrowRect: ArrowRect,
    maskRect: MaskRect
}

export interface Rect{
    width: number,
    height: number,
    left: number,
    top: number
}

export interface TargetRect extends Rect{
    right: number,
    bottom: number
}

export interface ArrowRect extends Pick<TargetRect, 'top'|'left'>{
    direction: string,
    size: number
}

export interface MaskRect{
    center: Rect,
    top: Rect,
    bottom: Rect,
    left: Rect,
    right: Rect
}