造完一個移動端picker輪子後的體驗

前言

最近用typescript造了一個移動端的picker插件,同時支持jsvue組件調用,此次去嘗試了不少不同比較有創新的思路,將比較創新的思路點和遇到的問題作成了筆記分享給你們javascript

預覽

首先咱們看一看實現的demo效果css

非聯動

省市區聯動

省市區異步聯動

demo網址

具體的demo演示網頁能夠點擊這裏查看html

使用方法

使用方法能夠查看咱們的github倉庫,咱們提供了豐富的demo沙盒演示,若是以爲不錯,能夠start支持一下vue

特色

1. 仿ios漸進動畫

什麼是漸進動畫,就是滑動的時候,速度會逐漸逐漸變小,而後趨近於0,若是用過ios app的同窗應該能感受到,刷掘金刷微博的時候滾動頁面,會有一段平滑動畫而後漸進式的中止java

這個位置的難點和核心點在於咱們要在用戶雙手離開屏幕後,仍然須要執行一段滾動,但咱們獲取用戶的手指事件touchstart touchmove touchend只能在用戶手指在屏幕上的時候react

當初想了不少方法,最終也卡住了,後來借鑑了scroller的源碼,找到了方法,這個方法思路有點不太容易想獲得,下面咱們經過實例來說解這個方法ios

首先咱們得掛載一下元素git

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
  <style> html, body { height: 100%; width: 100%; overflow: hidden; margin: 0; padding: 0; } #test { transform: translate3d(0, 0, 0); } .chunk { height: 80px; } </style>
</head>

<body>
  <div id="test">
  </div>
  <script> const $test = document.getElementById('test') const $frag = document.createDocumentFragment() for (let i = 0; i < 100; i++) { const $el = document.createElement('div') $el.classList = 'chunk' $el.innerHTML = i $frag.appendChild($el) } $test.appendChild($frag) </script>
</body>

</html>
複製代碼

整個頁面以下,此時能夠發現沒法拖動頁面顯示超出顯示區域的元素github

而後咱們爲id爲test的元素添加touch事件,經過手指的滑動改變translate的值算法

如下代碼放在上面代碼$test.appendChild($frag)後面

// 省略其餘代碼

// 設置位移
function set(y) {
  $test.style.transform = `translate3d(0,${y}px,0)`
}

// 設置觸碰須要的變量
let start, diff, base = 0

// 觸碰開始
$test.addEventListener('touchstart', e => {
  start = e.touches[0].pageY
})

// 移動
$test.addEventListener('touchmove', e => {
  diff = e.touches[0].pageY - start + base
  set(diff)
})

// 中止
$test.addEventListener('touchend', e => {
  base = diff
})
複製代碼

此時你能夠用鼠標一直按着屏幕像手指同樣移動,發現屏幕是能夠移動的,可是當手指一離開屏幕,屏幕的滾動也中止了

如今就是漸進式發揮做用的地方了,不過在咱們開始寫代碼前,咱們先分析一下

  • 觸發漸進動畫的時機

你們能夠思考一下日常的操做習慣,何時會觸發這種動畫呢,你們可能會以爲是在滑動比較快的時候,再細一點就是手指滑動離開屏幕比較快的時候

那咱們從代碼角度理解,是否是就是touchmove的最後一幀 和touchend觸發 二者時間差足夠快的時候,這裏要注意不是touchstarttouchend,緣由就是touchstart後用戶可能長時間手還沒離開在滑動,因此最準確的應該是touchmove的最後一幀

獲取時間差的api就是觸發touchmovetouchend的時候,返回的TouchEvent中會有一個時間戳timeStamp參數表當前的觸摸時間

咱們就是去經過這個判斷的,當touchmove最後一幀和touchend觸發的時候,若是二者時間差小於100ms,就觸發漸進動畫

咱們將這個點寫成代碼以下

// 設置觸碰須要的變量
// 增長了lastTime變量
let start, diff, base = 0, lastTime

// 觸碰開始
$test.addEventListener('touchstart', e => {
  start = e.touches[0].pageY
})

// 移動
$test.addEventListener('touchmove', e => {
  lastTime = e.timeStamp;
  diff = e.touches[0].pageY - start + base
  set(diff)
})

// 中止
$test.addEventListener('touchend', e => {
  base = diff
  // 執行漸進式動畫
  if (e.timeStamp - lastTime < 100) {
    console.log('執行漸進式動畫')
  }
})
複製代碼
  • 如何計算漸進位移

觸發的時機咱們找到了,怎麼計算位移呢,咱們在touchmove中將每個點的位移和時間戳存儲起來,在touchend觸發的時候去存儲中尋找100ms內最靠前的位移點,而後用兩點的位移除以兩點的時間差拿到兩點的平均速度

