头图
在工作中经常遇到需要预览一张尺寸可能非常大的图片,初始化显示的时候,希望它自适应显示区域后,还可以缩放并可以在显示区域中拖拽。

在这里,手把手展示一下如何实现一个简单的组件,以实现上述的需求。

效果展示

先看看效果

在这里插入图片描述

可以直达👇

示例仓库 | 示例文档 | 在线示例

别忘了,可以带话,给我一个 Star 哟!

实现 Hook

在实现组件之前,可以先实现一个 hook,以包含核心逻辑,后面实现 组件 和 指令 的时候需要依赖它。

使用方式

<template>
  <div ref="board" class="demo">
    <div>Somethings</div>
  </div>
</template>
// 页面

import { ref } from 'vue'

import { useZoomDrag } from 'vue3-zoom-drag'

const board = ref<HTMLElement>()

useZoomDrag({
  board,
  zoomMin: 0.1,
})

这里的 hook 就是 useZoomDrag

配置项

先设计一下,接下来要实现那些功能配置

export type useZoomDragOptions = {
  /**
   * 容器区域
   */
  board: Ref<HTMLElement | undefined> | ComputedRef<HTMLElement | undefined>
  /**
   * 目标区域
   */
  target?: Ref<HTMLElement | undefined> | ComputedRef<HTMLElement | undefined>
  /**
   * 目标变化事件
   */
  onTargetChange?: (info: ZoomDragSize & { zoom: number }, methods: ZoomDragMethods) => void
  /**
   * 容器大小变化事件
   */
  onBoardChange?: (info: ZoomDragSize, methods: ZoomDragMethods) => void
  /**
   * 初始化完成事件
   */
  onReady?: () => void
  /**
   * 放大缩小速率(默认 0.1)
   */
  zoomSpeed?: number
  /**
   * 最高放大倍速(默认 3)
   */
  zoomMax?: number
  /**
   * 最低缩小倍速(默认 0.2)
   */
  zoomMin?: number
  /**
   * 内边距
   */
  padding?: [number, number, number, number]
}
  • board - 作为可视区域的 HTML 节点
  • target - 可以缩放、拖拽的目标 HTML 节点

在这里插入图片描述

  • zoomSpeed - 缩放速度可配置,即 dom 每次触发 wheel 事件时,增加/减少多少缩放比例,例如,0.1、0.3....

    假如当前目标缩放比例为 1.2,缩放速度为 0.2,则 wheel 放大一次后,最新缩放比例就是 1.2 + 0.2 = 1.4;反之缩小一次,就是 1.2 - 0.2 = 1。
  • zoomMax - 最大缩放比例可配置,例如,2倍、3倍...
  • zoomMin - 最小缩放比例可配置,例如,0.5倍、0.2倍...
  • onReady - 初始化完成事件,默认会执行一次“自适应显示区域”操作(也就是后面会提到的 fitSize 方法)。

    值得一提的是,如果目标本身并非 img 节点,但是内部却包含 img 节点,此时,需要在 img 节点的 onload 事件中手动执行一次 fitSize 方法。
    原因就是目标节点(例如一个 div),它的大小取决于一个需要异步加载的节点(例如 img),初始化的时候执行的 fitSize,无法获得真实的大小尺寸。
  • onTargetChange - 目标变化事件,会返回关于目标的大小信息和缩放比例,并暴露一个 fitSize 方法。
  • onBoardChange - 容器大小变化事件,会返回关于显示区域的大小信息(例如浏览器窗口大小发生了改变,导致显示区域发生了改变),并暴露一个 fitSize 方法。

PS: 关于大小信息的定义:

export interface ZoomDragSize {
  width: number
  height: number
  left: number
  top: number
}
  • padding - 内边距,主要是为了预留一些空间,可以放置一些自定义的操作按钮。

假如设置为
padding: [0, 100, 0, 0]

在这里插入图片描述

代码实现

基础结构

先忽略具体细节,看看主要这个 hook 的代码结构:

import { ref, type Ref, reactive, watch } from 'vue'

// 类型定义
import type { useZoomDragOptions, ZoomDragMethods, ZoomDragSize } from '../types'

// 配置的默认值
const DefaultOptions: Partial<useZoomDragOptions> = {
  zoomSpeed: 0.1,
  zoomMax: 10,
  zoomMin: 0.2,
  padding: [0, 0, 0, 0],
}

