前言
提到元素拖拽,通常都會先想到用 HTML5 的拖拽放置 (Drag 和 Drop) 來實現(xiàn),它提供了一套完整的事件機(jī)制,看起來似乎是首選的解決方案,但實際卻不是那么美好,主要是它的樣式太過簡陋,無法實現(xiàn)更高級的用戶體驗:

這是瀏覽器默認(rèn)的拖拽效果,點住拖拽任意圖片或文字都會產(chǎn)生。
筆者因為之前有個小項目需要經(jīng)常參考稿定設(shè)計,一直有留意其元素拖拽的效果(如下圖),所以接下來我將以這種效果為藍(lán)本,使用原生 JS 實現(xiàn)一個富有動感的 自定義拖拽 效果,話不多說直接開摸。

實現(xiàn)原理
首先說下思路,我們需要知道鼠標(biāo)的三個事件,分別是 mousedown,mousemove,mouseup ,當(dāng)點擊按下的時候,克隆一個絕對定位的元素,并標(biāo)識下"拖拽中"的狀態(tài),接著在 mousemove 中就可以判斷應(yīng)該執(zhí)行的具體方法,從而讓元素隨著鼠標(biāo)移動起來。
在監(jiān)聽事件的 event 對象中,有幾個參數(shù)是比較重要的:clientX,clientY 標(biāo)識的鼠標(biāo)當(dāng)前橫坐標(biāo)和縱坐標(biāo),offsetX 和 offsetY 表示相對偏移量,可以在 mousedown 鼠標(biāo)按下時記錄初始坐標(biāo),在 mouseup 鼠標(biāo)抬起時判斷是否在目標(biāo)區(qū)域中,如果是則用鼠標(biāo)獲取到的當(dāng)前的偏移量 - 初始坐標(biāo)得到元素實際在目標(biāo)區(qū)域中的位置。
為了閱讀體驗,以下所有代碼均有部分省略,文末可查看完整源碼地址,代碼量并不多。
基礎(chǔ)界面
先簡單實現(xiàn)一個兩欄布局界面,并應(yīng)用上一些 CSS 效果:
<div id="app">
  <div class="slide">
    <div id="list">
      <img class="item" src="......." />
      <img  .........
    </div>
  </div>
  <div class="content"></div>
</div>
#app {
  width: 100vw;
  height: 100vh;
  display: flex;
}
.active {
  cursor: grabbing;
}
.slide {
  width: 260px;
  height: 100%;
  overflow: scroll;
  border-right: 1px solid rgba(0,0,0,.15);
  #list {
    user-select: none;
    .item {
      background: rgba(0,0,0,.15);
      width: 120px;
      display: inline-block;
      break-inside: avoid;  
      margin-bottom: 4px;
    }
    .item:hover {
      cursor: grab;
      filter: brightness(90%);
    }
    .item:active {
      cursor: grabbing;
    }
  }
  .grid {
      column-count: 2;
      column-gap: 0px;
  }
}
.slide::-webkit-scrollbar {
  display: none; /* Chrome Safari */
}
#content {
  position: relative;
  flex: 1;
  height: 100%;
  margin-left: 45px;
  background: rgba(0,0,0,.07);
  .item {
    position: absolute;
    transform-origin: top left;
  }
}利用濾鏡 filter: brightness(90%); 調(diào)節(jié)明亮度可以快速實現(xiàn)一個鼠標(biāo)覆蓋的動態(tài)效果,無需額外制作遮罩:

使用偽類激活 cursor 的 grab 和 grabbing 可以設(shè)置抓取動作的圖標(biāo):

實現(xiàn)元素抓取
利用事件委托機(jī)制為選擇列表添加 mousedown 事件監(jiān)聽,實現(xiàn)抓取的原理是在鼠標(biāo)按下時克隆按下的元素,并把克隆出來的元素設(shè)置成絕對定位,讓它"浮"起來:
let dragging = false
let cloneEl = null // 克隆元素
let initial = {} // 初始化數(shù)據(jù)記錄
......
// 選中了元素
cloneEl = e.target.cloneNode(true) // 克隆元素
cloneEl.classList.add('flutter') // 使其浮動
e.target.parentElement.appendChild(cloneEl) // 加入到列表中
dragging = true // 標(biāo)記拖動開始
// TODO:
........
.flutter {
  position: absolute;
  z-index: 9999;
  pointer-events: none;
}將鼠標(biāo)的坐標(biāo)設(shè)置為克隆元素的絕對定位值(left、top),就會像下圖所示這樣,此時減去 offset 偏移量,就能讓克隆元素覆蓋在本體上面。

