原生js实现新手引导功能(vue项目)

提示:前一阵做了“新手引导”功能,由于ui定制化需求太高,因此自己手动用原生js实现了一套新手引导。


前言

此篇文章以介绍“新手引导”需求思路为主,由于实际实现的定制化程度太高,所以只贴出部分代码,有疑问的同学可以留言或私信。


一、新手引导实现前的思考

1.由于新手引导功能涉及到页面路由的跳转,因此引导的步骤状态要进行全局性的维护,因此对封装工具类(step.js)进行全局引入(App.vue)

2.由于class外部可能会与引导的状态进行 访问/操作/通信等需求 ,因此对外部暴露新手引导的实例stepVue.observable(step)使得实例为可响应。(非高度定制化需求不建议这么做,我是图省事,其实只需要对外暴露需要的操作方法即可)

3.新手引导需要的元素为“高亮区”、“上一步”、“下一步”、“结束引导”、其他(这里的其他是包括引导箭头等定制化的ui元素,和这些一个道理)。

4.暂时采用数组对象的形式去配置每一步操作([{},{}]),这样就可以在后期改动的时候更快的去配置增删改我们的每一步。

5.每个需要引导的区域都进行id标识,增加全局的step.scss。

6.高亮区实现的问题:
我所用的页面为方案2,方案1带来的延迟感官影响会更大
方案1z-index设计:不高亮区域:0、蒙版:1、生成的高亮div:2、目标高亮操作区域:3
  采用生成全局div,改变各种dom的层级和等位去把他定位到目标元素区域(当窗口变化、滚动等情况后,需要移动创建好的元素,有一定的延迟,加上移动的动画会好一点,Driver.js就是这样实现的)

方案2z-index设计:不高亮区域:0、蒙版:1、目标高亮操作区域:3
  针对目标区域增加css仅仅设置层级,若是需要四周留白则设置padding(这种情况不会造成方法1中描述的副作用,高亮区和操作区为一体的,但是padding会影响布局)

二、新手引导实现具体步骤

下面来具体的详解如何实现新手引导功能

1.传入的配置参数设计

//step.js
class Step {
    
    
	constructor(step){
    
    
	    this.dom = null//记录引导区的dom
	    this.size = null//记录引导区dom的size,避免频繁的获取dom和调用计算size的方法
	    this.nowIndex = 0//记录步骤索引
	    this.step = step//记录传入的配置项
	 ...//此处存放所需要用到的变量
	}
}
//实例化类并传入配置参数(下例为第二步引导)
const step = new Step([
  {
    
    
    id: 'two',
    previous: {
    
    //上一步按钮配置
      text: 'previous',//按钮文字
      click () {
    
    //点击上一步以后执行的回调方法
        this.vm.$router.go(-1)
        this.previous(100)//class提供的previous方法
      }
    },
    // 下一步按钮配置
    next: {
    
    
      text: 'next',
      click () {
    
    
        document.getElementById('java').click()
      }
    },
    // 结束引导按钮配置
    skip: {
    
    
      text: 'skip',//按钮文字
      domClass: ['sept-ignore']//按钮的自定义class
    },
    point: {
    
    //引导小手的配置
      pointerId: 'pointer2', // 小手的id
      // pointerClass: 'language-pointer', // 小手的class
      pointerClass: 'instance-pointer', // 小手的class
      pointClass: '.point2-dom'// 小手需要指向dom的class
    },
    tips: [//引导提示的配置,此处采用数组是考虑到一个区域可能会有多个不同的提示
      {
    
    
      	//提示内容(由于内容中有关键词重点,所以采用以下配置方法生成提示dom的时候用span拼接)
        text: [
          {
    
    
            content: '"选择被保护应用采用的',
            class: ''//每段内容对应的class
          },
          {
    
    
            content: '开发语言',
            class: 'tips-important'
          },
          {
    
    
            content: '"',
            class: ''
          }
        ],
        //相对于引导区便宜的距离
        move: {
    
     left: '96px', top: '220px' },
        //提示与引导区相连的箭头
        arrow: {
    
    
          id: 'clickInstance',
          //引用的箭头图片
          url: require('@/assets/images/step/arrow10.svg'),
          //相对于引导区偏移的距离
          move: {
    
     left: '-25px', top: '75px' }
        }
      }
    ]
  },
])

2.新建step.js,定义相关的方法(previous )

