原生 JavaScript 实现滑动拖动验证

通常,我们在防止用户恶意提交表单的时候,会让用户在提交前完成滑动拖动验证,有时候这也能起到一丝反爬的作用。

实现滑动验证的方式当然不止一种,这里我们直接使用原生 JavaScript 来实现。

原生实现

通过原生 JavaScript 的实现,主要是通过监听鼠标事件来对 DOM 进行一系列的操作。

滑块验证的结构主要分为四个部分:轨道、滑块、背景和文案,我们可以使用下面的 HTML 结构来表示。

<div class="slide-track">
    <div class="slide-bg"></div>
    <div class="slide-block"></div>
    <p class="slide-text">请按住滑块,拖动到最右边</p>
</div>

基本思路就是我们给滑块(.slide-block)添加相应的事件,在按下滑块时记录鼠标的当前位置并添加滑动事件,在滑动过程中根据鼠标的移动来移动滑块的位置和增加背景元素(.slide-bg)的宽度,直到移动到轨道(.slide-track)的末端后,改变文案(.slide-text)来提示成功。

样式

在开始写脚本之前可以先来完成一下它们的样式,这让滑块相关的部分看起来更好,也让后面的工作更愉快的进行。

/* 样式的注意事项 */

样式的写法就不贴了,相信大家一看就懂,而且会有更好的实现。需要的话,也可以在 Github 上找到。

脚本

现在开始来实现脚本的内容,首先我们对 document.querySelector 方法进行简单的封装以方便后续操作 DOM 元素。

function $(selectors) {
    return document.querySelector(selectors);
}

然后通过自定义的 _h 函数我们可以很方便的创建上面的 HTML 结构,并添加到文档中。

function _h(tagName, propMap = {}, text) {
    const ele = document.createElement(tagName);
    Object.keys(propMap).forEach(prop => ele.setAttribute(prop, propMap[prop]));
    if (text) {
        ele.appendChild(document.createTextNode(text));
    }
    return ele;
}

class SlideUnlock {
    constructor(el = "body", options = {}) {
        this.$el = $(el)
        this.$isSuccess = false
        this.$options = {
            tip: '请按住滑块,拖动到最右边',
            unlockText: '验证成功',
            duration: 500,
            ...options
        }
    }

    init() {
        this.$root = _h("div", { class: "slide-track" }) // 轨道
        this.$bg = this.$root.appendChild(_h("div", { class: "slide-bg" }))
        this.$block = this.$root.appendChild(
            _h("div", { class: "slide-block" }) // 滑块
        )
        this.$text = this.$root.appendChild(
            _h("p", { class: "slide-text" }, this.$options.tip)
        )
        this.$el.insertBefore(this.$root, this.$el.firstChild)
    }
}

在创建好 DOM 结构后,接下来为滑块添加鼠标按下的事件,在这个事件中我们需要记录下鼠标的初始横坐标,以便后续和滑动过程中的位置相比较,同时为其添加滑动事件。

class SlideUnlock {
    init() {
        /* ... */
        this.$block.addEventListener(
            "mousedown",
            (this.$handleMouseDown = this._handleMouseDown.bind(this)),
            false
        )
    }

    _handleMouseDown(e) {
        const downx = e.clientX

        e.target.addEventListener(
            "mousemove",
            (this.$handleMouseMove = this._handleMouseMove.bind(this, downx)),
            false
        )
        e.preventDefault()
    }

    _handleMouseMove(downx, e) {}
}

在这里有点细节需要注意:

  • 首先,由于事件监听器中的 this 指向的是触发事件的元素,为此我们在指定鼠标按下的监听器时为其绑定了 this,以便调用滑块实例属性和原型上的方法。
  • 其次,我们在鼠标按下的监听器中添加了鼠标移动的监听器,如果在初始时同按下的监听器一起指定,那么会先执行鼠标移动的监听器,而此时并没有记录鼠标的初始位置;

接下来我们要实现滑动过程中的主要逻辑:根据鼠标的移动实时地更新滑块的位置,并对一些临界位置进行处理。