// hook 主体
export default function useZoomDrag(opts: useZoomDragOptions): {
  // hook 返回的内容
  // 目标、显示区域的相关信息是 Ref 类型的
  target: Ref<ZoomDragSize & { zoom: number }>
  board: Ref<ZoomDragSize>
  // 暴露一些方法,目前只有 fitSize(自适应显示区域)
  methods: ZoomDragMethods
} {
  // 传入配置的默认值合并
  const options = { ...DefaultOptions, ...opts }

  // 略

  // 返回的内容(目标、显示区域的相关信息)
  const targetInfoRef: Ref<ZoomDragSize & { zoom: number }> = ref({
    width: 0,
    height: 0,
    left: 0,
    top: 0,
    zoom: 1,
  })
  const boardInfoRef: Ref<ZoomDragSize> = ref({
    width: 0,
    height: 0,
    left: 0,
    top: 0,
  })

  // 获得目标节点(如果没有通过配置传入,则获取显示区域的第一个子节点)
  function getTarget() {
    // 略
  }

  // 略

  // 自适应大小(需要暴露的方法)
  // animate 表示缩放时是否显示缩放动画
  async function fitSize(animate = false) {
    // 略
  }

  // 略

  // 涉及到的 Dom 事件处理
  const eventHandlers = {
    // 缩放
    zoom: (e: WheelEvent) => {
      // 略
    },
    // 排除右键菜单的影响
    contextmenu: (e: MouseEvent) => {
      e.preventDefault()
    },
    // 拖拽相关逻辑
    dragStart: (e: MouseEvent) => {
      // 略
    },
    dragMove: (e: MouseEvent) => {
      // 略
    },
    dragEnd: () => {
      // 略
    },
  }

  // 事件处理(绑定逻辑)
  function eventHandle() {
    // 略
  }

  // 略

  // 容器区域必须样式(初始化可视区域的必要样式)
  function boardStyle() {
    // 略
  }

  // 目标区域必须样式(初始化目标的必要样式)
  function targetStyle() {
    // 略
  }

  // 初始化逻辑
  watch(
    () => [options.board.value, options.target?.value],
    async () => {
      const target = getTarget()

      if (options.board.value && target) {
        // 必须样式
        boardStyle()
        targetStyle()
        // 事件控制
        eventHandle()
        // 默认执行一次 自适应显示区域
        await fitSize()

        // 初始化完成(事件返回)
        options.onReady && options.onReady()
      }
    },
    {
      immediate: true,
    }
  )

  // 返回的内容
  return {
    target: targetInfoRef,
    board: boardInfoRef,
    methods: { fitSize },
  }
}

这里可以看出,整个初始化过程并不复杂,大概步骤为:

  • 获取并检查 可视区域、目标 节点
  • 赋予 可视区域、目标 节点必要的样式
  • 绑定一些事件的处理
  • 默认执行一次 自适应显示区域
  • 通知 初始化完成

下面一步步实现逻辑细节

获取目标节点

  function getTarget() {
    if (options.target?.value === void 0) {
      if (options.board.value !== void 0) {
        return options.board.value.children[0] as HTMLElement
      }
    }

    return options.target?.value
  }

如果没有通过配置传入,则获取显示区域的第一个子节点。

必要的样式

可视区域

  // 容器区域必须样式
  function boardStyle() {
    if (options.board.value) {
      const boardComputedStyle = getComputedStyle(options.board.value)
      options.board.value.style.overflow = 'hidden'
      options.board.value.style.userSelect = 'none'
      if (!['absolute', 'relative', 'fixed'].includes(boardComputedStyle.position)) {
        options.board.value.style.position = 'relative'
      }
    }
  }
  • 隐藏超出区域的内容
  • 不允许选择交互
  • position 必须是 absolute/relative/fixed 其中一个

    这里通过 getComputedStyle 判断节点当前的 position,如已经满足上述条件,则无需处理;否则,给予 position 为 relative 。
  // 目标区域必须样式
  function targetStyle() {
    const target = getTarget()

    if (target) {
      target.style.position = 'absolute'
      target.style.transform = 'scale(1)'
      target.style.transformOrigin = '0 0'
      target.style.userSelect = 'none'
      target.draggable = false
    }
  }
  • position 设置为 absolute
  • 初始化 transform 为 原始比例
  • 设置 transform 基于自身的左上角
  • 不允许选择交互
  • 不允许原生拖拉拽交互