方法1:点击上一步

  previous (time) {
    
    //time 延迟执行时间
     this.clearDom()//清空dom
     this.nowIndex = this.nowIndex - 1//步骤索引-1
     const item = this.step[this.nowIndex]//获取当前步骤的配置信息
     setTimeout(() => {
    
    
       this.vm.$nextTick(() => {
    
    
         this.dom = document.getElementById(item.id || '')
         /*
	mustRunStep方法用户判断是否满足执行条件,
	若是不满足则mustRunStep方法内会轮询重复执行对应的方法,
	每个10ms执行一次(不满足条件的情况有dom未来渲染出来等情况)       
	最后都满足执行条件后会调用rundom,rundom在后面有进行过说明
	参数1:当前步骤参数
	参数2:当前dom
	参数3:当前操作的步骤(previous:上一步、next:下一步)     
         */
         this.mustRunStep(item, this.dom, 'previous')
       })
     }, time ?? 100)
   }

方法2:点击下一步(next )

类似previous

  next (time, obj) {
    
    
      this.clearDom()
      this.nowIndex = this.nowIndex + 1
      const item = this.step[this.nowIndex]
      setTimeout(() => {
    
    
        this.vm.$nextTick(() => {
    
    
          this.dom = document.getElementById(item.id || '')
          this.mustRunStep(item, this.dom, 'next', obj)
        })
      }, time ?? 100)
  }

方法3:点击结束引导(skip )

  skip () {
    
    
    this.nowIndex = 0
    this.clearDom()
    const maskDom = document.getElementById('mask')
    if (maskDom) {
    
    //移除蒙版
      maskDom.remove()
    }
  }

方法4:开启新手引导(start )

  start (vm) {
    
    
    this.nowIndex = 0//初始化步骤索引
    this.vm = vm//外部的this
    this.resizeHandler()// 监听窗口变化
    this.scrollHandler()// 监听滚动变化
    this.domResizeHandler()// 监听目标dom变化
    this.maskScroll()//监听鼠标在蒙版的滚动
    this.initMustStep()//初始化一些东西
    this.runDom()//生成新手引导相关dom方法    
    /*
    rundom会在每次上一步、下一步、开始引导等情况下调用,在mustRunStep方法中最后也会调用rundom
    runDom:
    this.clearDom()
    this.dom = document.getElementById(item.id || '')//记录dom
    this.size = this.dom.getBoundingClientRect()//记录尺寸
    */    
    
  }

方法5:创建操作按钮(createButton )

  /*
  为元素创建按钮,
    text:按钮文字,
    click:外部传入的按钮点击方法
    move:{left:'xx',top:'xx'}
      left:当前位置向右平移距离
      top:当前位置向下平移距离
    domClass(Array):添加的样式(默认.step-button)
*/
  createButton ({
     
      text, click, move, domClass = [] }) {
    
    
    if (this.dom) {
    
    
      const textWord = {
    
    
        previous: '上一步',
        next: '下一步',
        skip: this.nowIndex === this.step.length - 1 ? '开始体验' : '结束引导'
      }
      // 创建下一步
      const div = document.createElement('div')
      // 为按钮绑定事件
      div.onclick = () => {
    
    
        if (click) {
    
    
          click.call(this)// 采用call是为了执行用户自定义事件
        }
        if (text === 'skip') {
    
    //如果是结束引导
          this.vm.$nextTick(() => {
    
    
            this.skip()// 执行默认事件(previous,next,skip)
          })
        }
      }
      div.setAttribute('class', `step step-button ${
      
      domClass.join(' ')}`)
      div.setAttribute('id', text)
      const domStyle = {
    
     // 样式
        top: `${
      
      move.top + 20}px`,
        left: `${
      
      move.left}px`
      }
      Object.entries(domStyle).forEach(item => {
    
    
        div.style[item[0]] = item[1]
      })
      div.innerHTML = textWord[text]
      document.body.appendChild(div)// 添加到body节点中
    }
  }

