Ripple:这个广受好评的水波纹组件,你不打算了解下怎么实现的吗?

avatar
前端解决方案集 @华为

本文源于 Vue DevUI 开源组件库实践。

之前我们发了一篇文章,用于介绍 Vue DevUI 组件库的使用:

Vue DevUI:100多位贡献者持续530多天,写了近60000行代码,这个新鲜出炉的 Vue3 组件库你不想尝试下吗?

有不少小伙伴用过之后,表示很喜欢我们的 Ripple 水波纹指令。

image.png

image.png

于是邀请到 Ripple 指令的田主 ErKeLost 同学给我们分析这个被大家喜爱的 Ripple 指令的设计思路和实现原理。

image.png

1. DevUI Ripple 介绍

Ripple 水波纹作为比较受欢迎的交互效果,用于对用户的行为进行反馈,本文将解析 DevUI Ripple 指令,并且从0到1为大家展示如何写出兼容 Vue2 和 Vue3 的自定义指令。

先给大家看下 Ripple 水波涟漪的效果。

ripple.gif

本文作为简易 Ripple 指令代码逻辑拆解,具体实现以及效果可以进入 DevUI 官网查看。

官网传送门

Github 传送门

Ripple 指令实战打包发布 Github 传送门

2. 核心功能解析

2.1 实现基本原理

水波纹指令需要作用在一个块级元素,元素要有宽度和高度,每当用户点击,获取用户点击坐标到块级元素的四个直角顶点的距离其中距离最远的顶点作为ripple半径画圆。

image.png

2.2 交互事件

交互事件用于 ripple 的生成与移除,我们使用pointEvent指针事件。

指针事件 - Pointer events 是一类可以为定点设备所触发的DOM事件,它们被用来创建一个可以有效掌握各类输入设备(鼠标、触控笔和单点或多点的手指触摸)的统一的DOM事件模型。

所谓指针 是指一个可以明确指向屏幕上某一组坐标的硬件设备。建立这样一个单独的事件模型可以有效的简化Web站点与应用所需的工作,同时也便于提供更加一致与良好的用户体验,无需关心不同用户和场景在输入硬件上的差异。而且,对于某些特定设备才可处理的交互情景,指针事件也定义了一个 pointerType 属性以使开发者可以知晓该事件的触发设备,以有针对性的加以处理。

也就是说本来web端我们会使用mousedownmouseovermouseup等来监听鼠标事件、移动端我们会用touchstarttouchmovetouchend监听触摸时间,但是使用了指针事件 Pointer events 就不用这样区分了,它会自动兼容web端还是移动端的事件,也会返回pointerType属性表明触发设备。

2.3 Ripple 元素生成问题

因为水波纹实际上是一个圆形,最终实现的大小会比当前点击元素大很多,类似于这样

ripple.gif

一种解决办法是我们给父元素添加overflow: "hidden",但是这样也会存在着一个问题,我们给父元素动态添加css属性属于修改了用户层面的样式,这就会导致各种隐藏bug,引起不必要的麻烦。

另一种解决方法,我们采用父元素与ripple之间在嵌套一层元素,作为ripple的父元素,并且对最外层的父元素也就是用户点击的元素做绝对定位就可以解决问题。

2.4 如何兼容 Vue2 和 Vue3

Vue2 和 Vue3 在指令方面最大的区别就是生命周期的不同,这样我们可以根据当前 Vue 版本来选择 Map 不同的生命周期进行兼容 Vue2 和 Vue3,代码实现部分后面会带大家实现。

3. 代码实现

3.1 指令编写

[hooks.mounted] [hooks.updated] 这两个属性后面会在兼容 Vue2 和 Vue3 的时候讲解, 我们可以先把他们当作 mountedupdated Vue3 指令的生命周期。

  // 定义一个全局共享map,方便我们设置与获取,我们指令传递的属性
  const optionMap = new WeakMap<
    HTMLElement,
    Partial<IRippleDirectiveOptions> | false
  >()
  const hooks = getHooks(app)
  app.directive('ripple', {
    [hooks.mounted](
      el: HTMLElement,
      binding: IRippleDirectiveOptionWithBinding
    ) {
      optionMap.set(el, binding.value ?? {})
      // 监听鼠标点击或者移动端触摸事件
      el.addEventListener('pointerdown', (event) => {
        const options = optionMap.get(el)
        if (binding.value?.disabled) return
        if (options === false) return
        // 接下来的代码解析都会在ripple函数中扩展
        ripple(event, el, {
          ...globalOptions,
          ...options
        })
      })
    },
    [hooks.updated](
      el: HTMLElement,
      binding: IRippleDirectiveOptionWithBinding
    ) {
      optionMap.set(el, binding.value ?? {})
    }
  })

3.1 获取鼠标点击坐标以及距离顶点距离 (ripple函数内部逻辑)

获取鼠标点击指令元素的DomRect对象,获取我们当前距离视口和距离el的各个位置。

