【VueUse源码解析系列】useDraggable是如何实现元素拖拽

大家好,这是我开设第一个系列文章,旨在提升自己的源码阅读和调试能力、学习更多的优秀代码与逻辑。

如何调试

  1. 克隆最新的 VueUse 仓库 git clone https://github.com/vueuse/vueuse.git
  2. 安装依赖,因为依赖管理器配置的是 pnpm,所以使用 pnpm 来安装依赖 pnpm install
  3. 启动项目 pnpm dev
  4. 如果没有意外的话,就能在浏览器上看到运行在本地的官方文档页面
  5. /packages/core 文件夹下找到想要调试的目标方法,在 VSCode 和浏览器源代码中设置断点

接下来就可以开始愉快的调试了!

useDraggable

让元素变得可拖动,有两种使用方式:

  • Hook – 源码位置 /useDraggable/index.ts
  • Component – 源码位置/useDraggable/component.ts

Hook 方式

使用示例

通过useDraggable返回的style去改变元素样式来实现拖拽效果

<script setup lang="ts">
import { ref } from 'vue'
import { useDraggable } from '@vueuse/core'

const el = ref<HTMLElement | null>(null)

const { x, y, style } = useDraggable(el, {
  initialValue: { x: 80, y: 80 },
  preventDefault: true,
  exact: true,
})
</script>

<template>
  <div
    ref="el"
    :style="style"
  >
    👋 Drag me!
    <div>
      I am at {{ Math.round(x) }}, {{ Math.round(y) }}
    </div>
  </div>
  
</template>

参数说明

export function useDraggable(
  target: MaybeRefOrGetter<HTMLElement | SVGElement | null | undefined>,
  options: UseDraggableOptions = {},
) {
  ...
}

useDraggable 接受两个参数

  • target – 拖拽的目标元素
  • option – 可选参数,UseDraggableOptions类型,下面贴出源码来解释
export interface UseDraggableOptions {
  /**
   * Only start the dragging when click on the element directly
   * 只在直接点击元素时才开始拖动
   *
   * @default false
   */
  exact?: MaybeRefOrGetter<boolean>

  /**
   * Prevent events defaults
   * 阻止事件默认行为
   *
   * @default false
   */
  preventDefault?: MaybeRefOrGetter<boolean>

  /**
   * Prevent events propagation
   * 阻止事件传播
   *
   * @default false
   */
  stopPropagation?: MaybeRefOrGetter<boolean>

  /**
   * Whether dispatch events in capturing phase
   * 是否在捕获阶段调度事件
   *
   * @default true
   */
  capture?: boolean

  /**
   * Element to attach `pointermove` and `pointerup` events to.
   * 将'pointintermove'和'pointinterup'事件附加到哪个元素上
   *
   * @default window
   */
  draggingElement?: MaybeRefOrGetter<HTMLElement | SVGElement | Window | Document | null | undefined>

  /**
   * Handle that triggers the drag event
   * 触发拖动事件的元素
   *
   * @default target
   */
  handle?: MaybeRefOrGetter<HTMLElement | SVGElement | null | undefined>

  /**
   * Pointer types that listen to.
   * 点击类型
   *
   * @default ['mouse', 'touch', 'pen']
   */
  pointerTypes?: PointerType[]

  /**
   * Initial position of the element.
   * 元素初始位置
   *
   * @default { x: 0, y: 0 }
   */
  initialValue?: MaybeRefOrGetter<Position>

  /**
   * Callback when the dragging starts. Return `false` to prevent dragging.
   * 拖动开始时的回调。返回'false'以防止拖动
   */
  onStart?: (position: Position, event: PointerEvent) => void | false

  /**
   * Callback during dragging.
   * 拖动时的回调
   */
  onMove?: (position: Position, event: PointerEvent) => void

  /**
   * Callback when dragging end.
   * 拖动结束时的回调
   */
  onEnd?: (position: Position, event: PointerEvent) => void

  /**
   * Axis to drag on.
   * 可拖动的方向
   *
   * @default 'both'
   */
  axis?: 'x' | 'y' | 'both'
}

代码解析

参数解构
const {
  pointerTypes,
  preventDefault,
  stopPropagation,
  exact,
  onMove,
  onEnd,
  onStart,
  initialValue,
  axis = 'both',
  draggingElement = defaultWindow,
  handle: draggingHandle = target,
} = options

有个地方需要提下,因为刚开始看得时候我是一脸懵逼,就是const { handle: draggingHandle = target } = options,它的意思是options对象里面取出handle属性,并将其赋值给draggingHandle,如果options中没有handle属性,那么draggingHandle变量就是target,也就是useDraggable的第一个参数

