H5/小程序通用新手引导组件实现指南

背景

在做微信小程序 (同时跨端输出H5 )的时候,涉及到新手引导功能的实现,即在用户第一次访问页面的时候,告诉用户页面有哪些主要的功能,用户点击屏幕可以进入下一步,同时会在蒙层中添加一些描述文案,如下图所示:

以上图片截取自 2024/03/01 滴滴司机招募,活动内容及奖励金额可能有变更,以官方为准

实现效果:

以上gif录制于 2024/03/01 滴滴司机招募,活动内容及奖励金额可能有变更,以官方为准

市面上有很多专业的新手引导第三方库,如:

这些库都能很方便的实现上述功能,遗憾的是,小程序是没有dom的,所以以上开源方案我们都没有办法使用。

技术方案

既然没办法通过开源方案做,那我们就需要实现一套可以跨平台的移动端新手引导方案。

设计模式

跨端意味着我们的设计是抽象 的,抽象意味着我们需要制定一套接口规范(DIP ),不涉及具体平台的逻辑实现。因此我们需要抹平小程序和H5 的差异,找出二者的共同点------"基于JS实现逻辑层"

视觉层设计

参照intro.js,采用状态机的形式完成新手引导。组件视图层结构设计如下:

  • div.intro (z-index: 99999)
    • div.intro-overlay (z-index: 1)
    • div.intro-helperLayer (z-index: 2)
    • div.intro-tooltipReferenceLayer (z-index: 3)
      • div.content

其中:

  • intro-overlay负责给屏幕添加一层遮罩,使屏幕下的元素不可操作
  • intro-helperLayer负责展示Step设定的高亮区域
  • intro-tooltipReferenceLayer负责绘制操作区域,也就是描述文案和按钮等

逻辑层设计

观察效果图,得出新手引导运行流程:

  1. 初始化展示第一步
  2. 点击按钮,进入下一步的展示,同时页面向上滚动到合适位置
  3. 重复第二步

这个过程很类似咱们的迭代器(Iterator),定义如下:

typescript 复制代码
interface Iterable {
  [Symbol.iterator]() : Iterator,
}

interface Iterator {
  next(value?: any) : IterationResult,
}

interface IterationResult {
  value: any,
  done: boolean,
}

因此,咱们的新手引导也采用同样的设计思路,具体的定义如下:

typescript 复制代码
import { DefineComponent } from 'vue';

// 定义单个步骤数据结构
interface Step {
  desc?: string // 步骤描述
  icon?: string // 左侧图标
  scrollTop: number // 屏幕需要向上折叠滚动的距离
  style: { // 描述高亮区域
    width: string
    height: string
    left: string
    top: string
  }
}

// 定义 props 类型
interface Props {
  steps: Step[] // 一共有几步
}

// 定义 emits 类型
interface Emits {
  next: (done: boolean, currentStep: Step) => void; // 每一次点击都会执行
}

// 定义 data 类型
interface Data {
  done: boolean // 标志引导是否完成
  currentStep: Step // 当前正在展示的步骤
  currentStepIndex: number // 当前正在展示的步骤下标
  style: CSSStyleDeclaration // 
}

// 定义 methods 类型
interface Methods {
  next: () => void
}

// 使用 DefineComponent 定义组件类型
type Intro = DefineComponent<Props, Emits, Data & Methods>

由于咱们的屏幕显示内容有限,同时可能会展示到第二屏第三屏 的内容,因此设计了一个scrollTop属性,在执行到下一步的时候,滚动屏幕(H5 通过window.scrollTo实现)到合适位置,这样就能够展示任意位置的内容。

核心逻辑很简单:

  1. 监听用户点击事件
  2. 执行事件回调
  3. 计算当前步骤的样式
  4. 判断是否完成,如果完成则将屏幕滚动到开始位置
  5. 销毁dom,结束新手引导

使用方法

其中getResponsiveValue是计算需要响应式展示的尺寸,比如设计稿为750,屏幕实际宽度375,那么以下数据都需要除以2并向下取整。

typescript 复制代码
const done = ref(true)
const steps = [
  {
    desc: '试试左右滑动卡片,可以查看更多奖励',
    scrollTop: 0,
    icon: 'https://gift-pypu-cdn.didistatic.com/static/driver_miniprogram/do1_hANKc5dtbGzSrNVUBwaM',
    style: {
      width: `${getResponsiveValue(690)}px`,
      height: `${getResponsiveValue(440)}px`,
      left: `${getResponsiveValue(30)}px`,
      top: `${getResponsiveValue(115)}px`
    }
  }
]
const handleNext = (params) => {
  // do something...
}

具体代码

小程序

小程序部分基于MPX框架实现(同时支持跨端输出Web):