const ripple = (
  event: PointerEvent,
  el: HTMLElement,
  options: IRippleDirectiveOptions
): void => {
  // el 代表点击元素 是指令传递过来的元素`el`
  const rect = el.getBoundingClientRect();
  // 获取点击位置距离el的垂直和水平距离 event 代表鼠标事件 directive 指令会传递event参数
  const x = event.clientX - rect.left
  const y = event.clientY - rect.top
  const height = rect.height
  const width = rect.width
}

然后我们一开始是拿不到点击位置距离各个顶点位置,我们可以根据勾股定理获取到点击坐标到矩形四个顶点的位置,然后判断谁是最长的那么最长的那个就作为Ripple的半径,继续上部分代码。

// 计算勾股定理函数
function getDistance (x1: number, y1: number, x2: number, y2: number): number {
  const deltaX = x1 - x2;
  const deltaY = y1 - y2;
  return Math.sqrt(deltaX * deltaX + deltaY * deltaY);
}
const topLeft = getDistance(x, y, 0, 0);
const topRight = getDistance(x, y, width, 0);
const bottomLeft = getDistance(x, y, 0, height);
const bottomRight = getDistance(x, y, width, height);
const radius = Math.max(topLeft, topRight, bottomLeft, bottomRight);

radius 就是我们需要的Ripple的半径。

3.2 创建 Ripple 元素与 Ripple 的第一级父元素

我们先创建Ripple的第一级父元素,我们其实就是需要复制一个div,让复制出来的div做跟el一样的事,cloneNode不会复制原来的样式,就直接创建元素就好,先获取el的样式,然后复制出一模一样的节点,目前我们需要跟el元素的boder-radius相同。

const computedStyles = window.getComputedStyle(el);
const {
  borderTopLeftRadius,
  borderTopRightRadius,
  borderBottomLeftRadius,
  borderBottomRightRadius
} = computedStyles;
const rippleContainer = document.createElement('div');

rippleContainer.style.top = '0';
rippleContainer.style.left = '0';
rippleContainer.style.width = '100%';
rippleContainer.style.height = '100%';
rippleContainer.style.position = 'absolute';
rippleContainer.style.borderRadius =
`${borderTopLeftRadius} ${borderTopRightRadius} ${borderBottomRightRadius} ${borderBottomLeftRadius}`;
rippleContainer.style.overflow = 'hidden';
rippleContainer.style.pointerEvents = 'none';

我们主要需要元素做overflow = 'hidden'防止元素溢出,影响全局。

然后我们创建 Ripple 元素:

const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const rippleElement = document.createElement('div');

rippleElement.style.position = 'absolute';
rippleElement.style.width = `${radius * 2}px`;
rippleElement.style.height = `${radius * 2}px`;
rippleElement.style.top = `${y}px`;
rippleElement.style.left =`${x}px`;

rippleElement.style.background = options.color;
rippleElement.style.borderRadius = '50%';
rippleElement.style.opacity = 0.1;
rippleElement.style.transform = `translate(-50%,-50%) scale(0)`;
rippleElement.style.transition = `transform ${options.duration / 1000}s ease-in-out, opacity ${options.duration / 1000}s ease-in-out`;

3.3 事件交互

创建完元素之后,接下来进行事件交互。

需要在每次鼠标点击的时候触发Ripple元素的生成, 因为我们的 css 写了transitionoptions.duration,我们触发 transfrom 就会出现水波涟漪的效果了。

setTimeout(() => {
    rippleEl.style.transform = `translate(-50%,-50%) scale(1)`;
    rippleEl.style.opacity = `${options.finalOpacity}`;
}, options.delay);

ripple.gif

然后我们需要优化一下代码,每次点击都会生成Ripple元素,需要在每次结束后把当前元素删掉,并且如果上次点击过生成的 Ripple 也需要删除掉。

具体实现时先监听鼠标点击抬起事件,在鼠标抬起时,需要移除 Ripple。

let shouldDissolveRipple = false;

function releaseRipple(e?: PointerEvent) {
  // 我们一种是监听指针事件去移除ripple 第二种是当我们设置duration时间过后,我们也需要自动去移除ripple
  if (typeof e !== 'undefined') {
    document.removeEventListener('pointerup', releaseRipple);
  }

  // 判断我们什么时候需要去调用移除ripple方法,只有我们鼠标抬起,或者设置的duration时间过后
  if (shouldDissolveRipple) {
    dissolveRipple();
  } else {
    shouldDissolveRipple = true;
  }
}

function dissolveRipple() {
  rippleEl.style.transition = 'opacity 120ms ease in out';
  rippleEl.style.opacity = '0';

  setTimeout(() => {
    // 删除ripple第一层父元素
    rippleContainer.remove();
  }, 100);
}

setTimeout(() => {
  rippleEl.style.transform = `translate(-50%,-50%) scale(1)`;
  rippleEl.style.opacity = `${options.finalOpacity}`;
  // 自动移除ripple
  setTimeout(() => releaseRipple(), options.duration);
}, options.delay);
// 监听指针(鼠标)抬起事件 监听移除rippele
document.addEventListener('pointerup', releaseRipple);

这时候最简单的 ripple 效果就出来啦!

1.gif

