本文为Varlet组件库源码主题浏览系列第九篇,读完本篇,能够理解到如何应用一个div创立一个点击的水波成果。

Varlet组件库提供了一个使元素点击时生成水波扩散成果的指令:

<template>  <div v-ripple>点击</div></template>

接下来就从源码角度看看它是如何实现的。

首先在指令所绑定的指标元素被挂载的时候会执行如下办法:

function mounted(el: RippleHTMLElement, binding: DirectiveBinding<RippleOptions>) {  // 给元素上增加一个对象记录一些数据  el._ripple = {    tasker: null,    ...(binding.value ?? {}),    touchmoveForbid: binding.value?.touchmoveForbid ?? context.touchmoveForbid,    removeRipple: removeRipple.bind(el),  }  // 给元素绑定了一些事件  el.addEventListener('touchstart', createRipple, { passive: true })  el.addEventListener('touchmove', forbidRippleTask, { passive: true })  el.addEventListener('dragstart', removeRipple, { passive: true })  document.addEventListener('touchend', el._ripple.removeRipple, { passive: true })  document.addEventListener('touchcancel', el._ripple.removeRipple, { passive: true })}

次要就是绑定了一些事件,处理函数一共有三个,从函数名中也能够大抵看出其作用。

留神看addEventListener办法的第三个参数中都设置了passive = true,这个选项用来通知浏览器咱们的处理函数中不会调用preventDefault办法,这么做有什么益处呢?比方touch事件或scroll事件的默认行为都会触发页面的滚动,如果调用了preventDefault办法,那么就会阻止滚动,但问题是浏览器并不知道咱们有没有在事件处理函数中调这个办法,那么就必须期待函数执行结束才晓得,有时候函数的执行是比拟耗时的,这样就会导致页面卡顿,所以如果咱们的处理函数中明确不会调用preventDefault办法,那么就通过passive标记间接通知浏览器,这样浏览器就不会期待,间接进行滚动,能够显著晋升页面性能和体验。

先看看touchstart事件的解决办法createRipple

function createRipple(this: RippleHTMLElement, event: TouchEvent) {  // 首先获取该元素上存储的数据  const _ripple = this._ripple as RippleOptions  // 先移除上一个水波  _ripple.removeRipple()  // 如果禁用或者上一个水波工作还未执行则返回  if (_ripple.disabled || _ripple.tasker) {    return  }  // 水波工作  const task = () => {    // ...  }  // 保留定时器  _ripple.tasker = window.setTimeout(task, 60)}

当咱们触摸点击一个元素的时候,会先移除该元素的上一个水波,而后增加一个新的水波工作,这个工作会在一个60ms的定时器后执行,而后把定时器id保存起来,为什么不立刻执行呢,应该是为了可能勾销吧,比方想在touchmove状况下不开启水波成果,那么就能够通过勾销这个定时器来实现,看一下touchmove事件的处理函数forbidRippleTask

function forbidRippleTask(this: RippleHTMLElement) {  const _ripple = this._ripple as RippleOptions  // 是否须要在触摸挪动时禁用水波成果  if (!_ripple.touchmoveForbid) {    return  }  // 如果在60ms内触摸挪动了就会勾销定时器,天然水波成果就不会有了  _ripple.tasker && window.clearTimeout(_ripple.tasker)  _ripple.tasker = null}

接下来看看task办法:

function createRipple(this: RippleHTMLElement, event: TouchEvent) {  //...  const task = () => {    // 定时器工作执行了则把保留的定时器id清空    _ripple.tasker = null    // 计算一些数据    const { x, y, centerX, centerY, size }: RippleStyles = computeRippleStyles(this, event)    // 创立一个div    const ripple: RippleHTMLElement = document.createElement('div')    // 增加一个var-ripple类名    ripple.classList.add(n())    // 设置透明度为0,即全透明    ripple.style.opacity = `0`    // 设置地位及缩放    ripple.style.transform = `translate(${x}px, ${y}px) scale3d(.3, .3, .3)`    // 设置大小    ripple.style.width = `${size}px`    ripple.style.height = `${size}px`    // 设置色彩    _ripple.color && (ripple.style.backgroundColor = _ripple.color)    // 记录创立工夫    ripple.dataset.createdAt = String(performance.now())    // 设置被点击元素的款式    setStyles(this)    // 将水波元素增加到被点击元素内    this.appendChild(ripple)    // 20ms后批改水波元素的款式,达到水波的扩散动画成果    window.setTimeout(() => {      ripple.style.transform = `translate(${centerX}px, ${centerY}px) scale3d(1, 1, 1)`      ripple.style.opacity = `.25`    }, 20)  }  //...}

能够看到所谓水波就是一个div,总体的流程为先创立一个div元素,而后设置它的透明度为0、初始地位、缩放、大小、背景色彩,而后增加为被点击元素的子元素,最初在20ms当前批改div的地位、缩放、透明度,只有设置了它的transation过渡属性即可实现过渡成果,也就是水波扩散的成果,款式是通过类名var-ripple设置的:

:root {  --ripple-cubic-bezier: cubic-bezier(0.68, 0.01, 0.62, 0.6);  --ripple-color: currentColor;}.var-ripple {  position: absolute;// 设置为相对定位  transition: transform 0.2s var(--ripple-cubic-bezier), opacity 0.14s linear;// 设置过渡成果  top: 0;  left: 0;  border-radius: 50%;// 设置为圆形  opacity: 0;  will-change: transform, opacity;  pointer-events: none;// 禁止响应鼠标事件  z-index: 100;  background-color: var(--ripple-color);// 背景色彩}

能够看到水波元素为相对定位,另外地位的过渡工夫为200ms,透明度的过渡工夫为140ms

接下来看看其中调用的几个函数。

首先是调用computeRippleStyles办法计算一些根本数据:

function computeRippleStyles(element: RippleHTMLElement, event: TouchEvent): RippleStyles {  // 被点击元素间隔屏幕顶部和左侧的间隔  const { top, left }: DOMRect = element.getBoundingClientRect()  // 被点击元素的宽高  const { clientWidth, clientHeight } = element  // 计算水波圆的半径  const radius: number = Math.sqrt(clientWidth ** 2 + clientHeight ** 2) / 2  // 直径  const size: number = radius * 2  // ...}

水波的直径是依据勾股定理计算的:

function computeRippleStyles(element: RippleHTMLElement, event: TouchEvent): RippleStyles {  // ...  // 手指点击的地位绝对于被点击元素的坐标  const localX: number = event.touches[0].clientX - left  const localY: number = event.touches[0].clientY - top  // 水波元素初始地位  const x: number = localX - radius  const y: number = localY - radius  // 水波元素最终地位  const centerX: number = (clientWidth - radius * 2) / 2  const centerY: number = (clientHeight - radius * 2) / 2  return { x, y, centerX, centerY, size }}

size为水波圆的直径;手指点击的地位是水波圆初始的中心点,而后计算其左上角坐标x、y为水波元素的初始地位;水波圆的最终中心点其实就是被点击元素的中心点,换算成左上角坐标centerX、centerY即为水波元素的最终地位。因为水波元素为被点击元素的子元素,所以这些坐标都是绝对于被点击元素的左上角坐标计算的:

从绿色的圆过渡成红色的圆,透明度、大小、地位的变动就是水波的扩散成果。

将水波元素增加到被点击元素内前还调用了setStyles办法:

function setStyles(element: RippleHTMLElement) {  const { zIndex, position } = window.getComputedStyle(element)  element.style.overflow = 'hidden'  element.style.overflowX = 'hidden'  element.style.overflowY = 'hidden'  position === 'static' && (element.style.position = 'relative')  zIndex === 'auto' && (element.style.zIndex = '1')}

这个函数做的事件次要是检查和设置被点击元素的一些款式,首先溢出须要设置为暗藏,否则水波圆的扩散就会溢出元素残缺显示进去,这显然不难看,而后后面提到过水波元素为相对定位,所以被点击元素的定位不能是动态定位,最初的层级设置笔者临时没有想进去是为了解决什么问题。

到这里,当咱们手触摸元素时,水波成果就创立实现了,接下来是移除操作,看一下removeRipple办法:

const ANIMATION_DURATION = 250function removeRipple(this: RippleHTMLElement) {  const _ripple = this._ripple as RippleOptions  const task = () => {    // 获取水波元素    const ripples: NodeListOf<RippleHTMLElement> = this.querySelectorAll(`.${n()}`)    if (!ripples.length) {      return    }    // 最初一个水波    const lastRipple: RippleHTMLElement = ripples[ripples.length - 1]    // 计算延迟时间    const delay: number = ANIMATION_DURATION - performance.now() + Number(lastRipple.dataset.createdAt)    // 提早后将水波的透明度设置为0    setTimeout(() => {      lastRipple.style.opacity = `0`      // 再次提早后移除水波元素      setTimeout(() => lastRipple.parentNode?.removeChild(lastRipple), ANIMATION_DURATION)    }, delay)  }  // 创立工作的定时器id存在则期待60ms  _ripple.tasker ? setTimeout(task, 60) : task()}

先回顾一下创立水波的各个阶段的耗时,当咱们第一次点击元素时,期待60ms后会创立水波元素,而后再期待20ms后会开始进行水波的扩散成果,动画耗时200ms完结,如果咱们在60ms内进行第二次点击不会创立第二个水波,因为前一个水波工作还未执行,如果是在60ms后第二次点击,会先调用removeRipplie移除上一个水波,而后反复第一个水波的创立流程:

每次执行removeRipple办法只须要移除以后最初一个水波即可,之前的水波会由之前的task移除。

接下来具体看看整个过程。

当手指第一次触摸点击元素时会执行createRipple办法,办法内会先执行removeRipple办法,此时_ripple.tasker不存在,会立刻执行removeRippletask办法,然而目前并没有水波元素,所以这个函数会间接返回,removeRipple办法执行结束。

接下来会创立一个60ms的定时器,期待执行createRippletask,如果咱们在60ms内就松开了手指,那么又会执行removeRipple办法,此时_ripple.tasker存在,所以removeRippletask办法也会期待60ms再执行;如果咱们是在60ms后才松开手指,那么_ripple.tasker不存在,会立刻执行removeRippletask办法,该办法内会获取最初一个水波元素,也就是刚刚创立的水波元素,而后计算delay

delay = ANIMATION_DURATION - (performance.now() - Number(lastRipple.dataset.createdAt))

performance.now() - Number(lastRipple.dataset.createdAt)代表此刻到创立水波时过来的工夫,ANIMATION_DURATION减去它即示意250ms还剩下的工夫,因为后面提到了水波从创立到扩散实现整个过程大略耗时20ms + 200ms = 220ms,所以提早dealy工夫,也就是期待水波动画实现后再让水波隐没,防止水波还未扩散实现就隐没的状况,批改水波的透明度为0,透明度动画耗时140ms,所以再期待250ms将水波元素移除。

如果在60ms内松开手指又立刻再次触摸元素,那么又会执行createRipple办法,同样又会先执行removeRipple办法,此时前一个创立水波的task工作还未执行,_ripple.tasker存在,所以removeRippletask办法会期待60ms再执行,这个task工作其实和松开手指时触发的task工作反复了,相当于两个task移除同一个水波元素,不过问题也不大。

因为上一个水波的task还未执行,所以createRipple会间接返回。

如果在60ms后再次触摸元素,执行removeRipple_ripple.tasker不存在,会立刻执行task办法,同样,这个task工作也会和松开手指触发的task工作反复。

此时_ripple.tasker不存在,所以创立第二个水波的工作会被增加到定时器里,当第二次松开手指时,执行removeRiplle会删除第二个水波。

更多次重复触摸元素时以此类推,会一直创立水波,水波动画完结后也会一直被删除。

在指标元素被卸载时会执行unmounted办法:

function unmounted(el: RippleHTMLElement) {  el.removeEventListener('touchstart', createRipple)  el.removeEventListener('touchmove', forbidRippleTask)  el.removeEventListener('dragstart', removeRipple)  document.removeEventListener('touchend', el._ripple!.removeRipple)  document.removeEventListener('touchcancel', el._ripple!.removeRipple)}

次要是移除绑定的事件。

到这里,水波成果的创立和移除就都介绍完了,能够看到这种实现形式对指标元素还是有肯定要求的,如果指标元素的款式布局须要设置positionoverflowz-index属性为不符合要求的值,那么间接批改可能就会导致款式呈现问题,并且卸载时也没有进行复原,这是不是也算是一个小bug