天天看點

原生 JS 手寫一個優雅的圖檔預覽功能,帶你吃透背後原理

前言

本文将用一個極簡的例子詳細講解如何用原生JS一步步實作完整的圖檔預覽和檢視功能,無任何第三方依賴,相容PC與H5,實作了觸屏雙指縮放等,幹貨滿滿。

為提升閱讀體驗,正文中代碼展示均有部分省略處理,完整代碼連結放在了文末。

實作原理

實作圖檔預覽/檢視的關鍵在于 CSS3 中的

transform

變換,該屬性應用于元素在2D或3D上的旋轉,縮放,移動,傾斜等等變換,通過設定

translate(x,y)

即可偏移元素位置,設定

scale

即可縮放元素,當然你也可以隻設定

matrix

來完成上述所有操作,這涉及到矩陣變換的知識,本文使用的均是CSS提供的文法糖進行變換操作。

PC上的點選、移動,H5的手勢操作,都離不開DOM事件監聽。例如滑鼠移動事件對應

mousemove

,移動端因為沒有滑鼠則對應

touchmove

,而本文将介紹如何僅通過指針事件來進行多端統一的事件監聽。在監聽事件中我們可以通過

event

對象擷取各種屬性,例如常用的

offsetX

offsetY

相對偏移量,

clientX

clientY

距離視窗的橫坐标和縱坐标等。

除此之外可能還需要具備一點數學基礎,如果像我這樣數學知識幾乎都還給了高中老師的話可以複習下向量的加減計算。

打開蒙層

在開始前我們先準備一個圖檔清單,并綁定好點選事件,當點選圖檔時,通過

document.createElement

建立元素,然後把圖檔節點克隆進蒙層中,這對你來說并不難,簡單實作如下。

<div id="list">
    <img class="item" src="...." />
    ............
</div>
           
/* 圖檔預覽樣式 */
.modal {
  touch-action: none;
  user-select: none;
  position: fixed;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
  background-color: rgba(0, 0, 0, 0.75);
}
.modal > img {
  position: absolute;
  padding: 0;
  margin: 0;
  transform: translateZ(0);
}
           
let cloneEl = null
let originalEl = null
document.getElementById('list').addEventListener('click', function (e) {
  e.preventDefault()
  if (e.target.classList.contains('item')) {
    originalEl = e.target // 緩存原始圖DOM節點
    cloneEl = originalEl.cloneNode(true) // 克隆圖檔
    originalEl.style.opacity = 0
    openPreview() // 打開預覽
  }
})

function openPreview() {
  // 建立蒙層
  const mask = document.createElement('div')
  mask.classList.add('modal')
  // 添加在body下
  document.body.appendChild(mask)
  // 注冊蒙層的點選事件,關閉彈窗
  const clickFunc = function () {
    document.body.removeChild(this)
    originalEl.style.opacity = 1
    mask.removeEventListener('click', clickFunc)
  }
  mask.addEventListener("click", clickFunc)
  mask.appendChild(cloneEl) // 添加圖檔
}

// 用于修改樣式的工具類,并且可以減少回流重繪,後面代碼中會頻繁用到
function changeStyle(el, arr) {
  const original = el.style.cssText.split(';')
  original.pop()
  el.style.cssText = original.concat(arr).join(';') + ';'
}
           

這時候我們成功添加一個打開預覽的蒙層效果了,但克隆出來的圖檔位置是沒有指定的,此時需要用

getBoundingClientRect()

方法擷取一下元素相對于可視視窗的距離,設定為圖檔的起始位置,覆寫在原圖檔的位置之上,以取代文檔流中的圖檔。

// ......
// 添加圖檔
const { top, left } = originalEl.getBoundingClientRect()
changeStyle(cloneEl, [`left: ${left}px`, `top: ${top}px`])
mask.appendChild(cloneEl)
           

效果如下,看起來像點選高亮圖檔的感覺:

原生 JS 手寫一個優雅的圖檔預覽功能,帶你吃透背後原理

接下來我們需要實作焦點放大的效果,簡單來說就是計算兩點之間的位移距離作為

