结合React与原生DOM事件实现的滑动删除组件

  • April 11, 2017 07:42
  • Posted by lan
  • 0 comments

零. 前言

React也可以结合原生事件使用,本文阐述了如何结合React与原生DOM事件实现业务上比较常用的滑动删除组件。

滑动删除组件是公司的某个客户提出来的,作为增强功能的建议。

作为一个刚从Python转行过来的前端,可能会有些地方考虑得不够全面,请多多包涵。

我当时是用jQuery实现的,大体效果如下:

remove.gif

后来公司同事建议说:“可以考虑做成react组件。因为最近都有微信项目,指不定哪一天就用上了。”

OK,只不过如果用React的话我就不打算往React项目里面引入jQuery了,这意味着我得使用原生DOM事件,或者是React内置的混合事件(本文作为范例,两种事件都有涉及)。反正我觉得思路都是差不多的,只是jQuery实现起来比较方便罢了(当然还有不用考虑兼容性问题)。

在唠叨开始之前请先看看效果:

a.gif

或许是因为七牛云的图片压缩机制(本博客的图片托管到七牛云),托管的gif图片比预期的播放速度要慢,跟实际效果相比还是有挺大差距的

如果您不想看长篇大论,又或者是想体验真实效果,可以考虑直接移步到 Easy Slide

一. 需求

先来说说业务需求,毕竟没有需求的话则谈不上实现:

  1. 组件可以跟随手指左右滑动。
  2. 根据手势的趋势(向左还是向右滑动)来决定最后组件的状态(是否显示删除按钮)。
  3. 点击删除按钮,整个组件会慢慢淡化,最后从文档流中消失。

二. 思路

滑动删除组件一般出现在手机端(IOS的应用采用这种方式比较多),为此我这里便只讨论手机端的情况。代码也只能在手机端或者在浏览器的手机调试模式下才能生效(因为PC端没有touch事件)。

先介绍两个touch事件

  1. touchmove 触摸屏幕的时候,手指在屏幕上移动会触发该事件。
  2. touchend 松开手指的时候会触发这个事件。

我们可以通过监听touchmove事件,来让组件跟随手指的移动方向左右移动。当手指松开的时候我们会监听到touchend事件,通过判断组件移动的趋势,最终决定删除按钮是否显示。

三. 实现

再多的阐述也不如贴代码来得直观。示例代码的脚手架我是用create-react-app搭建的。

下面是组件的核心代码

// Slider.js
import React, { Component } from 'react';
import './Slider.css';

// http://stackoverflow.com/questions/5898656/test-if-an-element-contains-a-class
function hasClass(element, cls) {
    return (' ' + element.className + ' ').indexOf(' ' + cls + ' ') > -1;
}

class Slider extends Component {
  constructor() {
    super();
    this.handleSliderButtonClick = this.handleSliderButtonClick.bind(this);
  }

  handleSliderButtonClick(e) {
    let sliderButton = e.target;
    let sliderWrap = sliderButton.parentNode;
    sliderWrap.style.opacity = '0';
    sliderWrap.style.transition = '0.5s all ease-in';

    setTimeout(() => {
      sliderWrap.style.display = 'none';
    }, 500)
  }

  render() {
    let contentText = this.props.children;

    return (
      <div className="slider-wrap">
        <a className="slider-content">{contentText}</a>
        <a onClick={this.handleSliderButtonClick} className="slider-button">Delete</a>
      </div>
    );
  }
}

export const loadTouchEvent = () => {

  let currentX, prevX, direction, sliderButton;

  sliderButton = document.getElementsByClassName('slider-button')[0];

  let sliderButtonWidth = sliderButton.offsetWidth;

  // touchend 的事件回调函数
  let touchEndFunction = (e) => {
    let currentComponent = e.target;

    if (!hasClass(currentComponent, 'slider-content')) return;

    if (direction === 'left') {
      currentComponent.style.left = `-${sliderButtonWidth}px`;
    } else if (direction === 'right') {
      currentComponent.style.left = '0px';
    }

    prevX = undefined;
    currentX = undefined;

    // 取消过渡效果
    currentComponent.style.transition = '0.3s all ease-in';


    // 每次这个事件调用之后移除对应的事件,免得内存泄漏
    currentComponent.removeEventListener('touchend', touchEndFunction);
    currentComponent.touchendExist = undefined;
  }

  document.body.addEventListener('touchmove', e => {
    let currentComponent = e.target;


    // 判断是否是目标的对象
    if (!hasClass(currentComponent, 'slider-content')) return;

    currentX = e.touches[0].pageX;
    currentComponent.style.transition = '';

    let currentComponentOffset = currentComponent.offsetLeft;

    if (prevX) {
      if (currentComponentOffset <= 0 && currentComponentOffset >= -sliderButtonWidth) {

        if (currentX < prevX) {
          direction = 'left';
          currentComponent.style.left = (currentComponentOffset - 1) + 'px';

        } else {
          direction = 'right';
          currentComponent.style.left = (currentComponentOffset + 1) + 'px';
        }
      }
    }

    prevX = currentX;

    // 判断是否绑定了`touchend`事件,适用组件里面的一个属性类记录状态
    if (!currentComponent.touchendExist) {
      currentComponent.addEventListener('touchend', touchEndFunction);
      currentComponent.touchendExist = true;
    }
  });

}

export const destoryTouchEvent = () => {
  // 在React中使用DOM原声事件的时候一定要在组件卸载的时候手动移除,以免内存泄漏  
  document.body.removeEventListener('touchmove');
}