typescript 复制代码
<template>
  <view
    wx:if="{{!done && steps.length}}"
    class="cube-intro"
    bindtap="next"
    catchtouchmove="catchtouchmove"
  >
    <view class="cube-intro-overlay" />
    <view class="cube-intro-helperLayer" wx:style="{{ currentStep.style }}" />
    <view
      class="cube-intro-tooltipReferenceLayer"
      wx:style="{{ style }}"
    >
      <view class="desc-box" wx:class="{{ { 'has-icon': currentStep.icon } }}">
        <view class="image" wx:if="{{currentStep.icon}}" wx:style="background-image: url({{currentStep.icon}})" />
        <text>{{currentStep.desc}}</text>
      </view>
      <button class="next-btn">知道了</button>
    </view>
  </view>
</template>

<script lang="ts">
import mpx, { createComponent, computed, ref } from '@mpxjs/core'

interface Step {
  desc: string
  scrollTop: number
  icon?: string // 图片链接
  style: {
    width: string
    height: string
    left: string
    top: string
  }
}

createComponent({
  properties: {
    steps: {
      type: Array,
      value: [] as Step[]
    }
  },
  setup(props, { triggerEvent }) {
    const done = ref(false)
    const currentStepIndex = ref(0)
    const currentStep = computed(() => props.steps[currentStepIndex.value] ?? {})
    const next = () => {
      let currentStep
      if (!done.value) {
        currentStepIndex.value++
      }
      done.value = currentStepIndex.value === props.steps.length

      if (!done.value) {
        currentStep = props.steps[currentStepIndex.value]
        mpx.pageScrollTo({
          scrollTop: parseInt(currentStep.scrollTop),
          duration: 300
        })
      } else {
        mpx.pageScrollTo({
          scrollTop: 0,
          duration: 300
        })
        if (__mpx_mode__ !== 'wx') {
          document.body.style.overflow = 'unset'
        }
      }
      triggerEvent('next', { done: done.value, currentStep })
    }
    const style = computed(() => {
      const step = currentStep.value as Step
      return { ...step.style, transform: 'translateY(' + step.style.height + ')' }
    })
    const catchtouchmove = () => {}
    if (__mpx_mode__ !== 'wx') {
      document.body.style.overflow = 'hidden'
    }
    return {
      next,
      done,
      style,
      currentStep,
      catchtouchmove
    }
  }
})
</script>

<style lang="stylus">
.cube-intro
  position: absolute
  top: 0
  left: 0
  z-index: 99999
  width: 100%
  height: 100%
  .cube-intro-overlay
    position: absolute
    top: 0
    left: 0
    z-index: 1
    width: 100%
    height: 100%
    opacity: 0
  .cube-intro-helperLayer
    position: fixed
    z-index: 2
    border-radius: 25rpx
    box-shadow: rgba(33, 33, 33, .8) 0rpx 0rpx 1rpx 1rpx, rgba(0, 0, 0, .6) 0rpx 0rpx 0rpx 5000rpx
  .cube-intro-tooltipReferenceLayer
    position: fixed
    z-index: 3
    display: flex
    flex-direction: column
    align-items: center
    justify-content: center
    height: unset !important
    margin-top: 100rpx
    &::before
      position: absolute
      top: 0
      left: 50%
      width: 2rpx
      height: 100rpx
      background: #fff
      transform: translate(calc(-50% - 2rpx), -100%)
      content: ''
    .desc-box
      position: relative
      width: 456rpx
      padding: 11rpx 0 11rpx 0
      padding-left: 23rpx
      color: #fff
      font-weight: 400
      font-size: 34rpx
      line-height: 46rpx
      border: 2rpx solid rgba(255, 255, 255, 1)
      border-radius: 16rpx
      box-shadow: 0rpx 0rpx 9rpx 0rpx rgba(200, 230, 255, .55)
      transform: translateX(-3rpx)
      text
        display: inline-block
        width: 100%
      &.has-icon
        display: flex
        align-items: center
        border: none
        box-shadow: none
        &::before
          display: none
        .image
          flex-shrink: 0
          width: 82rpx
          height: 82rpx
          margin-right: 16rpx
          background-repeat: no-repeat
          background-size: 100% 100%
      &::before
        position: absolute
        top: -5rpx
        left: 126rpx
        width: 9rpx
        height: 9rpx
        background: #fff
        border-radius: 50%
        box-shadow: 0rpx 0rpx 14rpx 7rpx rgba(255, 255, 255, .5)
        content: ''
    .next-btn
      width: auto
      height: 60rpx
      margin-top: 30rpx
      padding: 7rpx 44rpx
      color: #fff
      font-weight: 600
      font-size: 32rpx
      line-height: 45rpx
      background: rgba(255, 255, 255, 0)
      border: 1rpx solid rgba(255, 255, 255, 1)
      border-radius: 33rpx
</style>

<script type="application/json">
{
  "component": true,
  "styleIsolation": "apply-shared"
}
</script>

H5代码

H5部分基于Vue 2.7.14实现:

typescript 复制代码
<template>
  <div
    v-if="!done && steps.length"
    class="cube-intro"
    @click="next"
  >
    <div class="cube-intro-overlay" />
    <div class="cube-intro-helperLayer" :style="currentStep.style" />
    <div
      class="cube-intro-tooltipReferenceLayer"
      :style="style"
    >
      <div class="desc-box" :class="{ 'has-icon': currentStep.icon }">
        <div class="image" v-if="currentStep.icon" :style="{ 'background-image': `url(${currentStep.icon})` }" />
        <span>{{currentStep.desc}}</span>
      </div>
      <button class="next-btn">知道了</button>
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent, PropType, ref, computed } from 'vue'

interface Step {
  desc: string
  scrollTop: number
  icon?: string // 图片链接
  style: {
    width: string
    height: string
    left: string
    top: string
  }
}

export default defineComponent({
  props: {
    steps: {
      type: Array as PropType<Step[]>,
      default: () => ([])
    }
  },
  setup(props, { emit }) {
    const done = ref(false)
    const currentStepIndex = ref(0)
    const currentStep = computed(() => props.steps[currentStepIndex.value] ?? {})
    const next = () => {
      let currentStep
      if (!done.value) {
        currentStepIndex.value++
      }
      done.value = currentStepIndex.value === props.steps.length

      if (!done.value) {
        currentStep = props.steps[currentStepIndex.value]
        scrollTo({
          top: parseInt(currentStep.scrollTop),
          behavior: 'smooth'
        })
      } else {
        scrollTo({
          top: 0,
          behavior: 'smooth'
        })
        document.body.style.overflow = 'unset'
      }
      emit('next', { done: done.value, currentStep })
    }
    const style = computed(() => {
      const step = currentStep.value as Step
      return { ...step.style, transform: 'translateY(' + step.style.height + ')' }
    })
    document.body.style.overflow = 'hidden'
    return {
      next,
      done,
      style,
      currentStep
    }
  }
})
</script>

<style lang="stylus">
.cube-intro
  position: absolute
  top: 0
  left: 0
  z-index: 99999
  width: 100%
  height: 100%
  .cube-intro-overlay
    position: absolute
    top: 0
    left: 0
    z-index: 1
    width: 100%
    height: 100%
    opacity: 0
  .cube-intro-helperLayer
    position: fixed
    z-index: 2
    border-radius: 25px
    box-shadow: rgba(33, 33, 33, .8) 0px 0px 1px 1px, rgba(0, 0, 0, .8) 0px 0px 0px 5000px
  .cube-intro-tooltipReferenceLayer
    position: fixed
    z-index: 3
    display: flex
    flex-direction: column
    align-items: center
    justify-content: center
    height: unset !important
    margin-top: 40px
    .desc-box
      position: relative
      width: 456px
      padding: 11px 0 11px 0
      padding-left: 23px
      color: #fff
      font-weight: 600
      font-size: 32px
      line-height: 45px
      white-space: pre-wrap
      text-align: center
      text
        display: inline-block
        width: 100%
      &.has-icon
        display: flex
        align-items: center
        border: none
        box-shadow: none
        &::before
          display: none
        .image
          flex-shrink: 0
          width: 82px
          height: 82px
          margin-right: 16px
          background-repeat: no-repeat
          background-size: 100% 100%
    .next-btn
      width: auto
      height: auto
      margin-top: 40px
      padding: 16px 50px
      color: #fff
      font-weight: 600
      font-size: 32px
      background: rgba(255, 255, 255, 0)
      border: 1px solid rgba(255, 255, 255, 1)
      border-radius: 40px
</style>
相关推荐
挣扎与觉醒中的技术人4 分钟前
【技术干货】三大常见网络攻击类型详解:DDoS/XSS/中间人攻击,原理、危害及防御方案
前端·网络·ddos·xss
zeijiershuai8 分钟前
Vue框架
前端·javascript·vue.js
写完这行代码打球去10 分钟前
没有与此调用匹配的重载
前端·javascript·vue.js
华科云商xiao徐10 分钟前
使用CPR库编写的爬虫程序
前端
狂炫一碗大米饭13 分钟前
Event Loop事件循环机制,那是什么事件?又是怎么循环呢?
前端·javascript·面试
IT、木易14 分钟前
大白话Vue Router 中路由守卫(全局守卫、路由独享守卫、组件内守卫)的种类及应用场景
前端·javascript·vue.js
顾林海15 分钟前
JavaScript 变量与常量全面解析
前端·javascript
程序员小续15 分钟前
React 组件库:跨版本兼容的解决方案!
前端·react.js·面试
乐坏小陈16 分钟前
2025 年你希望用到的现代 JavaScript 模式 【转载】
前端·javascript
生在地上要上天16 分钟前
从600行"状态地狱"到可维护策略模式:一次列表操作限制重构实践
前端