实现新手引导组件

2,856 阅读4分钟

前言

最近在开发的时候遇到一个需求,涉及到新手引导组件。
效果类似这样: 动画11.gif 网上有可用的库,但是好奇心让我想试一下然后手动实现它。

准备工作

首先创建一个空白的页面,并在上面放置三个红色小方块用于表示新手指引的目标模块。
效果如下:

image.png

代码如下:

<template>
  <div class="home">
    <div class="box1"></div>
    <div class="box2"></div>
    <div class="box3"></div>
  </div>
</template>
<style scoped>
.home {
  background: #fff;
  font-size: 14px;
}
.home .box1 {
  position: absolute;
  left: 400px;
  top: 200px;
  height: 30px;
  width: 30px;
  background: red;
}
.home .box2 {
  position: absolute;
  left: 600px;
  top: 400px;
  height: 30px;
  width: 30px;
  background: red;
}
.home .box3 {
  position: absolute;
  left: 800px;
  top: 800px;
  height: 30px;
  width: 30px;
  background: red;
}
</style>

开发新手指引组件

一、效果分析

新手引导效果图如下:
动画11.gif
通过上图我们可以知道新手引导有以下特点:

  1. 新手引导会存在一个覆盖父级的蒙层,并且目标区域呈现透底的效果(即不受蒙层影响)。
  2. 新手引导存目标区域存在一个类似popover的弹窗。
  3. 新手引导可以存在多个目标区域,通过点击下一步依次展示。

二、实现思路

基于上面的效果分析,我们可以逐步的理清实现思路。

