造完一个移动端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

相关文章
相关标签/搜索