export default Slider;

1. 滑动删除组件的基本骨架

先来谈谈整个组件的基本结构,以及简单的业务逻辑。理论上我们只需要渲染3个html标签。然后通过常规的React事件绑定,给删除按钮添加了对应的事件处理程序(handleSliderButtonClick方法)。它做的工作是通过事件对象获取当前删除按钮的父元素节点(含有类名为slider-wrap的元素),然后让该节点渐渐透明,最终消失不见。

由代码可知,我加了0.5s的过渡效果,这意味着目标元素从不透明状态过渡到透明状态需要0.5s的时间。为了保证过渡效果我在0.5s之后再调用方法把元素样式的 display属性设置为none(防止元素继续占用标准文档流)。

2. 组件跟随手指滑动

接下来就要谈谈loadTouchEvent方法里面的业务逻辑,这部分代码逻辑会稍微复杂些。这个方法要放在组件的顶部容器调用(在本例子里面顶部容器是App组件),让body可以统一监听touch事件。

本文作为示例,采用了直接操作原生的DOM事件的方式实现,在正式的业务环境下可以考虑使用React混合事件实现。

这个方法主要做的事情有:

  • 监听touchmove事件。通过变量currentX, prevX来记录手指触摸屏幕时的水平方向的坐标。记录手指先前的位置(prevX),以及当前位置(currentX)。根据两个位置的数值来判断手指是向左移动还是向右移动。把手指移动的趋势记录到direction变量里面(供后面touchend事件的事件处理程序使用)。

  • 当放开手指的时候便会触发touchend事件,通过direction变量判断手指移动的方向,最终决定可滑动组件的状态。最后重置部分变量。

3. 移除事件

通过destoryTouchEvent方法来移除绑定在body上的touchmove事件。

最后我会在App.js里面渲染对应的组件

// App.js
import React, { Component } from 'react';
import logo from './logo.svg';
import './App.css';
import Slider, {loadTouchEvent, destoryTouchEvent} from './Slider/Slider';


const data = [
  "Lan",
  "Zhang",
  "Wang",
  "Liang",
  "Lin",
  "Huang",
  "Yang"
]


class App extends Component {

  componentDidMount() {

    // 绑定touchmove事件
    loadTouchEvent();
  }

  componentWillUnmount() {

    // 移除touchmove事件
    destoryTouchEvent();
  }

  render() {
    return (
      <div className="App">
        {data.map(item => {
          return <Slider key={item}>{item}</Slider>
        })}

      </div>
    );
  }
}

export default App;

这部分逻辑还是比较简单的,我只是通过数组映射来渲染多个Slider组件。需要注意的是App组件作为顶层容器需要分别在componentDidMountcomponentWillUnmount两个生命周期绑定以及移除touchmove事件(为什么需要移除?我后面会提到)。

四. 注意点

1. 手动移除DOM原生事件

React会给我们提供一些事件接口,这类事件是React内部包装的混合事件。在第一个代码片段的onClick接口用的就是React提供的混合事件接口,它绑定的事件处理函数是handleSliderButtonClick

当实现滑动操作的时候,我尝试使用原生的DOM事件来实现。

在React中使用DOM原生事件的时候,一定要在卸载组件的时候手动移除事件,否则很可能出现内存溢出问题。

以上是有经验的前辈给的忠告。

我在绑定原生事件的时候都会尽可能把对应的事件移除,以免出现前辈们提到的内存泄漏问题(当然我不一定是在组件卸载的时候才移除)。

为此我在顶级容器加载(调用componentDidMount)的时候手动调用loadTouchEvent方法让body可以监听touchmove事件,在顶级容器销毁(调用componentWillUnmount方法)的时候,我还需要手动调用destoryTouchEvent来移除对应的touchmove事件。

2. 在body上监听touchmove事件

只绑定一个事件肯定会比在每个组件里面都绑定对应事件性能要好些。因此我是在body里面统一监听touchmove事件,然后判断事件源是否是我们期望的组件,如果是则运行对应的代码。

3. 在需要的时候才绑定touchend事件
  • 在触发过touchmove事件的组件上才绑定touchend事件。
  • 如果把touchend事件绑定到body元素上面的话,同类组件之间可能会相互干扰,导致行为混乱。
  • 在每次touchend事件处理函数执行完后,解绑组件上的这个事件(手动移除原生事件)。
  • 为避免重复绑定touchend事件,我通过设置组件的一个属性值touchendExist,作为判断组件是否已经绑定该事件的标志。

五. 不足

这个组件目前来说是可以正常运行了,但是还算不上是一个合格的组件。上述例子中我采用原生事件,并且用body统一监听touchmove事件,为此,使用者不得不手动去绑定以及移除我们设定好的touchmove事件(通过loadTouchEvent, destoryTouchEvent两个方法)。

本文作为阐述性的文章,故而使用了原生事件来实现。后期优化组件的时候会考虑采用React的混合事件来实现对应的业务逻辑,使用者不需要再手动地去绑定,以及移除对应的事件。这些东西可以封装到组件内部,把该组件做成一个开箱即用的组件。

六. 尾声

该组件已经被托管到Easy Slide上,如果您觉得这些代码段有很大的槽点,我希望您能给予宝贵的修改意见(可以考虑在github上建issue,或者直接评论)。

感谢您的阅读

Happy Coding and Writing !!

Comments

Post your comment