1. 如何实现全局的蒙层及目标区域透底的效果?

  • 方案一:通过巨大的div进行覆盖,类似弹窗背景的方式(如何实现透底是个问题)
  • 方案二:通过阴影,可以设置阴影的范围以及颜色达到蒙层的效果(由于阴影的方式目标区域是不会被影响的,直接就能实现透底

基于上述表述我们直接使用方案二通过阴影会更方便:
css代码如下

.guide-content {
    position: absolute;
    left: 600px;
    top: 400px;
    height: 30px;
    width: 30px;
    border-radius: 4px;
    box-shadow: rgba(0, 0, 0, 0.4) 0px 0px 0px 80000px;
  }

效果如下:(这个作为目标区域的中间红色div,可以看到颜色比较明亮,已实现蒙层及透底效果)

image.png

2. 如何实现目标区域的popover?

通过浏览elementUI,可以找到“el-popover”组件。
完全符合我们的使用要求
并且通过透传popover的属性我们可以实现弹窗位置等属性的自定义。 image.png

总结

(1)借助阴影实现蒙层透底效果
(2)通过elpopover组件实现指引功能

三、代码实现

一、创建新手指引组件

<template>
  <div
    v-if="visible"
    ref="novice"
    class="novice-main"
  >
    <div class="guide-content"></div>
  </div>
</template>

<script>
export default {
  name: "guide",
  props: {
    visible: {
      type: Boolean,
      default: false,
    },
  },
};
</script>
<style lang="scss" scoped>
.novice-main {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  overflow: hidden;
}
</style>

创建一个基于父级高宽的div,并在其中留下一个class 为guide-content的div,指向新手指引目标区域
效果如下:

image.png

二、设置目标区域的阴影效果

<template>
  <div
    v-if="visible"
    ref="novice"
    class="novice-main"
  >
    <div class="guide-content"></div> // 新手指引目标区域
  </div>
</template>
<style lang="scss" scoped>
.novice-main {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 2000;
  width: 100%;
  height: 100%;
  overflow: hidden; // 防止阴影超出范围
  .guide-content { // 设置其阴影范围及颜色
    position: absolute;
    left: 600px;
    top: 400px;
    height: 30px;
    width: 30px;
    border-radius: 4px;
    box-shadow: rgba(0, 0, 0, 0.4) 0px 0px 0px 80000px;
  }
}
</style>

给guide-content的div设置阴影样式,给其父元素补充overflow: hidden;防止其阴影超出范围
效果如下:

image.png

三、给目标区域补充popover组件

    // 这里补充了popover组件 !!!
    <el-popover placement="top-start" v-bind="$attrs" :value="showPopover">
      // popover细节
      <div class="popover">
        <div v-html="guideItem.content"></div> // 自定义popover内容
        <div class="popover-foot"> // 自定义popover内容底部
          <div>
            <p v-show="guideList.length > 1" class="fl">
              {{ guideIndex }}/{{ guideList.length }}
            </p>
          </div>
          <div>
            <el-button v-show="jump" type="text" size="small"
              >跳过</el-button
            >
            <el-button size="mini" type="primary" @click="next">{{
              guideIndex == guideList.length ? "下一步" : "我知道了"
            }}</el-button>
          </div>
        </div>
      </div>
      // 基于目标区域
      <div slot="reference" class="guide-content"></div>
    </el-popover>
   // popover相关样式
  .popover {
    &-body {
      margin-bottom: 16px;
    }
    &-foot {
      display: flex;
      justify-content: space-between;
      p {
        line-height: 32px;
        font-size: 14px;
      }
    }
  }

通过上面的html代码可以看到,在

<div slot="reference" class="guide-content"></div>

的基础上,补充了popover组件,并补充了一些popover细节
效果如下:

image.png

三、新手引导补充业务逻辑及实现(完整代码)

通过上述内容,我们已经成功完成了新手引导样式的基本实现,下面开始处理其业务逻辑

  • 新手引导所处的位置应该由其父元素决定,需要传入当前父元素的dom,将新手引导组件设置在父元素下
  • 新手引导通常有几个步骤点,因此我们需要定义一个数组 guideList 用于存放各个步骤点
  • 由于步骤点其包含popover内容,popover大小,新手引导区域的位置宽高等信息,因此 guideList 中每一项都需要包含诸如新手引导区域的位置宽高等信息
  • 为了完善新手引导的功能,每个步骤点支持传入回调函数,以及提供跳过功能

根据上方业务逻辑,完整的代码实现如下:
(为了方便分割代码,新手引导组件分为两个组件,二者为父子关系)

  1. noviceGuide.vue作为新手引导组件的挂载组件,业务侧通过调用appendNoviceGuide()方法,将新手引导组件挂载在父元素上
  2. guide.vue是新手引导组件,通过将这个组件挂载在父元素上实现新手引导功能

noviceGuide.vue (父组件)

<template>
  <guide
    id="guide"
    v-if="visible"
    :guideList="guideList"
    :parentsNode="parentsNode"
    :jump="jump"
    v-bind="$attrs"
    @nextGuide="nextGuide"
    @close="close"
  />
</template>

<script>
import guide from "./guide.vue";
export default {
  inheritAttrs: true,
  name: "noviceGuide",
  components: {
    guide,
  },
  data() {
    return {
      visible: false,
      guideList: [], //新手引导步骤像数组
      parentsNode: {}, // 父元素节点
      jump: false, // 是否支持跳过
    };
  },
  watch: {
    visible() {
      this.$emit("visibleChange");
    },
  },
  methods: {
    // 新手引导组件执行函数,将组件挂载到对应的父元素下
    appendNoviceGuide(data) {
      this.visible = true;
      this.parentsNode = data.parentsNode;
      this.jump = data.jump;
      this.guideList = data.guideList;
      this.$nextTick(() => {
        this.parentsNode.appendChild(this.$el);
      });
    },
    close() {
      this.visible = false;
      this.$emit("close");
    },
    nextGuide(val) {
      this.$emit("nextGuide", val);
    },
  },
};
</script>
<style scoped></style>

guide.vue(子组件)

<template>
  <div
    ref="novice"
    class="novice-main"
    :style="{ 'z-index': modalIndex == 0 ? -10 : modalIndex }"
  >
    <el-popover
      placement="top-start"
      v-bind="$attrs"
      trigger="manual"
      :value="showPopover"
    >
      <div class="popover">
        <div v-html="guideItem.content"></div>
        <div class="popover_foot">
          <div>
            <!-- <p v-show="guideList.length > 1">
              {{ guideIndex + 1 }}/{{ guideList.length }}
            </p> -->
          </div>
          <div>
            <el-button v-show="jump" type="text" @click="close" size="small"
              >跳过</el-button
            >
            <el-button size="mini" type="primary" @click="nextGuide">{{
              guideIndex == guideList.length - 1 || guideList.length == 1
                ? "我知道了"
                : "下一步"
            }}</el-button>
          </div>
        </div>
      </div>
      <!-- 新手引导目标区域 -->
      <div
        slot="reference"
        id="guideContent"
        class="guide-content"
        :style="`width: ${guideItem.width}px; height: ${guideItem.height}px; left: ${guideItem.left}px; top: ${guideItem.top}px;`"
      ></div>
    </el-popover>
  </div>
</template>

<script>
export default {
  inheritAttrs: true,
  name: "guide",
  data() {
    return {
      guideIndex: 0, // 当前展示的新手引导下标
      showPopover: false, // 是否显示popover
    };
  },
  props: {
    // 新手引导步骤列表
    guideList: {
      type: Array,
      default: function () {
        return [];
      },
    },
    // 是否正常跳过新手引导功能
    jump: { type: Boolean, default: false },
    // 新手引导z-index属性防止覆盖
    modalIndex: {
      type: Number,
      default: 2000,
    },
  },
  computed: {
    // 获取新手引导当前步骤
    guideItem() {
      return this.guideList[this.guideIndex] || {};
    },
  },
  mounted() {
    // 每次进来初始化数据
    this.$nextTick(() => {
      this.guideIndex = 0;
      this.showPopover = true;
    });
  },
  methods: {
    // 新手引导关闭事件
    close() {
      this.showPopover = false;
      this.guideIndex = 0;
      this.$emit("close");
    },
    // 新手引导下一步事件
    nextGuide() {
      // 如果当前是最后一步直接走关闭事件
      if (this.guideIndex == this.guideList.length - 1) {
        this.close();
      }
      // 如果当前不是就 新手引导下标+1,让新手引导指向下一个步骤
      else {
        this.guideIndex = this.guideIndex + 1;
        // 需要触发showPopover的change才能让elpopover位置实时更新
        this.showPopover = false;
        this.$nextTick(() => {
          this.showPopover = true;
        });
      }
      // 如果有回调则执行回调
      this.guideItem.callBack && this.guideItem.callBack();
       this.$emit("nextGuide", this.guideList[this.guideIndex]);
    },
  },
};
</script>
<style scoped>
.popover_body {
  margin-bottom: 16px;
}
.popover_foot {
  display: flex;
  justify-content: flex-end;
}
.popover_foot p {
  line-height: 32px;
  font-size: 14px;
}
</style>
<style lang="scss" scoped>
.novice-main {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 2000;
  width: 100%;
  height: 100%;
  overflow: hidden;
  .guide-content {
    position: absolute;
    left: 600px;
    top: 400px;
    height: 40px;
    width: 60px;
    border-radius: 4px;
    box-shadow: rgba(0, 0, 0, 0.4) 0px 0px 0px 80000px;
  }
}
</style>

然后业务使用时需先引入该组件

<template>
  <div ref="main" class="home">
      <div @click="showNoviceGuide()" class="box2"></div>
      <noviceGuide ref="guide" /> // 组件引入
  </div>
</template>

通过ref去调用新手引导组件的挂载函数,在函数中传入父元素等信息

showNoviceGuide() {
      this.$refs.guide.appendNoviceGuide({
        parentsNode: this.$refs.main,
        jump: false,
        guideList: [
          { width: 60, height: 40, left: 600, top: 400, content: "<div>第一步</div>" },
          { width: 60, height: 40, left: 600, top: 600, content: "<div>第二步</div>" },
          { width: 60, height: 40, left: 600, top: 800, content: "<div>第三步</div>" },
        ],
      });
    },

运行上方代码,效果如下所示

动画11.gif

最后通过上述方法我们就可以手写一个看起来很高大上的新手引导组件

新手引导组件文档

Props

参数名类型必填默认值描述
guideListobject✔️[]步骤列表数组,存放每个步骤的具体信息。 步骤项参数:width(目标区域宽),height(目标区域高), left(目标区域基于父元素left值),top(目标区域基于父元素top值),content(用于自定义popover内容的html字符串),callBack(当前步骤项点击下一步之后的回调函数)
jumpbooleanfalse是否支持跳过
modalIndexnumberfalse新手引导z-index属性防止被覆盖
parentsNodeobject✔️{}新手引导组件挂载的父节点

注:组件使用了透传,其他的props和elment的Popover props一致

Event

事件名回调参数描述
close关闭新手引导组件的回调
nextGuide当前点击的步骤项对象关闭新手引导组件的回调
visibleChange新手引导组件开启状态变更的回调

代码示例

<template>
  <div ref="main" class="home">
    <div @click="showNoviceGuide()" class="box2"></div>
    <noviceGuide ref="guide" width="300" />
  </div>
</template>

<script>
import noviceGuide from "../components/noviceGuide.vue";
export default {
  name: "HomeView",
  data() {
    return {};
  },
  components: {
    noviceGuide,
  },
  methods: {
    showNoviceGuide() {
      this.$refs.guide.appendNoviceGuide({
        parentsNode: this.$refs.main,
        jump: true,
        guideList: [
          {
            width: 60,
            height: 40,
            left: 600,
            top: 400,
            content: "<div>第一步</div>",
          },
          {
            width: 60,
            height: 40,
            left: 600,
            top: 600,
            content: "<div>第二步</div>",
          },
          {
            width: 60,
            height: 40,
            left: 600,
            top: 800,
            content: "<div>第三步</div>",
          },
        ],
      });
    },
  },
};
</script>

实际效果

动画11.gif