translate

偏移量,将圖檔偏移到螢幕中心點位置,然後縮放一定的比例來達到檢視效果,通過

transition

實作過渡動畫。

原生 JS 手寫一個優雅的圖檔預覽功能,帶你吃透背後原理

中心點位置我們可以通過

window

下的

innerWidth

innerHeight

來擷取浏覽器可視區域寬高,然後除以2即可得到中心點坐标。

const { innerWidth: winWidth, innerHeight: winHeight } = window

// 計算自适應螢幕的縮放值
function adaptScale() {
  const { offsetWidth: w, offsetHeight: h } = originalEl // 擷取文檔中圖檔的寬高
  let scale = 0
  scale = winWidth / w
  if (h * scale > winHeight - 80) {
    scale = (winHeight - 80) / h
  }
  return scale
}

// 移動圖檔到螢幕中心位置
  const originalCenterPoint = { x: offsetWidth / 2 + left, y: offsetHeight / 2 + top }
  const winCenterPoint = { x: winWidth / 2, y: winHeight / 2 }
  const offsetDistance = { left: winCenterPoint.x - originalCenterPoint.x + left, top: winCenterPoint.y - originalCenterPoint.y + top }
  const diffs = { left: ((adaptScale() - 1) * offsetWidth) / 2, top: ((adaptScale() - 1) * offsetHeight) / 2 }
  changeStyle(cloneEl, ['transition: all 0.3s', `width: ${offsetWidth * adaptScale() + 'px'}`, `transform: translate(${offsetDistance.left - left - diffs.left}px, ${offsetDistance.top - top - diffs.top}px)`])
  // 動畫結束後消除定位重置的偏差
  setTimeout(() => {
    changeStyle(cloneEl, ['transition: all 0s', `left: 0`, `top: 0`, `transform: translate(${offsetDistance.left - diffs.left}px, ${offsetDistance.top - diffs.top}px)`])
    offset = { left: offsetDistance.left - diffs.left, top: offsetDistance.top - diffs.top } // 記錄值
  }, 300)
           

這裡先利用絕對定位

left

top

來設定克隆元素的初始位置,再通過

translate

偏移位置,是為了更自然地實作動畫效果,動畫結束後再将絕對定位的數值歸零并把偏移量加進

translate

中,并且這裡我并沒有直接使用

scale

放大元素,而是将比例轉化為寬高的變化。最終效果如下:

原生 JS 手寫一個優雅的圖檔預覽功能,帶你吃透背後原理

圖檔縮放(PC)

在PC實作圖檔縮放相對是比較簡單的,我們利用滾輪事件監聽并改變

scale

值即可。重點是利用

deltaY

值的正負來判斷滾輪是朝上還是朝下:

let origin = 'center'
let scale = 1
// 注冊事件
mask.addEventListener('mousewheel', zoom, { passive: false })

// 滾輪縮放
const zoom = (event) => {
  if (!event.deltaY) {
    return
  }
  event.preventDefault()
  origin = `${event.offsetX}px ${event.offsetY}px`
  
  if (event.deltaY < 0) {
    scale += 0.1 // 放大
  } else if (event.deltaY > 0) {
    scale >= 0.2 && (scale -= 0.1) // 縮小
  }
  changeStyle(cloneEl, ['transition: all .15s', `transform-origin: ${origin}`, `transform: translate(${offset.left + 'px'}, ${offset.top + 'px'}) scale(${scale})`])
}
           

因為縮放始終都以圖檔中心為原點進行縮放,這顯然不符合我們的操作習慣,是以在上面的代碼中,我們通過滑鼠目前的偏移量即

offsetX、offsetY

的值改變

transform-origin

來動态設定縮放的原點,效果如下:

原生 JS 手寫一個優雅的圖檔預覽功能,帶你吃透背後原理
乍一看好像沒什麼問題,事實上如果滑鼠不斷移動且幅度很大時會出現抖動,需要消除原點位置突然改變帶來的影響才能完全解決這個問題(起初我并未發現,後面在做移動端縮放時簡直是災難級體驗)而由于PC上問題并不明顯,這裡先按下不表,後面會詳細提到。

