如何實現原生 JS 的拖拽效果

前言: 關於 “拖拽”,其實是一個老生常談的需求了,並且還是一個非常經典的面試題。之前在項目中拖拽的場景都是直接使用輪子,雖然很快就能完成設計需求,但是這個的原理一直都是我十分想去深入瞭解的一部分。

正好在今天的項目中再一次碰到了這個需求,我覺得是時候去探索一下它了。

tips: 本文不會使用 Draggable 去實現,而是會採用原生的 JS 鼠標移動鼠標點擊等事件去完成。並且你需要明確知道的一點是:🎁本文的最終目的並不是實現一個開箱即用的輪子,而是讓你理解拖拽實現的原理,知其然並知其所以然。 希望你可以有耐心和我一起完成下面的功能。

我們先看一下預覽效果:

一. 前期準備

  1. 這個需求實現要準備的文件很少,你只需要創建一個 .vue 文件即可快速開始接下來的實現,你可以自己動手寫出下面的樣式,也可以跳到源碼標題複製我的樣式來快速進行下一步。

  2. 樣式方面,在這裏我使用的是 UnoCSS ,將樣式內聯在了標籤裏,如果你還不瞭解這種寫法,你可以點擊下方的文章學習。不過即使你之前從未了解過 UnoCSS ,也不會影響你下面的閱讀,因爲樣式不是本文的重點,並不影響整體閱讀。
    🫱手把手教你如何創建一個代碼倉庫 [2]

  3. 在這裏我們簡化一下,我們暫時去掉不重要的 hover 動畫的影響,直接切入主題 “拖拽”

  4. 注意:爲了減少出現大量的屬性名導致本文理解起來難度會有些許提升的緣故,在這裏我們暫時不牽扯 Y 軸上的拖拽效果。你也不必擔心,因爲它和 X 軸上的移動原理是完全一樣的,還希望讀者學習之後可以自行推導出。

二. clickmouseDownmouseUp 的區別

  1. 首先用戶想要完成拖拽這一操作,它的動作裏肯定包含了鼠標按下的這個動作。在這裏比較容易和 click 事件搞混。首先我們要知道 click 事件是包含兩個動作的。一個是用戶鼠標按下,一個是用戶鼠標抬起。這兩個關鍵動作如果在一起則構成了我們的 click 事件。

  2. 在這裏我們補充一個額外的知識。注意上面劃紅線的一句話:

    click 事件會在 mousedown[3] 和 mouseup[4] 事件依次觸發後觸發。”

    其實理解起來也很簡單,就是當你同時給一個元素添加 clickmouseDownmouseup 的時候。雖然看起來 click 好像是由另外兩個事件組成的,但其實它們三個是相互獨立的事件。並且 click 的優先級會低一點點,會在這兩個事件執行完畢之後再去執行。

  3. 驗證一下,我們直接先給滑塊一個綁定這三個事件。
    我們看一下控制檯的輸出順序:

    說明我們上面的結論是沒問題的。

