Vite 构建 Vue3 组件库之路: 行为验证码-滑块组件

916 阅读5分钟

引言

在现代互联网应用中,用户身份验证是一个至关重要的环节,尤其是在登录注册、金融交易和社交网络等场景中。为了防止用户频繁请求或恶意通过脚本自动化攻击,导致后端服务器压力过大,行为验证码成为了一种常见且有效的解决方案。行为验证码通过要求用户完成特定的操作来验证身份,从而降低请求频率,提高系统安全性,同时优化用户体验,避免了传统验证码输入的复杂性。

示例

image.png

需求分析

为了实现一个功能完善的滑块验证组件,主要分析如下:

  1. 滑块组件的DOM结构
    • 滑块组件的容器-轨道:作为滑块移动的轨道,承载整个滑块验证组件的布局.
    • 滑块:用户需要拖动的滑块元素,其移动距离将决定验证是否成功.
    • 滑动轨迹:用于展示滑块移动的轨迹,增强视觉效果,提升用户体验.
    • 组件提示语展示:显示提示信息,指导用户进行操作,如“请向右滑动”、“验证成功”等.
  2. 滑块组件的属性
    • 是否可滑动:控制滑块是否可以被拖动,适用于需要在特定条件下禁用滑块的场景(例如,在登录注册场景中,如果前置输入未完成,则不允许滑动)
    • 提示语:自定义提示信息,根据不同的验证状态或业务需求展示不同的提示语,如“请向右滑动完成验证”、“验证失败,请重新滑动”等.
    • 滑动组件的状态:设计组件时对于滑动结束需要做校验操作,通过事件交由父组件处理,父组件通过校验方法的处理可以传递滑动组件校验状态是成功还是失败,滑动组件用这些状态用于控制组件的样式和行为.
  3. 滑块组件提供的事件和方法
    • 滑动事件:当滑块开始移动时触发,携带滑块组件的信息,实时获取用户的操作行为.
    • 滑动结束事件:设计组件时,对于滑动结束需要进行校验操作,通过事件交由父组件处理。父组件通过校验方法的处理可以传递滑动组件的校验状态(成功或失败),滑动组件用这些状态来控制组件的样式和行为.
    • 重置的方法:用于将滑块验证组件恢复到初始状态,适用于用户操作失败或需要重新验证的场景.

实现细节

定义 props

export interface SliderVerifyProps {
  draggable?: boolean;
  promptText?: string;
  state?: -1 | 0 | 1;
}

定义 emits

interface SliderState {
  width?: number;
  moveX?: number;
  moveY?: number;
  trail?: number[];
  duration?: number;
}

export interface SliderVerifyEmits {
  (event: "thumbMove", params: SliderState): void;

  (event: "thumbEnd", params: SliderState): void;
}

定义 expose

export interface SliderVerifyExpose {
  reset: () => void;
}

编写 template

<template>
  <div ref="slider" :class="trackClasses">
    <div :style="maskStyle" class="track"></div>
    <div ref="thumb" :style="thumbStyle" class="thumb"></div>
    <span class="text"> {{ props.promptText }} </span>
  </div>
</template>

组件样式

@use 'sass:color';
// config
@use "../../styles/variables" as *;

.ld-slider-verify {
  position: relative;
  width: 100%;
  height: 40px;
  text-align: center;
  line-height: 40px;
  overflow: hidden;
  background: $gray-100;
  border: 1px solid $gray-300;
  color: $gray-900;

  .track {
    position: absolute;
    top: 0;
    left: 0;
    height: 100%;
    background: $info;
  }

  .thumb {
    position: absolute;
    top: 0;
    left: 0;
    width: 38px;
    height: 38px;
    background: $gray-500;
    cursor: pointer;
  }

  &.active {
    .track {
      background: color.adjust($primary, $lightness: 7.5%);
      transition: transform .3s ease-in-out;
    }

    .thumb {
      background: $primary;
      transition: transform .3s ease-in-out;
    }
  }

  &.success {
    .track {
      background: color.adjust($success, $lightness: 7.5%);
      transition: transform .3s ease-in-out;
    }

    .thumb {
      background: $success;
      transition: transform .3s ease-in-out;
    }
  }

  &.fail {
    .track {
      background: color.adjust($danger, $lightness: 7.5%);
      transition: transform .3s ease-in-out;
    }

    .thumb {
      background: $danger;
      transition: transform .3s ease-in-out;
    }
  }
}

组件源码

<script lang="ts" setup>
import { computed, onBeforeMount, onMounted, ref } from "vue";
import classNames from "classnames";
import useEventListener from "../../hooks/useEventListener";

interface SliderState {
  width?: number;
  moveX?: number;
  moveY?: number;
  trail?: number[];
  duration?: number;
}

export interface SliderVerifyProps {
  draggable?: boolean;
  promptText?: string;
  state?: -1 | 0 | 1;
}

export interface SliderVerifyEmits {
  (event: "thumbMove", params: SliderState): void;

  (event: "thumbEnd", params: SliderState): void;
}

export interface SliderVerifyExpose {
  reset: () => void;
}

defineOptions({
  name: "LdSliderVerify",
});

