有些系统在首次进入时需要引导用户怎样操作,效果参考ant.design的组件 ant.design/components/… ,但是这个组件只有react版本,却没有vue3的版本,所以打算自己实现一个。
已上传npm:npm install -s y-tour
封装Tour组件
分析下导航窗口,大致有以下几个部分: 灰色遮罩层、箭头、标题、内容、当前步骤、下一步按钮、关闭按钮
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类
- 创建一个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函数即可实现对渲染的结果的影响
- 还要有以下基本的方法 开始、停止、上一步、下一步、跳转到指定步数
// 开始
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&¤t<this.steps.length-1){
this.current = current
this.run()
}
}
start和close时将当前步骤current重置为0,next、last、toStep方法在校验后修改current,并调用run方法
- 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)
}
}
- 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
}
- 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
}