事件绑定

  // 事件处理
  function eventHandle() {
    const target = getTarget()

    if (options.board.value && target) {
      options.board.value.addEventListener('wheel', eventHandlers.zoom)
      //
      options.board.value.addEventListener('mousedown', eventHandlers.dragStart)
      options.board.value.addEventListener('mousemove', eventHandlers.dragMove)
      options.board.value.addEventListener('mouseup', eventHandlers.dragEnd)
      options.board.value.addEventListener('mouseleave', eventHandlers.dragEnd)
      //
      options.board.value.addEventListener('contextmenu', eventHandlers.contextmenu)
      //
      const resizeObserver = new ResizeObserver(async () => {
        ;[state.boardWidth, state.boardHeight, state.boardLeft, state.boardTop] = await getSize(
          options.board.value
        )

        boardInfoRef.value = {
          width: state.boardWidth,
          height: state.boardHeight,
          left: state.boardLeft,
          top: state.boardTop,
        }

        options.onBoardChange && options.onBoardChange(boardInfoRef.value, { fitSize })
      })
      resizeObserver.observe(options.board.value)
    }
  }

上面除了给 可视区域节点 绑定 eventHandlers 内定义的处理方法外,这里还会通过 ResizeObserver 监听 可视区域节点 大小是否发生变化,如果发生变化,则通过 getSize 方法,获得它的大小信息,赋予 boardInfoRef,以及通过 事件 onBoardChange 返回。

getSize - 获取节点大小信息

  // 获取元素大小
  async function getSize(ele: HTMLElement | undefined): Promise<[number, number, number, number]> {
    function inner(resolve: (res: [number, number, number, number]) => void) {
      if (ele) {
        const { left, top } = ele.getBoundingClientRect()
        const [width, height] = [ele.clientWidth, ele.clientHeight]
        resolve([width, height, left, top])
      } else {
        resolve([0, 0, 0, 0])
      }
    }
    return new Promise((resolve) => {
      if (ele) {
        if (ele instanceof HTMLImageElement) {
          if (ele.complete) {
            inner(resolve)
          } else {
            ele.onload = () => {
              inner(resolve)
            }
          }
        } else {
          inner(resolve)
        }
      } else {
        resolve([0, 0, 0, 0])
      }
    })
  }

这里主要利用 API getBoundingClientRect 去获得节点的大小信息,只是这里考虑了如果节点是图片,需要异步获得 onload 完成后的真实显示大小。

再次获取图片大小,将通过 img 的 complete 属性判断,为 true 则已经加载过了,则可以直接获取 img 的大小。

状态定义

  // 状态值
  const state = reactive({
    lastLeft: 0, // 上次的left
    lastTop: 0, // 上次的top
    overX: 0, // 鼠标移动坐标x
    overY: 0, // 鼠标移动坐标y
    boardLeft: 0, // 容器区域距离浏览器左边距离
    boardTop: 0, // 容器区域距离浏览器上边距离

    startX: 0, // 长按开始坐标x
    startY: 0, // 长按开始坐标y
    isDown: false, // 鼠标是否长按中
    moveX: 0, // 长按移动坐标x
    moveY: 0, // 长按移动坐标y

    boardWidth: 0, // 容器区域宽
    boardHeight: 0, // 容器区高
    targetWidth: 0, // 目标区域宽
    targetHeight: 0, // 目标区域高
  })

用于计算缩放大小、坐标,特别是 事件处理 逻辑。

fitSize - 自适应显示区域

