博客 / 詳情

返回

手把手實現一個圖片可縮放可拖拽的 Vue3 組件

在工作中經常遇到需要預覽一張尺寸可能非常大的圖片,初始化顯示的時候,希望它自適應顯示區域後,還可以縮放並可以在顯示區域中拖拽。

在這裏,手把手展示一下如何實現一個簡單的組件,以實現上述的需求。

效果展示

先看看效果

在這裏插入圖片描述

可以直達👇

示例倉庫 | 示例文檔 | 在線示例

別忘了,可以帶話,給我一個 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 喲!
user avatar jidongdehai_co4lxh 頭像 tigerandflower 頭像 yaofly 頭像 coderleo 頭像 codepencil 頭像 zbh 頭像 iymxpc3k 頭像 huanjinliu 頭像 nihaojob 頭像 layouwen 頭像
10 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.