方法6:创建提示框/提示箭头等元素(此步骤不重要,仅为特殊定制)(createarrow )

 /*
  创建箭头指向
    id:箭头id
    src:箭头图片引用位置
    dom:指向元素dom
    move:位移
*/
  createarrow ({
     
      id, url, move, rotate = 0, domClass = [] }) {
    
    
    const arrowDom = document.getElementById(id)
    if (this.dom && !arrowDom) {
    
    
      const div = document.createElement('img')
      div.setAttribute('class', `step step-arrow ${
      
      domClass.join(' ')}`)
      div.setAttribute('id', id)
      div.setAttribute('src', url)
      const domStyle = {
    
     // 样式
        top: `${
      
      this.size.top - 10}px`,
        left: `${
      
      this.size.left - 10}px`,
        transform: `translate(${
      
      move.left},${
      
      Number(move.top.replace('px', '')) + this.middleMove()}px) rotate(${
      
      rotate}deg)`//middleMove方法是偏移量的计算,无关紧要
      }
      Object.entries(domStyle).forEach(item => {
    
    
        div.style[item[0]] = item[1]
      })
      document.body.appendChild(div)// 添加到body节点中
    }
  }

方法7:重新定位按钮位置(resetPosition )

  // 重新定位元素位置
  resetPosition () {
    
    
    if (!this.dom) {
    
     return }
    // 当前步骤
    const item = this.step[this.nowIndex]
    // 重新定位提示框
    const tipsDom = document.querySelectorAll('.step-tips')
    // 重新定位箭头
    const arrowDom = document.querySelectorAll('.step-arrow')
    // 重新定位小手
    const pointerDom = document.querySelector(`#${
      
      item.point.pointerId}`)// 小手dom
    const pointDom = document.querySelector(item.point.pointClass)// 被指向的class
    if (pointerDom && pointDom) {
    
    
      // 获取被指向dom尺寸
      const pointDomSize = pointDom.getBoundingClientRect()
      pointerDom.style.top = `${
      
      pointDomSize.top + pointDomSize.height}px`
      pointerDom.style.left = `${
      
      pointDomSize.left + pointDomSize.width / 2 - 13}px`
    }
    // 重新定位按钮
    this.size = this.dom.getBoundingClientRect()
    const {
    
     top, leftPrevious, leftNext, leftSkip } = this.moveButon(item)
    const skipDom = document.getElementById('skip')
    const nextDom = document.getElementById('next')
    const previousDom = document.getElementById('previous')
    if (skipDom) {
    
    
      skipDom.style.top = top + 20 + 'px'
      skipDom.style.left = leftSkip + 'px'
    }
    if (nextDom) {
    
    
      nextDom.style.top = top + 20 + 'px'
      nextDom.style.left = leftNext + 'px'
    }
    if (previousDom) {
    
    
      previousDom.style.top = top + 20 + 'px'
      previousDom.style.left = leftPrevious + 'px'
    }
    const topAll = this.dom.getBoundingClientRect().top
    const leftAll = this.dom.getBoundingClientRect().left
    const array = [...tipsDom, ...arrowDom]
    array.forEach(dom => {
    
    
      if (dom) {
    
    
        dom.style.top = `${
      
      topAll - 10}px`
        dom.style.left = `${
      
      leftAll - 10}px`
      }
    })
  }

方法8:监听窗口变化(resizeHandler )

  // 监听窗口变化进行自适应
  resizeHandler () {
    
    
    addListener(document.body, utils.debounce(() => {
    
    
      this.resetPosition()//重新定位元素
    }, 100))
  }

方法9:监听引导区dom变化(domResizeHandler )

  // 监听目标dom变化
  domResizeHandler () {
    
    
    addListener(document.getElementById(this.step[this.nowIndex].id), utils.debounce(() => {
    
    
      this.runDom()
    }, 100))
  }

方法10:监听滚动变化(scrollHandler、maskScroll )

鼠标在蒙版滚动的时候,要把滚动映射到页面上,这解决了当屏幕过小或元素过长时,有些引导元素竖直方向显示不全的问题

  // 监听滚动变化进行自适应
  scrollHandler () {
    
    
    document.querySelector('.main').addEventListener('scroll', utils.debounce(() => {
    
    
      this.resetPosition()
    }, 10))
  }
  // 鼠标滚动
  onMouseWheel (ev) {
    
    
    const event = ev || window.event
    const mainDom = document.querySelector('.main')
    if (!mainDom) {
    
     return }
    const mainTop = mainDom.scrollTop
    let down = true
    down = event.wheelDelta ? event.wheelDelta < 0 : event.detail > 0
    if (down) {
    
    
      mainDom.scrollTo({
    
    
        top: mainTop + 200,
        behavior: 'smooth'
      })
    } else {
    
    
      mainDom.scrollTo({
    
    
        top: mainTop - 200,
        behavior: 'smooth'
      })
    }
    if (event.preventDefault) {
    
    
      // 阻止默认事件
      event.preventDefault()
    }
    return false
  }

  // 监听在蒙版的滚动
  maskScroll () {
    
    
    const box = document.getElementById('mask')
    this.addEvent(box, 'mousewheel', this.onMouseWheel)
    this.addEvent(box, 'DOMMouseScroll', this.onMouseWheel)
  }  
    // 增加监听事件
  addEvent (obj, xEvent, fn) {
    
    
    if (obj.attachEvent) {
    
    
      obj.attachEvent('on' + xEvent, fn)
    } else {
    
    
      obj.addEventListener(xEvent, fn, false)
    }
  }
  