// 自适应大小
  async function fitSize(animate = false) {
    const target = getTarget()
    if (options.board.value && target) {
      // 记录容器、目标大小
      ;[state.boardWidth, state.boardHeight, state.boardLeft, state.boardTop] = await getSize(
        options.board.value
      )
      ;[state.targetWidth, state.targetHeight] = await getSize(target)
      //

      // 是否需要动画缩放
      if (animate) {
        target.style.transition = 'all 0.3s ease-in'
      }

      // 计算 可视区域 和 目标 的比例(用于 自适应显示区域 计算)
      const rateBoard = state.boardWidth / state.boardHeight
      const rateTarget = state.targetWidth / state.targetHeight

      // 计算 扣除内边距
      const [boardWidth, boardHeight] = [
        state.boardWidth - (options.padding?.[1] ?? 0) - (options.padding?.[3] ?? 0),
        state.boardHeight - (options.padding?.[0] ?? 0) - (options.padding?.[2] ?? 0),
      ]

      // 根据 可视区域 和 目标 的比例,横向/纵向 计算 zoom 缩放比例
      if (rateBoard > rateTarget) {
        zoom.value = boardHeight / state.targetHeight - 1
      } else if (rateBoard < rateTarget) {
        zoom.value = boardWidth / state.targetWidth - 1
      }

      // zoom 保留 2位 小数
      zoom.value = Math.floor(zoom.value * 100) / 100

      // 容错处理
      if (zoom.value > 0) {
        zoom.value = 0
      }
 
      // 根据 zoom、padding,计算横向位置
      left.value = Math.round(
        (boardWidth + (options.padding?.[3] ?? 0) - state.targetWidth * (1 + zoom.value)) / 2
      )
      
      // 根据 zoom、padding,计算纵向位置
      top.value = Math.round(
        (boardHeight + (options.padding?.[0] ?? 0) - state.targetHeight * (1 + zoom.value)) / 2
      )
      
      // 缓存位置信息(用于 事件处理)
      state.lastLeft = left.value
      state.lastTop = top.value

      // 更新目标的样式
      updateTargetStyle()

      // 动画结束后,移除其 transition 样式
      if (animate) {
        setTimeout(() => {
          if (target) {
            target.style.transition = 'none'
          }
        }, 300)
      }
    }
  }
细节说明,请留意注释文字

上面主要处理了:

  • 计算位置、缩放比例
  • 更新样式
  • 动画处理
  • 信息缓存

其实就是实现了类似 CSS 样式中大 object-fit: cover 效果!

updateTargetStyle - 更新目标样式

  function updateTargetStyle() {
    const target = getTarget()

    if (target) {
      target.style.transform = `scale(${zoom.value + 1})`
      target.style.left = `${left.value}px`
      target.style.top = `${top.value}px`

      targetInfoRef.value = {
        width: Math.round(state.targetWidth * (zoom.value + 1)),
        height: Math.round(state.targetHeight * (zoom.value + 1)),
        left: left.value,
        top: top.value,
        zoom: zoom.value,
      }

      options.onTargetChange && options.onTargetChange(targetInfoRef.value, { fitSize })
    }
  }

上面主要步骤:

  • 通过 transform 的 scale 设置目标的 zoom 缩放比例
  • 设置 left、top,作为目标的坐标
  • 记录 Ref 信息
  • 事件通知 onTargetChange

事件处理

const eventHandlers = {
    zoom: (e: WheelEvent) => {
      if (e.deltaY < 0) {
        // 鼠标上滚 - 缩小
        if (zoom.value <= options.zoomMax! - options.zoomSpeed!) {
          changeZoom(options.zoomSpeed!)
        }
      } else if (e.deltaY > 0) {
        // 鼠标下滚 - 放大
        if (zoom.value >= options.zoomMin! - 1 + options.zoomSpeed!) {
          changeZoom(-options.zoomSpeed!)
        }
      }

      e.preventDefault()
    },
    contextmenu: (e: MouseEvent) => {
      e.preventDefault()
    },
    dragStart: (e: MouseEvent) => {
      // 右键
      if (e.button === 0) {
        // 记录鼠标坐标
        state.startX = e.clientX
        state.startY = e.clientY
        // 按下状态
        state.isDown = true
      }
    },
    dragMove: (e: MouseEvent) => {
      // 当前鼠标位置(没有按下,也需要记录,计算所需)
      state.overX = e.clientX
      state.overY = e.clientY
      // 检查 按下状态
      if (state.isDown) {
        // 当前鼠标位置
        state.moveX = e.clientX
        state.moveY = e.clientY

        // 计算拖拽后的坐标
        left.value = Math.round(state.lastLeft + state.moveX - state.startX)
        top.value = Math.round(state.lastTop + state.moveY - state.startY)

        // 更新目标样式
        updateTargetStyle()
      }
    },
    dragEnd: () => {
      // 鼠标离开状态
      state.isDown = false
      // 缓存坐标信息
      state.lastLeft = left.value
      state.lastTop = top.value
    },
  }

上面主要是通过事件的处理,改变目标的缩放比例和坐标位置。

changeZoom - 改变目标比例