const slider = ref<HTMLDivElement | null>(null);
const sliderWidth = ref<number>(0);
const thumb = ref<HTMLDivElement | null>(null);
const trackActive = ref<boolean>(false);
const thumbMoveX = ref<number>(0);
const isDrag = ref<boolean>(false);
const thumbBeginX = ref<number>(0);
const thumbBeginY = ref<number>(0);
const thumbTrail = ref<number[]>([]);
const timestamp = ref<number>(0);

const props = withDefaults(defineProps<SliderVerifyProps>(), {
  draggable: true,
  promptText: "请向右滑动",
  state: -1,
});
const trackClasses = computed(() => {
  return classNames("ld-slider-verify", {
    active: trackActive.value,
    success: !trackActive.value && props.state === 1,
    fail: !trackActive.value && props.state === 0,
  });
});
const thumbStyle = computed(() => {
  return {
    left: thumbMoveX.value + "px",
  };
});
const maskStyle = computed(() => {
  return {
    width: thumbMoveX.value + "px",
  };
});

const emits = defineEmits<SliderVerifyEmits>();

// 鼠标按下
const handleStart = (event: Event) => {
  event.preventDefault();
  if (!props.draggable) return;
  if (event instanceof TouchEvent) {
    thumbBeginX.value = event.touches[0].clientX;
    thumbBeginY.value = event.touches[0].clientY;
  } else {
    thumbBeginX.value = (event as MouseEvent).clientX;
    thumbBeginY.value = (event as MouseEvent).clientY;
  }
  isDrag.value = true;
  timestamp.value = +Date.now();
};
// 鼠标移动
const handleMove = (event: Event) => {
  if (!isDrag.value) return;
  let moveY;
  if (event instanceof TouchEvent) {
    thumbMoveX.value = event.touches[0].clientX - thumbBeginX.value;
    moveY = event.touches[0].clientY - thumbBeginY.value;
  } else {
    thumbMoveX.value = (event as MouseEvent).clientX - thumbBeginX.value;
    moveY = (event as MouseEvent).clientY - thumbBeginY.value;
  }
  // 限制滑动范围
  const rect = slider.value!.getBoundingClientRect();
  if (thumbMoveX.value > rect.width - 40) {
    thumbMoveX.value = rect.width - 40;
  }
  if (thumbMoveX.value < 0) {
    thumbMoveX.value = 0;
  }
  trackActive.value = true;
  thumbTrail.value.push(moveY);
  emits("thumbMove", {
    width: sliderWidth.value,
    moveX: thumbMoveX.value,
    moveY: moveY,
    trail: thumbTrail.value,
  });
};
// 鼠标抬起
const handleEnd = (event: Event) => {
  if (!isDrag.value) return;
  isDrag.value = false;
  let moveY;
  if (event instanceof TouchEvent) {
    thumbMoveX.value = event.touches[0].clientX - thumbBeginX.value;
    moveY = event.touches[0].clientY - thumbBeginY.value;
  } else {
    thumbMoveX.value = (event as MouseEvent).clientX - thumbBeginX.value;
    moveY = (event as MouseEvent).clientY - thumbBeginY.value;
  }
  // 限制滑动范围
  const rect = slider.value!.getBoundingClientRect();
  if (thumbMoveX.value > rect.width - 40) {
    thumbMoveX.value = rect.width - 40;
  }
  if (thumbMoveX.value < 0) {
    thumbMoveX.value = 0;
  }
  trackActive.value = false;
  const duration = +Date.now() - timestamp.value;
  if (!verifyHuman(thumbTrail.value)) {
    reset();
    return;
  }
  emits("thumbEnd", {
    width: sliderWidth.value,
    moveX: thumbMoveX.value,
    moveY,
    duration,
    trail: thumbTrail.value,
  });
};

// 重置
const reset = () => {
  isDrag.value = false;
  trackActive.value = false;
  thumbMoveX.value = 0;
  thumbTrail.value = [];
};

// 人机操作判断
const verifyHuman = (tail: number[]) => {
  // 平均值
  const average = tail.reduce((x, y) => x + y, 0) / tail.length;
  // 标准差集合
  const deviations = tail.map((x) => x - average);
  // 标准差
  const stddev = Math.sqrt(
    deviations.map((x) => x * x).reduce((x, y) => x + y, 0) / tail.length,
  );

  return average !== stddev;
};

useEventListener(thumb, "mousedown", handleStart);
useEventListener(document, "mousemove", handleMove);
useEventListener(thumb, ["mouseup", "mouseleave"], handleEnd);

useEventListener(thumb, "touchstart", handleStart, {
  passive: true,
});
useEventListener(document, "touchmove", handleMove, {
  passive: true,
});
useEventListener(thumb, "touchend", handleEnd);

onMounted(() => {
  sliderWidth.value = slider.value!.offsetWidth;
});

defineExpose<SliderVerifyExpose>({
  reset,
});
</script>

后记

当前组件已经实现了基本功能,但还需要不断优化和完善.

  1. 当前组件仅实现了最基础的滑块验证功能,没有与后端进行交互。这意味着验证过程仅限于前端,可能无法满足一些需要后端参与的复杂验证场景.
  2. 后续还会基于此滑块组件,实现滑块拼图等验证组件,并支持后端接口交互

感谢阅读,敬请斧正!