3.对外暴露的方法

//开始方法
export function start () {
    
    
  return step.start(...arguments)
}
//下一步方法
export function next (time, object) {
    
    
  if (store.state.login.userInfo.firstLogin === 0) {
    
    
    return step.next(time, object)
  }
}
//结束引导方法
export function skip () {
    
    
  return step.skip()
}
//销毁各种监听的方法
export function destroy () {
    
    
  return step.destroy()
}
//class的实例
export const state = Vue.observable(step)

4.涉及到的样式

仅供参考,重点关注z-index层级、position定位等关键元素的样式

//页面蒙版
.mask{
    
    
  position: fixed;
  z-index: 999997;
  top: 0;
  left: 0;
  bottom: 0;
  right: 0;
  background-color: rgba(0, 0, 0, 0.7);  
}
// 新手引导可操作元素样式
.step-item{
    
    
  position: relative !important;
  background-color:var(--card-primary-second);
  transform-style: preserve-3d;
  &:after {
    
    
    content: "";
    position: absolute;
    width: calc(100% + 30px);
    height: calc(100% + 30px);
    left: -1px;
    top: -1px;
    border-radius: 3px;
    background-color: #ffffff;
    z-index: 10;
    transform: translate3d(-14px, -14px, -1px);
  }
}

// 新手引导可操作区域样式
.step-block{
    
    
  position: fixed;
  background-color: white;
  border-radius: 3px;
  z-index: 999999 !important;
  transition: all 0.2s;
}
// 新手引导操作按钮
.step-button{
    
    
  position: fixed;
  z-index: 1000001 !important;
  width: 70px;
  height: 30px;
  line-height:30px;
  border-radius: 4px;
  border: 1px solid #DCDEE0;
  color:#ffffff;
  text-align: center;
  cursor: pointer;
  box-sizing: border-box;
}
// 新手引导结束引导按钮
.step-ignore{
    
    
  border: none;
  width: auto;
  height: auto;
}
//新手引导提示样式-虚线圈
.step-tips{
    
    
  position: fixed;
  z-index: 1000000 !important;  
  width: 220px;
  height: 132px;
  background-color:transparent;
  border: 1px dashed #ffffff;
  border-radius: 100%;
  display:flex;
  align-items: center;
  justify-content: center;
  color:#333333;
}

三.新手引导使用

使用时仅需要使用js暴露的方法以及对需要引导的html元素进行如下简单的配置,就可以进行使用了。

html部分(vue写法)

<!-- 
id为上文配置项中,对应步骤为three的操作
stepId为js获取的当前全局的操作步骤,是通过class对外暴露的方法获取的,
	通过次来判断step-item是否显示,避免非引导状态下step-item的样式的影响,
step-item为整个高亮区的class
-->
<div  :class="[stepId==='three'?'step-item':'']" id="three"><!--高亮的操作区-->
	<!-- 操作的元素-->
	<span>添加按钮</span>	
</div>

js操作方法

//start方法建议在app.vue中进行使用
import {
    
    start,destroy,skip,next,previous} from '@/utils/step.js'
//方法内的具体参数见上文
//开启引导
start(this)
next()
previous()
//结束引导
skip(){
    
    
  const maskDom=document.getElementById('mask')
  if(maskDom){
    
    
    maskDom.remove()
  }
}
//销毁监听
destroy()

总结

以上是原生js实现的新手引导,代码有很多很多还需要进行优化的点,由于时间问题就没有继续进行优化。有同学有遇到问题或者是不懂的地方可以进行提问。若是定制化程度不高的话,driver.js,或者是antd的组件是更好的推荐。

有不懂的可以及时留言或者联系微信a13716670638

猜你喜欢

转载自blog.csdn.net/weixin_43695002/article/details/128717017