// 放大缩小
  function changeZoom(value: number) {
    const target = getTarget()

    if (options.board.value && target) {
      // 上次的 大小
      const lastTargetWidth = state.targetWidth * (1 + zoom.value)
      const lastTargetHeight = state.targetHeight * (1 + zoom.value)
      // 基于鼠标位置,计算上次的偏移量
      const lastOffsetX = state.overX - state.lastLeft - state.boardLeft
      const lastOffsetY = state.overY - state.lastTop - state.boardTop

      // 偏移量 相对于 大小的 比例
      const rateX = lastOffsetX / lastTargetWidth
      const rateY = lastOffsetY / lastTargetHeight

      // 更新缩放比例
      zoom.value += value
      zoom.value = Math.round(zoom.value * 100) / 100

      // 最新的 大小
      const newTargetWidth = state.targetWidth * (1 + zoom.value)
      const newTargetHeight = state.targetHeight * (1 + zoom.value)

      // 计算最新的偏移量
      const newSpanX = newTargetWidth * rateX - lastOffsetX
      const newSpanY = newTargetHeight * rateY - lastOffsetY

      // 更新位置
      left.value = Math.round(state.lastLeft - newSpanX)
      top.value = Math.round(state.lastTop - newSpanY)
      state.lastLeft = left.value
      state.lastTop = top.value
      
      // 更新样式
      updateTargetStyle()
    }
  }

改变目标缩放比例,需要考虑当前的鼠标位置,基于该位置作为缩放中心。这将会同时影响 left、top 的坐标位置。

这里的逻辑需要一边调试一边看效果,才能更直观的理解!

切换鼠标 cursor

  // 切换鼠标 cursor
  watch(
    () => state.isDown,
    () => {
      options.board.value &&
        (options.board.value.style.cursor = state.isDown ? 'pointer' : 'default')
    }
  )

最后,根据状态是否按下,切换鼠标指针样式。

关于 hook useZoomDrag 的完整代码,可以直奔这里

实现 组件

有了上面实现的 hook,基于它实现一个组件就很简单了:

import { ref } from 'vue'
import useZoomDrag from '@/lib/hooks/useZoomDrag'
import type { useZoomDragOptions, ZoomDragSize, ZoomDragMethods } from '../types'

// 保留部分 useZoomDrag 的配置项
const props = withDefaults(
  defineProps<Pick<useZoomDragOptions, 'zoomSpeed' | 'zoomMax' | 'zoomMin' | 'padding'>>(),
  {
    zoomSpeed: () => 0.1,
    zoomMax: () => 3,
    zoomMin: () => 0.2,
  }
)

// 组件即 可视区域
const boardRef = ref<HTMLElement>()

// 使用 useZoomDrag
const { target, board, methods } = useZoomDrag({
  board: boardRef,
  zoomSpeed: props.zoomSpeed,
  zoomMax: props.zoomMax,
  zoomMin: props.zoomMin,
  padding: props.padding,
  // 事件转换
  onReady: () => {
    emits('ready')
  },
  onTargetChange: (info: ZoomDragSize & { zoom: number }, methods: ZoomDragMethods) => {
    emits('target-change', info, methods)
  },
  onBoardChange: (info: ZoomDragSize, methods: ZoomDragMethods) => {
    emits('board-change', info, methods)
  },
})

const emits = defineEmits(['ready', 'target-change', 'board-change'])

// 暴露方法
defineExpose(methods)
<template>
  <div ref="boardRef">
    <slot
      v-bind="{
        target,
        board,
        methods,
      }"
    ></slot>
  </div>
</template>

是不是很简单呢?

实现 指令

指令也比较简单,只是存在一些限制,例如无法暴露 fitSize 方法:

    {
      mounted: (el, { value }: { value: Omit<useZoomDragOptions, 'board' | 'target'> }) => {
        useZoomDrag({
          ...value,
          board: ref(el),
        })
      },
    }

实现 插件

import { ref, type App } from 'vue'
import ZoomDrag from './components/ZoomDrag.vue'
import useZoomDrag from './hooks/useZoomDrag'
import type { useZoomDragOptions } from './types'

export default {
  // 供 app.use 使用
  install(app: App) {
    // 全局注册 组件
    app.component('ZoomDrag', ZoomDrag)
    
    // 全局注册 指令
    app.directive('zoom-drag', {
      mounted: (el, { value }: { value: Omit<useZoomDragOptions, 'board' | 'target'> }) => {
        useZoomDrag({
          ...value,
          board: ref(el),
        })
      },
    })
  },
}

篇幅较长,希望达到手把手分享的效果。

可以直达👇

示例仓库 | 示例文档 | 在线示例

别忘了,可以带话,给我一个 Star 哟!

xachary
1 声望0 粉丝

Be an entry-level front-end developer for a long time.