注册事件监听器
if (isClient) {
  // 处理事件监听参数
  const config = { capture: options.capture ?? true }

  // draggingHandle 默认为 target,它的主要作用是用来控制在哪个元素上触发点击事件时开始拖拽行为
  useEventListener(draggingHandle, 'pointerdown', start, config)
  useEventListener(draggingElement, 'pointermove', move, config)
  useEventListener(draggingElement, 'pointerup', end, config)
}

在这里需要理解draggingHandledraggingElement变量,以及为什么是在draggingHandle监听pointerdown事件,在draggingElement监听pointermovepointerup

  • draggingHandle – 在上面参数中有讲解,它的值为options.handle || taget,这个变量的作用就是用来控制在哪个元素上触发点击事件时开始拖拽行为。
  • draggingElement – 它的值为options.draggingElement || window,因为在它上面绑定了pointermovepointerup事件监听器,用来计算拖动位置和监听拖拽结束的,那这个变量的作用用通俗的话来说就是鼠标在哪个元素内移动时可以进行元素的拖拽。

关于isClient变量,源码为const isClient = typeof window !== 'undefined',用来判断当前环境是否为浏览器。
关于useEventListener先不做过多的赘述,它是VueUseele.addEventListenerele.removeEventListener进行的一层封装,接受四个参数:

  • target – 创建事件监听器的目标元素
  • event – 事件名称
  • listener – 事件触发时执行的函数
  • options – 此参数同[addEventListener](https://developer.mozilla.org/zh-CN/docs/Web/API/EventTarget/addEventListener#%E8%AF%AD%E6%B3%95)的第三个参数

关于pointerdown/pointermove/pointerupmousedown/mousemove/mouseup的区别,它们都可以用来处理鼠标和触摸屏等指针设备的事件,但是mousedown/mousemove/mouseup是只支持鼠标事件,而pointerdown/pointermove/pointerup支持多种不同的指针设备,包括触摸屏、触控笔等等。

实现拖拽

元素拖拽的功能逻辑主要在start() move() end()这三个函数中,在进入这上个函数之间去需要先了解几个通用函数

  • toValue()– 函数意图很清晰,如何参数是函数的话就返回函数的返回值,不是函数的话就调用 Vue 中的 unref()函数并返回。
export function toValue<T>(r: MaybeRefOrGetter<T>): T {
  return typeof r === 'function'
    ? (r as AnyFn)()
    : unref(r)
}
  • filterEvent()– 判断当前触发的事件类型是否在useDraggable参数options.pointerTypes之中,不传options时默认为['mouse', 'touch', 'pen']
const filterEvent = (e: PointerEvent) => {
  if (pointerTypes)
    return pointerTypes.includes(e.pointerType as PointerType)
  return true
}
  • handleEvent()– 根据useDraggable参数options.pointerTypesoptions.stopPropagation来是否阻止事件默认行为和事件传播。
const handleEvent = (e: PointerEvent) => {
  if (toValue(preventDefault))
    e.preventDefault()
  if (toValue(stopPropagation))
    e.stopPropagation()
}

下面解析拖拽的主要逻辑函数start()move()end()

  • start()函数
const start = (e: PointerEvent) => {
  // 判断是否在配置的事件类型之内
  if (!filterEvent(e))
    return

  // 当exact为true时,判断点击事件触发的元素是否和需要拖拽的元素是否一致,如若不一致,则不能拖动
  if (toValue(exact) && e.target !== toValue(target))
    return

  // 获取需要拖拽的元素的位置坐标
  const rect = toValue(target)!.getBoundingClientRect()
  // 计算点击位置与拖动元素左上角坐标的差值
  const pos = {
    x: e.clientX - rect.left,
    y: e.clientY - rect.top,
  }

  // 判断是否定义了拖动开始时的回调函数以及回调函数是否返回的false
  if (onStart?.(pos, e) === false)
    return

  // 保存点击位置与元素坐标的差值
  pressedDelta.value = pos
  // 控制指针点击事件的默认行为与传播
  handleEvent(e)
}
  • move()函数
// 记录元素坐标
const position = ref<Position>(
  toValue(initialValue) ?? { x: 0, y: 0 },
)

const move = (e: PointerEvent) => {
  // 判断是否在配置的事件类型之内
  if (!filterEvent(e))
    return

  // 判断开始移动以前是否触发过点击事件start()方法
  if (!pressedDelta.value)
    return

  let { x, y } = position.value
  // axis为x或both时,元素可以在x轴上拖动
  if (axis === 'x' || axis === 'both')
    // 计算元素x轴坐标
    x = e.clientX - pressedDelta.value.x
  // axis为y或both时,元素可以在y轴上拖动
  if (axis === 'y' || axis === 'both')
    // 计算元素y轴坐标
    y = e.clientY - pressedDelta.value.y

  // 更新元素坐标
  position.value = {
    x,
    y,
  }

  // 判断是否定义了拖动时的回调函数,若定义了则执行回调函数
  onMove?.(position.value, e)
  // 控制指针移动事件的默认行为与传播
  handleEvent(e)
}
  • end()函数
const end = (e: PointerEvent) => {
  // 判断是否在配置的事件类型之内
  if (!filterEvent(e))
    return
  
  // 判断是否触发过点击事件start()方法
  if (!pressedDelta.value)
    return

	// 将坐标差值数据清空,因为后面需要使用这个变量来判断元素是否在拖动构成中
  pressedDelta.value = undefined
  // 判断是否定义了拖动结束时的回调函数,若定义了则执行回调函数
  onEnd?.(position.value, e)
  // 控制指针释放事件的默认行为与传播
  handleEvent(e)
}
返回值
export function useDraggable(
  target: MaybeRefOrGetter<HTMLElement | SVGElement | null | undefined>,
  options: UseDraggableOptions = {},
) {
  ...
  return {
    // 返回元素左上角最新坐标值:x, y
    ...toRefs(position),
    // 返回Ref类型的元素左上角坐标
    position,
    // 用于判断元素是否在拖动过程中
    isDragging: computed(() => !!pressedDelta.value),
    // 元素样式,通过left和top来控制元素位置,直接把它绑定到元素上即可使用拖动
    style: computed(
      () => `left:${position.value.x}px;top:${position.value.y}px;`,
    ),
  }
}

至此,直接将useDraggable返回的style绑定到元素上即可实现拖动效果,VueUse里面默认使用的是lefttop的方式来使用元素位置的改变,我们可以通过返回的position去使用其他方式来改变元素位置。

Component 方式

使用示例

将需要拖拽的组件放到<UseDraggable></UseDraggable>内即可

<script setup lang="ts">
import { ref } from 'vue'
import { UseDraggable } from '@vueuse/components'
</script>

<template>
  <UseDraggable
    v-slot="{ x, y }"
    p="x-4 y-2"
    :initial-value="{ x: 150, y: 150 }"
    :prevent-default="true"
    storage-key="vueuse-draggable-pos"
    storage-type="session"
  >
    Renderless component
    <div>
      Position persisted in sessionStorage
    </div>
    <div>
      {{ Math.round(x) }}, {{ Math.round(y) }}
    </div>
  </UseDraggable>
</template>

参数说明

【VueUse源码解析系列】useDraggable是如何实现元素拖拽
以组件的方式使用时,里面默认集成了持久化保存坐标数据的功能,参数基本上和useDraggable的中options大部分一致,有几个是options没有的:

  • storageType – 用来持久化保存坐标数据的位置,为'session'时,保存在sessionStorage,否则保存在localStorage
  • storageKey – 保存在sessionStoragelocalStorage中的键名
  • as – 渲染时使用什么标签包裹<UseDraggable></UseDraggable>内部的元素

代码解析

持久化数据
// 如何传递了storageKey参数,就去对应的缓存位置拿取数据
const storageValue = props.storageKey && useStorage(
  props.storageKey,
  toValue(props.initialValue) || { x: 0, y: 0 },
  isClient
    ? props.storageType === 'session'
      ? sessionStorage
      : localStorage
    : undefined,
)

// 获取初始坐标数据
const initialValue = storageValue || props.initialValue || { x: 0, y: 0 }

// 拖拽结束时保存数据
const onEnd = (position: Position) => {
  if (!storageValue)
    return
  storageValue.value.x = position.x
  storageValue.value.y = position.y
}

关于useStorage先不做了解,在后续的文章再解析,现在理解成根据storageTypestorageKey去浏览器缓存保存和获取数据

调用useDraggable以及渲染模板

<UseDraggable>组件内部其实也是调用了useDraggable,将<UseDraggable>组件上的参数经过处理后调用useDraggable,下面我把持久化坐标功能代码剔除后来讲解

export const UseDraggable = defineComponent<UseDraggableProps>({
  name: 'UseDraggable',
  props: [
    'storageKey',
    'storageType',
    'initialValue',
    'exact',
    'preventDefault',
    'stopPropagation',
    'pointerTypes',
    'as',
    'handle',
    'axis',
  ] as unknown as undefined,
  setup(props, { slots }) {
    // 声明一个ref,后面渲染的组件会绑定ref,用于获取组件实例
    const target = ref()

    // 用来控制在哪个元素上触发点击事件时开始拖拽行为。
    // 类似于 useDraggable 中的 const { handle: draggingHandle = target } = options
    const handle = computed(() => props.handle ?? target.value)

    // 处理元素初始坐标
    const initialValue = props.initialValue || { x: 0, y: 0 }

    // 调用 useDraggable,这部分的逻辑就跟上面一致了
    const data = reactive(useDraggable(target, {
      ...props,
      handle,
      initialValue,
      onEnd,
    }))

    return () => {
      // 判断 <UseDraggable> 组件内部是否有内容
      if (slots.default)
        return h(props.as || 'div', { ref: target, style: `touch-action:none;${data.style}` }, slots.default(data))
    }
  },
})

关于最后的渲染函数h()

  • 第一个参数props.as || 'div'是用来指定渲染时使用什么标签;
  • 第二个参数{ ref: target, style: touch-action:none;${data.style} },则是将参数里的属性直接设置在渲染出的元素上;
  • 第三个参数slots.default(data),就是在渲染结果中加入默认插槽,并将data里的数据作为插槽的参数,可以转换为<slots v-bind="data"></slot>,没有显式指定name属性时,默认为default

渲染元素最后是把ref="target"加上了,这样useDraggabletarget参数就是渲染出来的元素,指定了拖拽的目标元素

使用示例解析
<script setup lang="ts">
import { ref } from 'vue'
import { UseDraggable } from '@vueuse/components'
</script>

<template>
  <UseDraggable
    v-slot="{ x, y }"
    p="x-4 y-2"
    :initial-value="{ x: 150, y: 150 }"
    :prevent-default="true"
    storage-key="vueuse-draggable-pos"
    storage-type="session"
  >
    Renderless component
    <div>
      Position persisted in sessionStorage
    </div>
    <div>
      {{ Math.round(x) }}, {{ Math.round(y) }}
    </div>
  </UseDraggable>
</template>

到这里整个useDraggable的源码解析结束了,两个使用方式各有各的特点。
PS:使用时一定不要忘了给需要拖动的元素加上**postion:fixed**,否则不生效哦!!!

两种使用方式的差异

  • Hook 方式更加的灵活,可以通过onMoveonEndonStart这三个回调函数加入更多的逻辑;
  • Component 方式更加简单,集成持久化拖拽数据、不用显式指定target

使用 transform 替代 left 和 top

因为lefttop的修改会频繁的触发重排重绘,基于性能考虑我们将其改成transform形式

Hook

<script setup lang="ts">
  import { ref, computed } from 'vue'
  import { useDraggable } from '@vueuse/core'

  const el = ref<HTMLElement | null>(null)

  const { x, y, style } = useDraggable(el, {
    initialValue: { x: 80, y: 80 },
    preventDefault: true,
    exact: true,
  })

  const transformStyle = computed(() => `transform: translate(${position.value.x}px, ${position.value.y}px)`)
</script>

<template>
  <div
    ref="el"
    :style="transformStyle"
    >
    👋 Drag me!
    <div>
      I am at {{ Math.round(x) }}, {{ Math.round(y) }}
    </div>
  </div>

</template>

Component

因为<UseDraggable>组件内部实现逻辑是默认直接将lefttop属性定义在组件上了,如果不想让它生效就别给元素设置position: fixed || absolute
Component 使用方式暂还没想到要如何改造,我初步的设想是:

<script setup lang="ts">
import { UseDraggable } from '@vueuse/components'
</script>

<template>
  <UseDraggable
    v-slot="{ x, y }"
    :style="{ transform: `translate(${x}px, ${y}px)` }"
  >
    <div>
      {{ Math.round(x) }}, {{ Math.round(y) }}
    </div>
  </UseDraggable>
</template>

<style scoped></style>

但是style中拿不到默认插槽上的参数,这种方式就放弃了,要是大佬有其他方式实现,欢迎大佬指点!
PS:主要需要注意的是transform变换是基于元素本身的位置来计算的,这一点是特别需要注意的,需要根据自己的使用场景来判断是否需要使用transform

总结

下面是我总结的几个自己在此之前没有详细了解过知识点

  • 变量解构:const { handle: draggingHandle = target } = options 等效于 const draggingHandle = options.handle || target
  • PointEvent事件替代MouseEventPointEvent事件更通用,不仅包含鼠标,还包含触控笔和触摸屏
  • 渲染函数h():创建虚拟 DOM 节点,并且在里面设置插槽
  • 接受props的插槽:v-slot="{ x, y }"解构默认插槽上的参数;v-slot:item="{ x, y }"解构名为item插槽上的参数

共同成长,无限进步!!! 💪

原文链接:https://juejin.cn/post/7226376235558699063 作者:土豆鸡蛋

(3)
上一篇 2023年4月29日 上午10:38
下一篇 2023年4月29日 上午10:48

相关推荐

发表回复

登录后才能评论