移動檢視

由于縮放導緻圖像發生變化,我們自然地想到要靠移動來觀察圖檔,此時展現在PC端的是按住滑鼠拖拽,移動端則是手指點選滑動,而兩者各自的事件監聽顯然并不共通,如以移動事件為例,PC端對應的是

mousemove

事件而移動端則是

touchmove

事件,這就意味着如果我們要做到相容多端,就必須注冊多個事件監聽。

那麼有沒有一種事件可以做到同時監聽滑鼠操作和手指操作呢?答案是有的!那就是 指針事件(Pointer events),它被設計出來就是為了便于提供更加一緻與良好的體驗,無需關心不同使用者和場景在輸入硬體上的差異。接下來我們就以此事件為基礎來完成各項操作功能。

指針 是輸入裝置的硬體層抽象(比如滑鼠,觸摸筆,或觸摸屏上的一個觸摸點),它能指向一個具體表面(如螢幕)上的一個(或一組)坐标,可以表示包括接觸點的位置,引發事件的裝置類型,接觸表面受到的壓力等。

PointerEvent

 接口繼承了所有 

MouseEvent

 中的屬性,以保障原有為滑鼠事件所開發的内容能更加有效的遷移到指針事件。

移動圖檔的實作是比較簡單的,在每次指針按下時我們記錄

clientX

clientY

為初始值,移動時計算目前的值與初始點位的內插補點加到

translate

偏移量中即可。需要注意的是每次移動事件結束時都必須更新初始點位,否則膨脹的偏移距離會使圖檔加速逃逸可視區域。另外當擡起動作結束時,會觸發

click

事件,是以注意加入全局變量标記以及定時器進行一些判斷處理。

let startPoint = { x: 0, y: 0 } // 記錄初始觸摸點位
let isTouching = false // 标記是否正在移動
let isMove = false // 正在移動中,與點選做差別

// 滑鼠/手指按下
window.addEventListener('pointerdown', function (e) {
  e.preventDefault()
  isTouching = true
  startPoint = { x: e.clientX, y: e.clientY }
})
// 滑鼠/手指擡起
window.addEventListener('pointerup', function (e) {
  isTouching = false
  setTimeout(() => {
    isMove = false
  }, 300);
})
// 滑鼠/手指移動
window.addEventListener('pointermove', (e) => {
  if (isTouching) {
    isMove = true
    // 單指滑動/滑鼠移動
    offset = {
      left: offset.left + (e.clientX - startPoint.x),
      top: offset.top + (e.clientY - startPoint.y),
    }
    changeStyle(cloneEl, [`transform: translate(${offset.left + 'px'}, ${offset.top + 'px'}) scale(${scale})`, `transform-origin: ${origin}`])
    // 注意移動完也要更新初始點位,否則圖檔會加速逃逸可視區域
    startPoint = { x: e.clientX, y: e.clientY }
  }
})
           
原生 JS 手寫一個優雅的圖檔預覽功能,帶你吃透背後原理

雙指縮放(移動端)

TouchEvent

的事件對象中,我們可以找到

touches

這個數組,在移動端通常都是利用這個數組來判斷觸點個數的,例如

touches.length > 1

即是多點操作,這是我們實作雙指縮放的基礎。但在 指針事件 中卻找不到類似的對象(MDN對其描述隻是擴充了

MouseEvent

的接口),想來

Touch

對象隻服務于

TouchEvent

這點其實也可以了解,是以要自己實作對觸摸點數的記錄。

這裡我們使用

Map

數組對觸摸點進行記錄(通過這個執行個體你可以看到

Map

數組純

api

操作增删改有多麼優雅)。其中我們利用

pointerId

辨別觸摸點,移動事件中根據事件對象的

pointerId

來更新對應觸點(指針)的資料,當觸點擡起時則從

Map

中删除點位:

let touches = new Map() // 觸摸點數組