這個速度表明的是什麼意思呢?能夠理解爲手指在離開屏幕前的滑動速度,也就是屏幕的滑動速度,這就獲取了咱們剛纔的核心點,手指它離開了屏幕,咱們沒法捕捉,可是咱們拿到了屏幕的滑動速度,若是手指離開的快,這個速度就快,離開的慢,這個速度就慢

那這個速度有什麼意義呢?舉個列子,若是咱們用這個速度乘以時間,那麼屏幕是否是就會按照手指離開前同樣勻速的滑動,但很明顯不是勻速的,因此咱們得想一想辦法

咱們的動畫定位是60fps就是60幀,也就是1000ms刷新60次,咱們先經過這個速度拿到第一幀的移動距離

v * (1000/60)

而後遞歸去計算以後的每一幀而且每一幀的移動距離*0.95以一個遞減的趨勢逐漸減少,將每次獲取到的距離累加,當距離減少到必定程度的時候則中止

咱們將這個思路寫成代碼

// 設置位移
function set(y) {
  $test.style.transform = `translate3d(0,${y}px,0)`
}

// 設置觸碰須要的變量
// 增長了positions變量
let start, diff, base = 0, lastTime, positions = [], rid

// 觸碰開始
$test.addEventListener('touchstart', e => {
  window.cancelAnimationFrame(rid)
  start = e.touches[0].pageY
})

// 移動
$test.addEventListener('touchmove', e => {
  lastTime = e.timeStamp;
  diff = e.touches[0].pageY - start + base
  set(diff)
  // 存儲每個點的位置和時間
  positions.push({
    lastTime,
    diff
  })
  // 防止數組過大 當數組大於60的時候 將前30截斷
  if (positions.length > 60) {
    positions.splice(0, 30)
  }
})

// 中止
$test.addEventListener('touchend', e => {
  // 執行漸進式動畫
  if (e.timeStamp - lastTime < 100) {
    // 獲取100ms內最靠前的點
    const pre = positions.filter(v => e.timeStamp - v.lastTime <= 100)[0]
    // 當前點和靠前點的距離差
    const diffOffset = diff - pre.diff
    // 當前點和靠前點的時間差
    const lastTimeOffset = e.timeStamp - pre.lastTime
    // 拿到平均速度
    const v = diffOffset / lastTimeOffset
    // 拿到平均速度下一幀的位移
    let s = v * 1000 / 60
    // 制空存儲數組
    positions.length = 0

    // 遞歸循環每次s*0.95知道s小於0.01
    function loop() {
      if (Math.abs(s) <= 0.01) {
        window.cancelAnimationFrame(rid)
      } else {
        s = s * 0.95
        diff += s
        set(diff)
        base = diff
        rid = window.requestAnimationFrame(loop)
      }
    }

    loop()
  } else {
    base = diff
  }
})
複製代碼

這樣一個簡單的仿ios漸進式滾動就實現了

2. 嘗試採用requestAnimationFrame做動畫

不少相似的picker插件採用的是transition,元素的滾動用的是transform:translate(0,y,0),當改變y的值的時候,欄目會上移或者下移,此時設置了transition會讓整個移動看起來像是動畫滾動的

其實最初咱們也用的transition,咱們也總結了一些transition出現的問題和解決方法

2.1 避免touchmove帶來的延遲動畫

touchmove移動的時候,動做是很快的,若是此時仍然設置了transition動畫,整個移動效果感受會延遲,好比下面這樣

由於我這裏用的不是transition,因此demo比較難作,這裏借鑑了下有讚的vant組件作了demo,改寫了一部分達到這個效果

但咱們實際指望的狀況是這樣的

這裏作法很簡單,在鼠標開始移動前也就是touchstart的時候能夠設置transition-duration爲0,transition-property爲none就能夠了,而後再touchend處將它們再回歸,大概意思就是若是鼠標滑動就沒有動畫

2.2 動畫效果的選擇

常見的動畫有ease變速 linear勻速,固然還有不少比較有意思的,若是你們對動畫有要求能夠去這個網站看看

比較符合咱們要求的就是easeOutCubic,但這個不是瀏覽器自帶的,因此得換種寫法

.block {
    transition: transform 0.6s cubic-bezier(0.215, 0.61, 0.355, 1);
}
複製代碼

可是最後我仍是棄用了transition,一個緣由是想嘗試一下requestAnimationFrame,在一個就是漸進動畫的獲取採用的是編程方式,因此動畫下意識選擇了編程方式的requestAnimationFrame

3 diff算法

前段時間終於把vue的diff算法弄懂了,因而將這個思路放在了項目中

