提示:前一阵做了“新手引导”功能,由于ui定制化需求太高,因此自己手动用原生js实现了一套新手引导。
文章目录
前言
此篇文章以介绍“新手引导”需求思路为主,由于实际实现的定制化程度太高,所以只贴出部分代码,有疑问的同学可以留言或私信。
一、新手引导实现前的思考
1.由于新手引导功能涉及到页面路由的跳转,因此引导的步骤状态要进行全局性的维护,因此对封装工具类(step.js)进行全局引入(App.vue)
。
2.由于class外部可能会与引导的状态进行 访问/操作/通信等需求 ,因此对外部暴露新手引导的实例step
并Vue.observable(step)
使得实例为可响应。(非高度定制化需求不建议这么做,我是图省事,其实只需要对外暴露需要的操作方法即可)
3.新手引导需要的元素为“高亮区”、“上一步”、“下一步”、“结束引导”、其他(这里的其他是包括引导箭头等定制化的ui元素,和这些一个道理)。
4.暂时采用数组对象的形式去配置每一步操作([{},{}]),这样就可以在后期改动的时候更快的去配置增删改我们的每一步。
5.每个需要引导的区域都进行id标识,增加全局的step.scss。
6.高亮区实现的问题:
我所用的页面为方案2,方案1带来的延迟感官影响会更大
方案1:z-index设计:不高亮区域:0、蒙版:1、生成的高亮div:2、目标高亮操作区域:3
采用生成全局div,改变各种dom的层级和等位去把他定位到目标元素区域(当窗口变化、滚动等情况后,需要移动创建好的元素,有一定的延迟,加上移动的动画会好一点,Driver.js就是这样实现的)
方案2:z-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