window.addEventListener('pointerdown', function (e) {
  e.preventDefault()
  touches.set(e.pointerId, e) // TODO: 點選存入觸摸點
  isTouching = true
  startPoint = { x: e.clientX, y: e.clientY }
  if (touches.size === 2) { 
        // TODO: 判斷雙指觸摸,并立即記錄初始資料
  }
})

window.addEventListener('pointerup', function (e) {
  touches.delete(e.pointerId) // TODO: 擡起時移除觸摸點
  // .....
})

window.addEventListener('pointermove', (e) => {
  if (isTouching) {
    isMove = true
    if (touches.size < 2) {
      // TODO: 單指滑動,或滑鼠拖拽
    } else {
      // TODO: 雙指縮放
      touches.set(e.pointerId, e) // 更新點位資料
      // .......
    }
  }
})
           

Map

是二維數組,可以利用

Array.from

轉成普通數組即可通過

index

下标取值。

簡單在手機浏覽器上測試後發現,這個數組偶爾會不停增加(例如在滑動頁面時),也就是

pointerup

會出現不能正确删除對應點位的情況,或者說被意外中斷了,此時會觸發

pointercancel

事件監聽(對應

touchcancel

事件),我們必須在這裡清空數組,這是容易被忽略的一點,原本

TouchEvent

中的

touches

并不需要處理。

window.addEventListener('pointercancel', function (e) {
  touches.clear() // 可能存在特定事件導緻中斷,是以需要清空
})
           

現在我們有了對觸摸點判斷的基礎,就可以開始實作縮放了,當雙指接觸螢幕時,記錄兩點間距離作為初始值,當雙指在螢幕上捏合,兩點間距不停發生變化,此時存在一個變化比例 =

目前距離 / 初始距離

,該比例作為改變

scale

的系數就能得到新的縮放值。

原生 JS 手寫一個優雅的圖檔預覽功能,帶你吃透背後原理
在上一篇文章手寫拖拽效果中我也講到了如何在JS中使用數學方法計算兩點間距離,下面介紹另一種常見的簡潔寫法,

Math.hypot()

函數傳回其參數的平方和的平方根:
原生 JS 手寫一個優雅的圖檔預覽功能,帶你吃透背後原理
// 坐标點1: start,坐标點2: end,求兩點距離:
Math.hypot(end.x - start.x, end.y - start.y)
// 是以為什麼上面代碼可以計算兩點距離,因為等價于:
Math.sqrt(Math.pow(end.x - start.x, 2) + Math.pow(end.y - start.y, 2))
           

回到代碼中,直接取出

touches

的前兩個點位,于兩點間擷取距離:

// 擷取距離
function getDistance() {
  const touchArr = Array.from(touches)
  if (touchArr.length < 2) {
    return 0
  }
  const start = touchArr[0][1]
  const end = touchArr[1][1]
  return Math.hypot(end.x - start.x, end.y - start.y)
}
           

繼續完善上面的事件監聽代碼:

let touches = new Map() // 觸摸點數組
let lastDistance = 0 // 記錄最後的雙指初始距離
let lastScale = 1 // 記錄下最後的縮放值

window.addEventListener('pointerdown', function (e) {
  // .........
  if (touches.size === 2) { // TODO: 判斷雙指觸摸,并立即記錄初始資料
    lastDistance = getDistance()
    lastScale = scale // 把目前的縮放值存起來
  }
})

window.addEventListener('pointerup', function (e) {
// .........
  if (touches.size <= 0) {
    // .........
  } else {
    const touchArr = Array.from(touches)
    // 雙指如果擡起了一個,可能還有單指停留在觸屏上繼續滑動,是以更新點位
    startPoint = { x: touchArr[0][1].clientX, y: touchArr[0][1].clientY }
  }
  // .......
})

window.addEventListener('pointermove', (e) => {
  e.preventDefault()
  if (isTouching) {
    isMove = true
    if (touches.size < 2) { // 單指滑動
      // .......
    } else {
      // 雙指縮放
      touches.set(e.pointerId, e)
      const ratio = getDistance() / lastDistance // 比較距離得出比例
      scale = ratio * lastScale // 修改新的縮放值
      changeStyle(cloneEl, ['transition: all 0s', `transform: translate(${offset.left + 'px'}, ${offset.top + 'px'}) scale(${scale})`, `transform-origin: ${origin}`])
    }
  }
})
           