_handleMouseMove(downx, e) {
    const info = this.$block.getBoundingClientRect(),
        x = e.clientX,
        y = e.clientY,
        x1 = info.left,
        y1 = info.top,
        x2 = info.right,
        y2 = info.bottom,
        moveX = x - downx

    if (this.$isSuccess) {
        return
    }
    if (moveX < 0) {
        return
    }
    if (x < x1 || x > x2 || y < y1 || y > y2) {
        // 当鼠标移开滑块时取消移动
        return
    }

    this.$block.style.left = `${moveX}px` // 更新滑块的我i之
    this.$bg.style.width = `${moveX}px` // 同步增大背景元素的宽度

    // 当滑块滑动的距离大于等于轨道除去滑块宽度后的距离时表示已经到达轨道的最右边了
    if (moveX >= this.$root.offsetWidth - (x2 - x1)) {
        this.$isSuccess = true
        this.$text.textContent = "验证成功"
        this.$text.style.cssText = `color: #fff; left: 0; right: ${this.$block.offsetWidth}px`
        this.$block.classList.add("success")
    }
}

这里的实现也很简单,唯一需要看一下的就是通过 getBoundingClientRect 来获取了滑块相对于视口的位置,然后根据鼠标所在的位置来判断鼠标是否在滑块上,如果不在则取消移动。

现在它已经能很好的滑动,并完成提示成功的基本功能了。但是,当我们每次滑动到中间就取消,然后再次点击滑动时,就会导致重复的添加滑动事件,而且中途释放后,滑块就停在了当前位置,这显然不对。

解决的办法就是在添加鼠标按下事件的时候,同时也指定一个松开的事件,在这个事件的监听器中判断如果没有成功则取消之前绑定的滑动事件,并进行重置,为了看起来更友好,我们还可以加上一点动画。

class SlideUnlock {
    init() {
        /* ... */
        document.addEventListener(
            "mouseup",
            (this.$handleMouseUp = this._handleMouseUp.bind(this)),
            false
        )
    }

    _handleMouseDown(e) {
        /* ... */
        // 取消在手动滑动过程中的动画效果
        this.$bg.style.transition = ""
        this.$block.style.transition = ""
        /* ... */
    }

    _handleMouseUp(e) {
        this.$block.removeEventListener(
            "mousemove",
            this.$handleMouseMove,
            false
        )

        if (this.$isSuccess) {
            return
        }

        // 给重置过程添加动画效果
        this.$bg.style.transition = "width 1s ease"
        this.$block.style.transition = "left 1s ease"

        this.$block.style.left = 0
        this.$bg.style.width = 0
    }
}

目前为止,滑块已经可以在 PC 端正常的工作了,不过在移动端却并不理想。

为了它能够在移动端也可以很好的工作,我们可以借助 touchstarttouchmovetouchend 等事件来完成。

绑定这些事件的时机和处理方式和之前的三个事件分别相对应,所以我们新增两个方法(和 jQuery 的 on、off 方法很像)来更好的添加和移除事件。

function bindEvents(events, handler, element = $("body")) {
    events.split(" ").forEach(event => {
        element.addEventListener(event, handler, false)
    })
}

function unbindEvents(events, handler, element = $("body")) {
    events.split(" ").forEach(event => {
        element.removeEventListener(event, handler, false)
    })
}

根据这两个方法,我们来稍微修改一下滑块中添加事件的代码。

class SlideUnlock {
    init() {
        /* ... */
        bindEvents(
            "mousedown touchstart",
            (this.$handleMouseDown = this._handleMouseDown.bind(this)),
            this.$block
        )
        bindEvents(
            "mouseup touchend",
            (this.$handleMouseUp = this._handleMouseUp.bind(this)),
            document
        )
    }

    _handleMouseDown(e) {
        /* ... */
        if (e.cancelable) {
            e.preventDefault() // 阻止默认行为
        }

        /* ... */
        bindEvents(
            "mousemove touchmove",
            (this.$handleMouseMove = this._handleMouseMove.bind(this, downx)),
            this.$block
        )
    }

    handleMouseUp(e) {
        unbindEvents("mousemove touchmove", this.$handleMouseMove, this.$block)
        /* ... */
    }
}