初始化的值需要記錄起來方便后續(xù)計算,同時我們用 dragging 變量標(biāo)記了狀態(tài)(拖動中),接下來配合移動鼠標(biāo)的監(jiān)聽事件就能將元素“抓”起來了:
// 鼠標(biāo)移動
window.addEventListener("mousemove", (e) => {
  if (dragging && cloneEl) {
    // TODO:
    // x 軸(left)計算方法:e.clientX - initial.offsetX
    // y 軸(top)計算方法:e.clientY - initial.offsetY
  }
})

上面只是實現(xiàn)了元素的拖動,但是"克隆"的效果實在太明顯了,為了讓元素看起來更像是拖出來的而不是復(fù)制出來的,我們還要讓本體隱藏,同時DOM結(jié)構(gòu)不能丟失,這時只需在按下拖動時給本體元素設(shè)置個 opacity: 0,結(jié)束時再改回透明度1就能搞定。
雖然到這功能就算實現(xiàn)了,但實際效果還是有點僵硬,參考稿定設(shè)計中的元素放開時會固定回到一個位置,然后再收回去,這個過渡又有點鬼畜,不夠流暢。其實只需讓元素回退過程有一個自然地動畫就行,transition 就能實現(xiàn):
.is_return {
  transition: all 0.3s;
}// 鼠標(biāo)抬起
window.addEventListener("mouseup", (e) => {
  dragging = false
  if (cloneEl) {
      cloneEl.classList.add('is_return') // 加上過渡動畫
      changeStyle(......) // 設(shè)置回元素的初始位置
      setTimeout(() {
        cloneEl.remove() // 移除元素
      }, 300)
  }
})
最終我在動作結(jié)束時給克隆元素添加了過渡屬性,然后直接設(shè)置回初始坐標(biāo)讓克隆元素回到它的出生地點,用定時器在過渡動畫持續(xù)的相同時間后移除克隆元素,這樣就有了一個平滑穩(wěn)定的回退動畫。

性能優(yōu)化
由于在改變元素狀態(tài)的過程中需要頻繁進(jìn)行多個 CSS 操作,為降低回流重繪的成本,最好將多個操作合并起來處理,這里利用了 cssText 來實現(xiàn):
// 改變漂浮元素:x、y、縮放倍率
function moveFlutter(x, y, d = 0){
  const scale = d ? initial.width + d < initial.fakeSize ? `transform: scale(${(initial.width + d) / initial.width})` : null : null
  const options = [`left: ${x}px`, `top: ${y}px`]
  scale && options.push(scale)
  // 將CSS處理成數(shù)組,然后丟進(jìn)DOM操作方法中一次執(zhí)行
  changeStyle(options)
}
// 合并多個操作
function changeStyle(arr){
  const original = cloneEl.style.cssText.split(';')
  original.pop()
  cloneEl.style.cssText = original.concat(arr).join(';') + ';'
}
實現(xiàn)拖拽放大
放大我們可以使用 transform: scale 來實現(xiàn),只需要將拖動位置之間的距離當(dāng)做變化系數(shù)(假設(shè)為d),那么scale變化數(shù)值即為(元素寬度 + d)/元素寬度,而放大的最終倍數(shù)必定為 圖片實際寬度/元素的寬度,只要判斷不超過這個邊界就可以。(這個圖片實際寬高在真實業(yè)務(wù)場景中建議在上傳資源時就記錄在數(shù)據(jù)庫,這里我是模擬的隨機(jī)一個原圖尺寸)。

兩點間距離計算公式為:

代碼實現(xiàn):
// 計算兩點之間距離
function distance({ clientX, clientY }){
  const { clientX: x, clientY: y } = initial // 獲取初始的坐標(biāo)
  const b = clientX - x;
  const a = clientY - y;
  return Math.sqrt(Math.pow(b, 2) + Math.pow(a, 2))
}
window.addEventListener("mousemove", (e) => {
  if (dragging && cloneEl) {
    const d = distance(e) // 計算距離
    moveFlutter(e.clientX - initial.offsetX, e.clientY - initial.offsetY, d)
  }
})
function moveFlutter(x, y, d = 0){
  let scale = ''
  // 如果距離大于0,且寬度+距離小于實際寬度
  if( d && initial.width + d <= initial.fakeSize ) {
      scale = `transform: scale(${(initial.width + d) / initial.width})`
  }
  // TODO ... changeStyle ...
}
效果演示:

注意元素都要設(shè)置 transform-origin: top left; 改變縮放原點到左上角,否則默認(rèn)(中心為原點)的轉(zhuǎn)換會發(fā)生比較明顯的偏移。
實現(xiàn)放置
其實拖拽放置有點像是"復(fù)制"與"粘貼",前面我們實現(xiàn)了復(fù)制,放置主要就是將元素粘貼到畫布當(dāng)中,流程步驟如下:
- 如果鼠標(biāo)在目標(biāo)區(qū)域,拷貝元素到畫布中,如果不在畫布中,執(zhí)行倒退動畫
 - 刪除元素
 
// 完成處理
function done(x, y) {
  if (!cloneEl) { return }
  const newEl = cloneEl.cloneNode(true)
  newEl.classList.remove('flutter')
  newEl.src = cloneEl.getAttribute('raw') // 設(shè)置原圖地址
  newEl.style.cssText = `left: ${x - initial.offsetX}px; top: ${y - initial.offsetY}px;`
  document.getElementById('content').appendChild(newEl)
  // TODO:
}
判斷是否在畫布內(nèi)抬起很簡單,往畫布上綁定mouseup監(jiān)聽事件即可,克隆的新元素必須刪除無用的屬性和class,此時設(shè)置元素的left、top即可將元素放置進(jìn)畫布中,關(guān)鍵點在于畫布內(nèi)的target有可能是錯的,因為如果鼠標(biāo)抬起的區(qū)域已經(jīng)放置了元素,那么相對偏移量就得我們自己計算了,使用getBoundingClientRect方法獲取畫布本身相對于視窗的偏移,鼠標(biāo)坐標(biāo)減去畫布本身的偏移就是元素在畫布中的位置了。

document.getElementById('content').addEventListener("mouseup", (e) => {
  if (e.target.id !== 'content') {
    const lostX = e.x - document.getElementById('content').getBoundingClientRect().left
    const lostY = e.y - document.getElementById('content').getBoundingClientRect().top
    done(lostX, lostY)
  } else { done(e.offsetX, e.offsetY) }
})只貼了部分關(guān)鍵代碼,完整代碼文末查看。
邊界判斷
如果不對邊界情況進(jìn)行處理可能會導(dǎo)致拖動時發(fā)生意外的中斷,無法正確回收克隆元素。
// 鼠標(biāo)離開了視窗
document.addEventListener("mouseleave", (e) {
  end()
})
// 用戶可能離開了瀏覽器
window.onblur = () {
  end()
}
體驗優(yōu)化
參考稿定設(shè)計中元素拖拽是直接賦值原圖的,原圖大小通常無法控制,免不了需要加載時間,造成卡頓空白的問題,在網(wǎng)絡(luò)不夠快時體驗尤其尷尬:

我的優(yōu)化思路是利用瀏覽器加載過同一張圖片就會優(yōu)先讀緩存的機(jī)制,先用一個Image加載原圖,等其加載完畢再把拖拽元素的src改成原圖,這樣瀏覽器會"自動"幫我們優(yōu)化這個過程,只需要注意一點,由于這是個異步任務(wù),所以一定要做好對應(yīng)標(biāo)記,不然手速快的時候控制不好觸發(fā)順序。
function simulate(url, flag) {
  cloneEl.setAttribute('raw', url)
  const image = new Image()
  image.src = url
  image.onload = function () {
    // 異步任務(wù),克隆節(jié)點可能已不存在,flag標(biāo)記是否拖動的還是當(dāng)前目標(biāo)
    cloneEl && initial.flag === flag && (cloneEl.src = url)
  }
}效果演示,故意加大了圖片的分辨率差異:

以上就是文章的全部內(nèi)容,感謝看到這里,希望對你有所幫助或啟發(fā)!創(chuàng)作不易,如果覺得文章寫得不錯,可以點贊收藏支持一下,也歡迎關(guān)注,我會更新更多實用的前端知識與技巧,我是茶無味的一天,期待與你共同成長~
相關(guān)鏈接
[1] 完整代碼地址: https://juejin.cn/post/7145447742515445791/#heading-9
?[2] 關(guān)于作者: https://book.palxp.com