三. clientX 是什麼?

  1. 不過我們今天的主角是 onMousedown 這個事件,所以我們暫時先把另外兩位請下臺稍作休息。

  2. 我們點擊一下這個元素,在這裏我們需要用到當前點擊傳遞過來的事件對象身上的一些屬性。
    其實拖拽的關鍵點就在於如何利用這些屬性來動態改變滑塊的位置。

  3. 在這裏我們選擇使用 clientX 這個屬性。這裏如果大家對其它關於 X 的屬性不瞭解的話,還希望自行去了解一下,不屬於本文的範疇。你可以點擊這裏去了解其它屬性的含義。
    🫱你必須知道的關於座標軸的屬性 [5]
    在這裏我簡單介紹一下 clientX 代表的含義。

    假設我在滑塊黃色圓點處點擊了一下,那麼從可視區域的範圍內的最左側開始到這個黃點的距離就是 clientX
    爲什麼我在這裏要強調可視區域呢?我在下面的文章裏已經做出了非常詳細的介紹,感興趣的話可以自行查閱。
    [🫱你必須知道的 clientWidth, offsetWidth, scrollWidth](juejin.cn/post/719612…[6]

  4. 這個屬性對我們來說非常關鍵,聰明的你已經猜到了,它其實就代表着我們拖拽的起點座標,這裏我們需要把它保存到一個變量裏。

四. onMouseMove 和 onMouseUp 的使用

  1. 和上面的代碼大同小異,這裏我就不過多贅述。

  2. 綁定這個事件之後,我們會發現當我鼠標在滑塊內移動的時候,它就會執行。

  3. 但是這個效果並不是我們想要的,我們想要的是當我們鼠標按下的時候你開始記錄就可以了,不需要觸發的這麼頻繁。要達成也非常簡單,增加一箇中間變量 isDown,來記錄這個狀態即可。那麼隨之就需要搭配我們的 onMouseDownonMouseUp 來共同維護這個變量。

    我把這個變量值直接顯示在頁面上,接下來我們測試一下:
    可以看到已經暫時達到我們的需求。

  4. 到目前爲止我們的實現其實存在一個 bug。具體看下面:

    細心的讀者可能已經發現了一個問題,當我在滑塊內部按下鼠標後 isDown 的值變爲了 true,但是當我鼠標劃出滑塊內部然後抬起的時候,mouseup 事件並沒有被正確的執行。

  5. 最開始我在這裏迷惑了很久🤔,去 MDN 查閱相關事件的時候,並沒有發現任何相關的解釋。

  6. 但是我突然注意到了之前看到 Click 事件上的一段解釋。

  7. 由這句話我猜想是否應該把這個 onMouseUp 上移到最外層的元素上來呢?🤔 說幹就幹。

    然後我們驗證一下:

    嗯~現在我們的代碼應該是沒什麼問題了,可以接着進行下一步了。

  8. 這裏或許會有小夥伴迷惑,那我如果不在滑塊外面鬆開了,我依舊在滑塊內部鬆開呢?我們先驗證一下:

    可以看出,是絲毫不影響我們的效果的。
    奇怪🤔,這是爲什麼呢?

  9. 我們首先給滑塊一個不一樣的 onMouseUp 事件。

    經過上面的實驗,我猜你已經發現了,其實非常簡單,就是因爲事件冒泡的機制。雖然我們在滑塊內部鬆開了鼠標,但是由於事件冒泡,最外層 divonMouseUp 事件也被觸發了,所以正確的設置了 isDown 的狀態。

五. 拖拽效果的原理

  1. 解決了邊界問題,那麼我們現在就可以放心地去完成拖拽的效果了,彆着急寫代碼。首先讓我們分析一下拖拽的原理到底是什麼?

  2. 假設我在滑塊內部鼠標按下後,拖拽了一段距離然後鬆開了鼠標。我們用下圖的起點終點分別代表這兩個事件。

  3. 然後我們結合我們上面提到的關鍵屬性 clientX

    可以看出,我們滑塊滑動的距離其實就是 clienX值。

  4. 關鍵問題就來了,如何得出這個差值?其實非常非常簡單,我們的 onMouseMove 會被傳遞的那個事件對象上也存在一個 clientX屬性,那我的起點座標信息有了,這兩者相剪不就是我們想要的結果嗎?

六. 拖拽效果的實現

  1. 移動的距離有了,那麼接下來就是如何將這個滑塊動起來了,這裏我查閱了兩種方式,我們先介紹第一種。主要思路爲將滑塊更換爲 absolute 佈局,然後更改 left 值來完成。這裏我們先簡單實現一下,然後再講解它的弊端。

  2. 我們先給滑塊打上 ref,因爲之後我們要藉助 JS 去操作這個元素節點。

  3. 思路非常清晰,當我們鼠標按下 (onMouseDown) 的時候,要給滑塊設置 absolute

  4. 鼠標移動onMouseMove)的時候,將滑塊 left 的值修改爲差值

  5. 對了,別忘了需要給滑塊滑動範圍的外殼 div 設置 relative 屬性。

  6. 到這裏我們其實就可以看到簡單的效果了。

  7. 但是目前還會出現一個問題,如果我在滑動的時候鬆手,然後重新拖拽的時候,滑塊會從頭開始。

  8. 造成這個情況的原因也很簡單,理想情況下,假設你在中間鬆手之後重新拖拽了 10px 的距離。
    那麼根據我們現在的邏輯,其實你剛剛移動了 1px 的時候,我們的代碼馬上執行了 onMouseMove 函數。

    那麼它會馬上設置我們滑塊的 left1px,就造成了滑塊馬上回到了起點的現象。

  9. 解決方法也很簡單,當鼠標按下的時候,拿到起始的 left 值即可。

    然後我們在鼠標移動差值之前每次都加上初始值就 ok 了。

    我們看一下效果:

七. 更優雅的拖拽方案

  1. 在上面我們使用到了 absolute 定位,並且重複修改 left 的值。其實這樣的操作是會引起頁面的重排。在性能方面上的考慮來講,我們可以採取搭配 tansform 來去操作這個移動的效果,對性能方面考慮來講是更優的選擇。

  2. 並且實現起來更加簡單,我們只需要在滑塊移動的時候修改 tansform 屬性的 tanslateX 即可。

    效果如下:
    只是目前還是會出現在中間鬆手,然後重新拖拽會返回起點的情況,造成的原因和上面 absolute 的情況一樣,都是需要加上初始的值。

  3. 但是這裏獲取初始值的方法不太一樣。由於我們第一次調取 onMouseDown 的時候,我們的 onMouseMove 事件其實還沒觸發,所以我們的 transform 屬性有可能爲 字符串 String 格式的 null。並且這裏需要特別注意的一點是,我們拿到的 tansform 屬性是一個 matrix 函數的字符串表示形式。它並不是我們理想狀態下的 tansformX = 110 px 等這樣現成可以使用的值。

  4. 這裏我們如果要是使用的話的話,需要自己去通過字符串的一些方法去自行切割。
    而我們想要的數據就是切割好的數組中的第五個。

  5. 那麼對應的,在 onMouseMove 函數中直接使用即可。

    這是頁面的效果:

七. 源碼

<script setup lang="ts">
import { ref } from "vue";

const slider = ref<HTMLDivElement>();

const startPoint = ref<number>(0);
const isDown = ref<boolean>(false);

const premitiveX = ref<number>(0);

function onMouseDown(e: any) {
  isDown.value = true;
  const style = window.getComputedStyle(slider.value!);
  const { transform } = style;
  if (transform !== "none") {
    const matrixArr = transform.replace(/[^0-9\-,]/g, "").split(",");
    console.log("matrixArr", matrixArr);
    premitiveX.value = parseInt(matrixArr[4]);
  } else {
    premitiveX.value = 0;
  }
  const { clientX } = e;
  startPoint.value = clientX;
}

function onMosueUp(e: any) {
  isDown.value = false;
}

function onMouseMove(e: any) {
  if (!isDown.value) return;
  const { clientX } = e;
  const moveDistance = clientX - startPoint.value;
  const offset = premitiveX.value + moveDistance;
  console.log("offset", offset);
  slider.value!.style.transform = `translateX(${offset}px)`;
}
</script>

<template>
  <div @mouseup="onMosueUp">
    <div>
      <div
        ref="slider"
        @mousedown="onMouseDown"
        @mousemove="onMouseMove"
       
      >
        <span>滑塊</span>
      </div>
    </div>
  </div>
</template>
<style></style>
複製代碼

總結

最開始寫這個解鎖效果的時候,其實也查閱了很多教程,大部分都是直接教你如何使用 H5 draggble 這個標籤去實現的,但是我就在想 H5 之前人們是如何使用這個拖拽的呢?於是就自己去思考和動手嘗試,最終纔有了這篇文章。

隨之幾天我也會重新更新一篇使用 draggable 實現拖拽效果的文章,還是會秉持着通俗易懂的語言來和你一起學習這個知識點。與君共勉纔是我寫作的真正目的。

贈人玫瑰手有餘香~🌹

關於本文

作者:韓振方

https://juejin.cn/post/7204316982887514169

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Gawu4wCYSGNuWqKB5NAufQ