另外,需要注意的是在移动端 touch 事件中获取 clientXclientY 时不能在事件对象上直接读取,而是在 event.changedTouches[0] 对象上取得。

现在,它已经能够同时在 PC 端和移动端上工作了,不过我们还能对它进行一些优化,比如使用函数节流。

函数节流的实现方式有很多,这里我们列一下在本次中使用的方式。

utils.throttle = function(method, context = {}, delay = 4, ...outParams) {
  return function(...innerParams) {
    clearTimeout(context.$tId)
    context.$tId = setTimeout(function() {
      method.apply(context, [...outParams, ...innerParams])
    }, delay)
  }
}

然后用这个节流函数,来包装我们移动时的处理函数,并根据实际情况做点调整。

除此之外,我们还可以添加一个重置的方法,让它回到最初的状态,涉及到的内容也很简单,就是在成功的状态下重新设置样式和绑定事件。

reset() {
    if (!this.$isSuccess) {
        return
    }
    this.$isSuccess = false
    this.$bg.style.cssText =
        `transition: width ${this.$options.duration}ms ease; width: 0;`
    this.$block.style.cssText =
        `transition: left ${this.$options.duration}ms ease; left: 0;`
    this.$text.style.cssText =
        `color: #5f5f5f; left: ${this.$block.offsetWidth}px; right: 0;`
    this.$text.textContent = this.$options.tip
    this.$block.classList.remove("success")
    this._bindEvents()
}

好了,滑块的实现到这里就告一段落了,相信大家看到这里已经完全明白了,甚至有更好的实现。

这里写下的实现也只是提供一个思路,欢迎大家一起交流学习。

如何使用

你可以简单的在 HTML 页面中引入该脚本,然后根据自己的需求设置合适的样式;不过更好的方式是通过这样的思路,在项目中做一些改进(比如平滑降级)等处理。

接下来是一个简单的使用模板。

<body>
    <script src="slide-unlock/core.js"></script>
    <script>
        const slider = new SlideUnlock()
        slider.init()
    </script>
</body>

你可以在 这里 看见完整的使用方式和效果。

LESS
@track-height: 32px;
@block-width: 45px;
@slide-pc-size: 18px;

@block-text: #afafaf;
@block-pc-text: #fff;
@slide-text: #5f5f5f;

@track-bg: #e8e8e8;
@block-bg: #fff;
@block-bd: #ccc;
@bg-success: #7cc13c;

