造完一个移动端picker轮子后的体验

3,374 阅读10分钟

前言

最近用typescript造了一个移动端的picker插件,同时支持jsvue组件调用,这次去尝试了很多不一样比较有创新的思路,将比较创新的思路点和遇到的问题做成了笔记分享给大家

预览

首先我们看一看实现的demo效果

非联动

省市区联动

省市区异步联动

demo网址

具体的demo演示网页可以点击这里查看

使用方法

使用方法可以查看我们的github仓库,我们提供了丰富的demo沙盒演示,如果觉得不错,可以start支持一下

特点

1. 仿ios渐进动画

什么是渐进动画,就是滑动的时候,速度会逐渐逐渐变小,然后趋近于0,如果用过ios app的同学应该能感觉到,刷掘金刷微博的时候滚动页面,会有一段平滑动画然后渐进式的停止

这个位置的难点和核心点在于我们要在用户双手离开屏幕后,仍然需要执行一段滚动,但我们获取用户的手指事件touchstart touchmove touchend只能在用户手指在屏幕上的时候

当初想了很多方法,最终也卡住了,后来借鉴了scroller的源码,找到了方法,这个方法思路有点不太容易想得到,下面我们通过实例来讲解这个方法

首先我们得挂载一下元素

<!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>

整个页面如下,此时可以发现无法拖动页面显示超出显示区域的元素

然后我们为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