最后给第一层的父元素rippleContainer添加overflow:hidden完整效果就出来了。

2.gif

3.5 优化交互效果

在点击 Ripple 元素的时候,因为 ripple 是通过 transition 从 0 scale 到 1的一个过程, 这样会有一个问题,如果我们点击速度过快就会导致中间一直都会有一个小圆点,影响交互效果。

那么我们如何优化呢?

我们这里可以使用cubic-bezier贝塞尔曲线。

我们可以在 animation 和 transition 中使用 cubic-bezier,贝塞尔曲线就是控制过渡变化的速度曲线。

推荐一个可以在线预览进行交互的贝塞尔曲线网站

image.png

我们只需要保证 ripple 过渡的效果是一开始快,然后慢,我们就可以让整个 ripple 效果看起来好很多。

  // old
  rippleElement.style.transition = `transform ${options.duration / 1000}s ease-in-out, opacity ${options.duration / 1000}s ease-in-out`;
  
  // new 
  rippleElement.style.transition = `transform ${options.duration / 1000}s cubic-bezier(0, 0.5, 0.25, 1), opacity ${options.duration / 1000}s cubic-bezier(0.0, 0, 0.2, 1)`;

3.gif

实际效果比 Gif 图要好!欢迎大家到 Vue DevUI 官网体验:vue-devui.github.io/components/…

3.4 兼容 Vue2 & Vue3

app.config.globalProperties 是一个在 Vue3 中访问全局属性的对象,我们只需要判断当前 Vue 实例中是否有globalProperties这个对象,然后赋值不同的生命周期。

import { App } from 'vue';

interface Vue2 {
  default: {
    version: string
  }
}

const isVue3 = (app: Vue2 | App): app is App => 'config' in app && 'globalProperties' in app.config;

const getHooks = (app: App) => {
  return isVue3(app)
    ? {
        created: 'created',
        mounted: 'mounted',
        updated: 'updated',
        unMounted: 'unmounted'
      }
    : {
        created: 'bind',
        mounted: 'inserted',
        updated: 'updated',
        unMounted: 'unbind'
      }
}

然后我们返回一个 Vue 的插件, 在这里我们可以获取 app 对象。

import rippleDirective from './ripple';

export default {
  title: 'Ripple 水波纹',
  install(app: App): void {
    rippleDirective(app)
  }
} as Plugin & { installed: boolean }

// Vue3
import VRipple from 'ripple-directive'
const app = createApp(App)
app.use(VRipple)

// Vue2
import VRipple from 'ripple-directive'
import Vue from 'vue'
Vue.use(VRipple)

4. 打包

我们使用tsup进行打包。

新建 src/index.ts 文件

import VRippleDirective from './ripple'

export const VRipple = { 
  install: function (app: App | Vue2, options: any){ 
    VRippleDirective(app)
  }
} as Plugin & { installed: boolean }

下载 tsup,然后根目录新建 tsup.config.js

npm install tsup

tsup.config.js

import { defineConfig } from 'tsup'

export default defineConfig({
  entry: ['index.ts'],
  target: 'esnext',
  format: ['esm', 'cjs', 'iife'],
  splitting: false,
  sourcemap: true,
  clean: true,
  // 要注意移除掉vue
  external: ['vue']
})

然后我们就完成了一个简单的ripple指令的编写与打包。

完整代码体验monorepo版本传送门

欢迎加入 DevUI 开源社区

除了 Ripple,Vue DevUI 还有很多实用的组件等你来探索,我们每周也会组织田主大会,大家一起检视代码、分析组件实现原理、分享最新的前端技术,欢迎加入我们!

感兴趣可以添加 DevUI 小助手微信:devui-official,拉你到我们的官方交流群。

加入 DevUI 开源社区你将收获:

直接的价值:

  1. 通过打造一个实际的vue3组件库项目,学习最新的Vite+Vue3+TypeScript+JSX技术
  2. 学习从0到1搭建一个自己的组件库的整套流程和方法论,包括组件库工程化、组件的设计和开发等
  3. 为自己的简历和职业生涯添彩,参与过优秀的开源项目,这本身就是受面试官青睐的亮点
  4. 结识一群优秀的、热爱学习、热爱开源的小伙伴,大家一起打造一个伟大的产品

长远的价值:

  1. 打造个人品牌,提升个人影响力
  2. 培养良好的编码习惯
  3. 获得华为云 DevUI 团队的荣誉&认可和定制小礼物
  4. 成为 PMC & Committer 之后还能参与 DevUI 整个开源生态的决策和长远规划,培养自己的管理和规划能力
  5. 未来有更多机会和可能

ErKeLost 同学在搭建 Create Vite App 和 Create Vue DevUI 脚手架项目,欢迎大家一起参与进来共建!

往期文章推荐:

🚀Turborepo:发布当月就激增 3.8k Star,这款超神的新兴 Monorepo 方案,你不打算尝试下吗?

前端Vuer,请收下这份《Vue3中使用JSX简明语法》

Vue DevUI:100多位贡献者持续530多天,写了近60000行代码,这个新鲜出炉的 Vue3 组件库你不想尝试下吗?