.slide-track {

  &,
  * {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
  }

  position: relative;
  width: 300px;
  height: @track-height;
  background-color: @track-bg;

  .slide-block {
    z-index: 999;
    position: absolute;
    top: 0;
    bottom: 0;
    left: 0;
    width: @block-width;
    height: @track-height;
    line-height: @track-height;
    text-align: center;
    color: @block-text;
    background: @block-bg;
    border: 1px solid @block-bd;
    cursor: move;

    &::after {
      content: '»';
    }

    &.success::after {
      content: '√';
      display: block;
      position: absolute;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
      width: @slide-pc-size;
      height: @slide-pc-size;
      line-height: @slide-pc-size;
      color: @block-pc-text;
      border-radius: 50%;
      background: @bg-success;
    }
  }

  .slide-text {
    position: absolute;
    left: @block-width;
    right: 0;
    z-index: 99;
    margin: 0;
    line-height: @track-height;
    font-size: 14px;
    text-align: center;
    color: @slide-text;
  }

  .slide-bg {
    position: absolute;
    top: 0;
    bottom: 0;
    left: 0;
    width: @block-width;
    background: @bg-success;
  }
}
JavaScript
const utils = new (class {
  /**
   * @public
   * @method
   * @name isFunction
   * @description 判断传入的值是否是一个函数
   * @param {*} value 需要检测的值
   * @returns {boolean}
   */
  isFunction(value) {
    return Object.prototype.toString.call(value) === '[object Function]'
  }

  /**
   * @public
   * @function
   * @name $
   * @description 获取文档中与指定选择器或选择器组匹配的第一个 HTML 元素
   * @param {string} selectors 包含一个或多个要匹配的选择器的 DOM 字符串
   * @returns {Element | null} 如果找不到匹配项,则返回 null,否则返回对应的 Element
   */
  $(selectors) {
    return document.querySelector(selectors)
  }

  /**
   * @private
   * @method
   * @name _h
   * @description 创建由 tagName 指定的 HTML 元素
   * @param {string} tagName 指定要创建元素类型的字符串
   * @param {object} propMap 可选的,包含 class 等额外配置项
   * @param {string} text 可选的文字节点
   * @returns {Element | HTMLUnknownElement} 如果 tagName 不被识别,则创建一个 HTMLUnknownElement
   */
  h(tagName, propMap = {}, text) {
    const ele = document.createElement(tagName)
    Object.keys(propMap).forEach(prop => ele.setAttribute(prop, propMap[prop]))
    if (text) {
      ele.appendChild(document.createTextNode(text))
    }
    return ele
  }

  /**
   * @public
   * @function
   * @name bindEvents
   * @param {string} events 一个以空格分开的字符串,指定了要绑定的事件
   * @param {function} handler 事件监听器
   * @param {Element | undefined} element 可选的,绑定事件的元素
   * @returns {void}
   */
  bindEvents(events, handler, element = this.$('body')) {
    events.split(' ').forEach(event => {
      element.addEventListener(event, handler, false)
    })
  }

  /**
   * @public
   * @function
   * @name unbindEvents
   * @param {string} events 一个以空格分开的字符串,指定了要取消的事件
   * @param {function} handler 事件监听器
   * @param {Element | undefined} element 可选的,取消事件的元素
   * @returns {void}
   */
  unbindEvents(events, handler, element = this.$('body')) {
    events.split(' ').forEach(event => {
      element.removeEventListener(event, handler, false)
    })
  }

  /**
   * @public
   * @function
   * @name throttle
   * @description 对函数进行包装,避免高频率执行损耗性能
   * @param {function} method 需要执行函数
   * @param {object} context 函数执行时的上下文环境
   * @param {number} delay 时间,以毫秒计
   * @returns {function} 包装后的函数
   */
  throttle(method, context = {}, delay = 4, ...outParams) {
    function withThtottle(...innerParams) {
      clearTimeout(context.$$tId)
      function throttleCore() {
        method.apply(context, [...outParams, ...innerParams])
      }
      throttleCore.displayName = `throttleCore(${method.name})`
      // eslint-disable-next-line no-param-reassign
      context.$$tId = setTimeout(throttleCore, delay)
    }
    withThtottle.displayName = `withThtottle(${method.name})`
    return withThtottle
  }
})()

/**
 * Class implement slide verification.
 * @example
 * new SlideUnlock().init()
 */
// eslint-disable-next-line no-unused-vars
class SlideUnlock {
  /**
   * 初始化滑块,处理配置
   * @param {Element} el 挂载的元素
   * @param {object} options 配置项
   */
  constructor(el = 'body', options = {}) {
    this.$el = utils.$(el)
    this.$$isSuccess = false
    this.$options = {
      tip: '请按住滑块,拖动到最右边',
      unlockText: '验证成功',
      duration: 500,
      ...options
    }
  }

  /**
   * @public
   * @method
   * @name init
   * @description 创建滑块的 DOM 结构,并添加鼠标在滑块上按下的监听器
   * @returns {void}
   */
  init() {
    this.$$root = utils.h('div', { class: 'slide-track' })
    this.$$bg = this.$$root.appendChild(utils.h('div', { class: 'slide-bg' }))
    this.$$block = this.$$root.appendChild(
      utils.h('div', { class: 'slide-block' })
    )
    this.$$text = this.$$root.appendChild(
      utils.h('p', { class: 'slide-text' }, this.$options.tip)
    )
    this.$el.insertBefore(this.$$root, this.$el.firstChild)
    this._bindEvents()
  }

  /**
   * @private
   * @method
   * @name _bindEvents
   * @description 绑定鼠标的按下和松开事件
   * @returns {void}
   */
  _bindEvents() {
    utils.bindEvents(
      'mousedown touchstart',
      (this.$$handleMouseDown = this._handleMouseDown.bind(this)),
      this.$$block
    )
    utils.bindEvents(
      'mouseup touchend',
      (this.$$handleMouseUp = this._handleMouseUp.bind(this)),
      document
    )
  }