以上僅是實作了縮放的處理,而縮放原點還在預設的圖檔中心,就和PC端一樣我們還要改變原點才顯得自然,對于雙指縮放來說,改變的隻是兩點間距離,無論雙指間距如何改變,兩點連成的線段中心點是不會變的,是以我們隻要通過兩點求出中心點坐标然後設定為縮放原點坐标即可:

原生 JS 手寫一個優雅的圖檔預覽功能,帶你吃透背後原理
window.addEventListener('pointermove', (e) => {
  // .......
      // 雙指縮放
      const ratio = getDistance() / lastDistance // 比較距離得出比例
      scale = ratio * lastScale // 修改新的縮放值    
      const touchArr = Array.from(touches)
      const start = touchArr[0][1]
      const end = touchArr[1][1]
      x = (start.offsetX + end.offsetX) / 2
      y = (start.offsetY + end.offsetY) / 2
      origin = `${x}px ${y}px`
      changeStyle(cloneEl, ['transition: all 0s', `transform: translate(${offset.left + 'px'}, ${offset.top + 'px'}) scale(${scale})`, `transform-origin: ${origin}`])
  // ........
})
           

這時縮放感覺是沒有問題了,但是每當往螢幕中的不同位置再多進行幾次操作時,圖檔會突然間閃動一下位置,到最後幾乎不受控制。

這就回到前面提到的,原點位置突然改變帶來的偏移量引起了圖檔位置的閃動,這段偏移是如何産生的呢?我們畫兩張圖看下,在原點變化的前後圖像的坐标點發生了哪些變化:

原生 JS 手寫一個優雅的圖檔預覽功能,帶你吃透背後原理

如上圖,原點為 O 時,我們取右下角的點設為點 A,圖像放大2倍時 A 點變換到 B 點。

原生 JS 手寫一個優雅的圖檔預覽功能,帶你吃透背後原理

而當原點突然變為 O’ 時,點 A 在圖像放大2倍時則變換到了 B' 點。

我們可以把圖像的偏移抽象為圖像某個點位的偏移,這樣問題就變成了計算 BB' 的距離:

原生 JS 手寫一個優雅的圖檔預覽功能,帶你吃透背後原理

設原點 O=(Ox , Oy),點 A=(x, y),縮放值為 s,OA 向量乘縮放倍數得出 OB 的向量:

原生 JS 手寫一個優雅的圖檔預覽功能,帶你吃透背後原理

點 B 坐标就等于 OB 向量加上原點 O 的坐标:

原生 JS 手寫一個優雅的圖檔預覽功能,帶你吃透背後原理

同理得出點 B' 的坐标:

原生 JS 手寫一個優雅的圖檔預覽功能,帶你吃透背後原理

BB' 的距離就是兩點相減後的結果,兩點已在上面得出,代入計算過程這裡就不多寫了,最終化簡的結果如下:

原生 JS 手寫一個優雅的圖檔預覽功能,帶你吃透背後原理

在進行縮放時我們主動改變

scale

的值,那麼 s 是已知的,雙指落下時是我們主動改變了縮放原點,(Ox , Oy) 和 (O'x , O'y) 這兩個點也是已知的,那麼根據上面的式子就可以得出 BB' 的實際距離了,也就是圖像的偏移量。這麼說有點抽象,我們還是回到代碼中,在雙指縮放時将這個偏移量減掉,同樣的在PC端的縮放中,我們也加入對偏移量的修正:

let scaleOrigin = { x: 0, y: 0, }
// 擷取中心改變的偏差
function getOffsetCorrection(x = 0, y = 0) {
  const touchArr = Array.from(touches)
  if (touchArr.length === 2) {
    const start = touchArr[0][1]
    const end = touchArr[1][1]
    x = (start.offsetX + end.offsetX) / 2
    y = (start.offsetY + end.offsetY) / 2
  }
  origin = `${x}px ${y}px`
  const offsetLeft = (scale - 1) * (x - scaleOrigin.x) + offset.left
  const offsetTop = (scale - 1) * (y - scaleOrigin.y) + offset.top
  scaleOrigin = { x, y }
  return { left: offsetLeft, top: offsetTop }
}