首先解釋下什麼是diff,diff算法僅在多節點對比的時候觸發,在數據更新的時候,並非直接把以前的dom移除而後再把新dom從新渲染,而是保留以前的dom進行比較,若是dom的節點和以前同樣則不變更,若是dom節點不同則只替換改變的dom

好比

<div>
  <div>123</div>
  <div>456</div>
</div>
複製代碼
<div>
  <div>123</div>
  <div>789</div>
</div>
複製代碼

以上節點就只會替換文本節點456爲文本節點789

picker中的diff不會有vue中的那麼複雜,由於要改變的只有dom的文本節點和對應綁定的事件,出現的狀況也只在聯動的時候

咱們分爲幾種狀況

3.1 聯動層次不變

好比第一次有兩個欄目,第二次也有兩個欄目,但數據不同

此時須要對欄目進行diff比較,欄目比較又分爲三種狀況

  • 新數據大於老數據

    好比新數據20個,老數據10個,此時保留老數據的10個dom,對老數據10個文本節點進行從新賦值,而後建立10個新dom並賦值

  • 新數據等於老數據

    好比新數據20個,老數據20個,此時保留老數據的20個dom,對老數據20個文本節點進行從新賦值

  • 新數據小於老數據

    好比新數據10個,老數據20個,此時移除老數據後10個dom,並對前10個文本節點進行從新賦值

3.2 新的聯動欄目小於老的聯動欄目

好比第一次有四個欄目,第二次有兩個欄目,此時須要隱藏後兩欄dom,注意這裏不是移除,是隱藏,由於可能以後咱們還會用到這一欄,而後對前兩欄單欄目進行3.1中講到的欄目diff

3.3 新的聯動欄目大於老的聯動欄目

好比第一次有兩個欄目,第二次有四個欄目,此時須要增長兩欄dom並綁定對應的touch事件,這裏的增長也有說法,若是像3.2中隱藏的dom,那麼就不新增而是讓隱藏的dom從新顯示,而後對前兩欄單欄目進行3.1中講到的欄目diff

其實寫完這個diff是一種練手,但寫完以後是有點後悔的,由於難度增長了,要考慮的點不少,代碼多了快400行,但執行的性能確實是比普通從新渲染的方式提高了不少

4 友好的參數校驗

以前寫過一項目,不少用項目的開發者不太熟悉參數的設定,程序就會報錯,此次大概多寫了200來行代碼進行參數校驗和友好的提醒,若是不符合當前規則會給一個友好的提醒

體驗

1 vue3.0 api 嚐鮮

插件支持vue使用,因而嘗試了vue-function-api,也就是setup的寫法

最大的感觸就是setup裏面this沒有了,用了一個context替代,致使若是我要獲取this上的一些實例,好比我這個項目須要獲取當前組件的uid,就須要在其它位置(render或指定生命週期)獲取並賦值給變量

寫的時候還遇到了一個坑,以前文檔demo指定的掛載生命週期是onMounted,舉得相反列子是onUnmounted,我覺得destroyed更名了,而後在這個生命週期銷燬組件,結果就出問題了,由於onUnmounteddestroyeddeactived的結合,在組件被keep-alive下銷燬組件第二次進頁面就會出問題

前段時間這個倉庫被指向了composition-api

有些api變了,api變化其實挺讓人驚訝的,由於最初倉庫定的標題是api能夠直接遷移vue3.0,因此致使了我又改寫了一部分vue的封裝

好比value變成了ref,還提供了一個reactive的api

還有在vue-function-api中的摧毀生命週期onDestroyed被取消了,而後如今得用onBeforeUnmount

感受對於生命週期這一塊的命名有點讓我捉摸不透。。

2 lerna使用體驗

由於項目有兩個包一個支持原生js一個支持vue,並且兩個包的版本是有關聯的,因此下意識選擇了lerna

若是你們對lerna有興趣能夠去看看文檔,這裏說一下lerna的坑點

lerna publish 會自動建立tag併發布到遠端release,而後下一步是發佈npm,這個自動操做是能夠的,但問題就出在若是網速出問題了,npm發不上去的狀況

發佈npm這一步出錯,可是tag已經打了,咱們知道tag是惟一的不能同時存在兩個同樣的名字,因此我又得把tag刪了,再從新跑一次發佈腳本

因此我感受若是能在npm發佈成功後打tag是最好的

結束語

這個項目是個人一次比較新的技術棧的嘗試,同時也是我對動畫性能的一次探索,若是你們有更好的動畫實現方式或者對項目有什麼性能上的優化意見能夠在評論區留言哈~若是對項目喜歡,能夠start支持一下q-select

相關文章
相關標籤/搜索