  /**
   * @private
   * @method
   * @name _handleMouseDown
   * @description 滑块上鼠标按下的监听器,主要记录鼠标的横坐标并指定滑块滑动的监听器
   * @param {MouseEvent} e 一个基于 Event 的对象
   * @returns {void}
   */
  _handleMouseDown(e) {
    const eve = e || window.e
    const downx = eve.clientX || e.changedTouches[0].clientX

    if (e.cancelable) {
      e.preventDefault() // 阻止默认行为
    }

    if (this.$$isSuccess) {
      return
    }

    // 取消在手动滑动过程中的动画效果
    this.$$bg.style.transition = ''
    this.$$block.style.transition = ''

    utils.bindEvents(
      'mousemove touchmove',
      (this.$$handleMouseMove = utils.throttle(
        this._handleMouseMove,
        this,
        undefined,
        downx
      )),
      this.$$block
    )
  }

  /**
   * @private
   * @method
   * @name _handleMouseMove
   * @description 滑块滑动的监听器,主要更新滑块位置和背景元素的宽度
   * @param {MouseEvent} e 一个基于 Event 的对象
   * @returns {void}
   */
  _handleMouseMove(downx, e) {
    const info = this.$$block.getBoundingClientRect()
    const x = e.clientX || e.changedTouches[0].clientX
    const y = e.clientY
    const x1 = info.left
    const y1 = info.top
    const x2 = info.right
    const y2 = info.bottom
    const moveX = x - downx

    if (this.$$isSuccess) {
      return
    }
    if (moveX < 0) {
      return
    }
    if (x < x1 || x > x2 || y < y1 || y > y2) {
      // 当鼠标移开滑块时取消移动
      return
    }

    this.$$block.style.left = `${moveX}px`
    this.$$bg.style.width = `${moveX}px`

    if (moveX >= this.$$root.offsetWidth + x1 - x2) {
      this.$$isSuccess = true
      this.$$text.textContent = this.$options.unlockText
      this.$$text.style.cssText = `color: #fff; left: 0; right: ${this.$$block.offsetWidth}px;`
      this.$$block.classList.add('success')
      if (utils.isFunction(this.$options.cb)) {
        // 为了避免阻塞成功提示界面的渲染,你可以设置两百毫秒左右的延迟
        this.$options.cb.call(this)
      }
    }
  }

  /**
   * @private
   * @method
   * @name _handleMouseUp
   * @description 鼠标松开的监听器,主要用于重置
   * @param {MouseEvent} e 一个基于 Event 的对象
   * @returns {void}
   */
  _handleMouseUp() {
    clearTimeout(this.$$tId)
    utils.unbindEvents(
      'mousemove touchmove',
      this.$$handleMouseMove,
      this.$$block
    )

    if (this.$$isSuccess) {
      utils.unbindEvents('mouseup touchend', this.$$handleMouseUp, document)
      utils.unbindEvents(
        'mousedown touchstart',
        this.$$handleMouseDown,
        this.$$block
      )
      return
    }

    // 给重置过程添加动画效果
    this.$$bg.style.cssText = `transition: width ${this.$options.duration}ms ease; width: 0;`
    this.$$block.style.cssText = `transition: left ${this.$options.duration}ms ease; left: 0;`
  }

  /**
   * @public
   * @method
   * @name isSuccess
   * @description 返回当前滑块的状态
   * @returns {boolean}
   */
  isSuccess() {
    return this.$$isSuccess
  }

  /**
   * @public
   * @method
   * @name reset
   * @description 重置滑块的状态
   * @returns {void}
   */
  reset() {
    if (!this.$$isSuccess) {
      return
    }
    this.$$isSuccess = false
    this.$$bg.style.cssText = `transition: width ${this.$options.duration}ms ease; width: 0;`
    this.$$block.style.cssText = `transition: left ${this.$options.duration}ms ease; left: 0;`
    this.$$text.style.cssText = `color: #5f5f5f; left: ${this.$$block.offsetWidth}px; right: 0;`
    this.$$text.textContent = this.$options.tip
    this.$$block.classList.remove('success')
    this._bindEvents()
  }
}