window.addEventListener('pointermove', (e) => {
  // .......
      // 雙指縮放
      touches.set(e.pointerId, e)
      const ratio = getDistance() / lastDistance
      scale = ratio * lastScale
      offset = getOffsetCorrection()
      changeStyle(cloneEl, ['transition: all 0s', `transform: translate(${offset.left + 'px'}, ${offset.top + 'px'}) scale(${scale})`, `transform-origin: ${origin}`])
  // ........
})

// 滾輪縮放
const zoom = (event) => {
  // ........
  offset = getOffsetCorrection(event.offsetX, event.offsetY)
  changeStyle(cloneEl, ['transition: all .15s', `transform-origin: ${origin}`, `transform: translate(${offset.left + 'px'}, ${offset.top + 'px'}) scale(${scale})`])
}
           

最終雙指縮放效果如下,啊~ 如此絲滑,不由得流下兩行熱淚

原生 JS 手寫一個優雅的圖檔預覽功能,帶你吃透背後原理

一些細節

有些在正文中未提及,但同樣重要的小細節。

是什麼阻止了滾動?

雖然浏覽器滾動對應的其實是

scroll

事件,但我們在PC上滾動通常都是用利用滾輪(筆記本觸控闆也被視作滾輪),是以在滾輪事件中阻止系統預設事件也就阻止了滾動,但不是完全阻止,因為滾動條沒隐藏的話還是可以拖動來滾動頁面的,在本文例子中并沒有針對滾動做什麼處理,如果需要完全禁止滾動,應該在打開彈窗時為

body

設定

overflow

'hidden'

注意滾輪事件(

wheel

)是可以觸發冒泡捕獲的,而滾動事件(

scroll

)卻無法觸發冒泡,了解更多可以看我之前的一篇文章:哪些浏覽器事件不會冒泡。

至于移動端又是為什麼阻止了滾動呢?得益于一個強大的

CSS

屬性,可能在開頭布局部分你就發現了這個屬性,沒錯,這裡為彈層遮罩設定了

touch-action: none;

進而阻止了所有手勢效果,自然也就不會發生頁面滾動。該屬性在平時的業務代碼中也可用于優化移動端性能、解決

touchmove

passive

報錯等,這個我在之前另一篇文章中有提到,感興趣可以看看:一行CSS提升頁面滾動性能。

translateZ(0) 有什麼用?

在本例的代碼中這個CSS本身是沒有意義的,為的隻是觸發css3硬體加速來提升性能,那為什麼不直接使用

translate3d()

呢?又或者使用

will-change: transform;

來告訴浏覽器提升渲染性能呢?

正常圖檔顯示 使用了 translate3d 之後
原生 JS 手寫一個優雅的圖檔預覽功能,帶你吃透背後原理
原生 JS 手寫一個優雅的圖檔預覽功能,帶你吃透背後原理

答案是後兩者都會使移動端的圖檔變模糊!(Android似乎不會)起初我發現圖檔在手機上模糊的問題時,調試很久都沒定位到源頭,一籌莫展之際想起以前做H5網頁常使用 vant 架構,就想要不看看它源碼中的圖檔預覽元件吧,很快我找到相關代碼位置,代碼截圖:

原生 JS 手寫一個優雅的圖檔預覽功能,帶你吃透背後原理

從代碼片段中我看到 vant 并沒有使用任何

translate3d

scale3d

屬性,看來是真的有坑了。其實我們使用

translate3d

提升性能也是把第三個參數一直設定為

(2d平面沒有Z軸)來實作的,這和

translateZ(0)

是等價的。

但奇怪的是單獨設定

translateZ

卻沒有引發問題。。

原生 JS 手寫一個優雅的圖檔預覽功能,帶你吃透背後原理

結束