react-draggable 是怎样实现拖拽功能的

3,246 阅读5分钟

这是我参与更文挑战的第 6 天,活动详情查看:更文挑战

前言

react-draggable 是一个自由度非常高的 React 拖拽组件,它在 Github 上收到了 6.8k star。下面就来介绍下我在这个项目的源码里学到了什么。

一、源码分析

1.1 拖拽的实现原理

拖拽功能都在 <DraggableCore> 里实现, 源码点击这里

拖拽可以分为拖拽开始、拖拽中、拖拽结束过程,在这三个拖拽的过程中做如下的处理

  • 拖拽开始:记录拖拽的初始位置
  • 拖拽中:监听拖拽的距离和方向,并移动真实 dom
  • 拖拽结束:取消拖拽中的事件监听

这个三个拖拽过程被封装为: handleDragStarthandleDraghandleDragStop 三个方法,源码如下:

handleDragStart: EventHandler<MouseTouchEvent> = (e) => {
  // Make it possible to attach event handlers on top of this one.
  this.props.onMouseDown(e);

  // Only accept left-clicks.
  if (!this.props.allowAnyClick && typeof e.button === 'number' && e.button !== 0) return false;

  // Get nodes. Be sure to grab relative document (could be iframed)
  const thisNode = this.findDOMNode();
  if (!thisNode || !thisNode.ownerDocument || !thisNode.ownerDocument.body) {
    throw new Error('<DraggableCore> not mounted on DragStart!');
  }
  const {ownerDocument} = thisNode;

  // Short circuit if handle or cancel prop was provided and selector doesn't match.
  if (this.props.disabled ||
    (!(e.target instanceof ownerDocument.defaultView.Node)) ||
    (this.props.handle && !matchesSelectorAndParentsTo(e.target, this.props.handle, thisNode)) ||
    (this.props.cancel && matchesSelectorAndParentsTo(e.target, this.props.cancel, thisNode))) {
    return;
  }

  // Prevent scrolling on mobile devices, like ipad/iphone.
  // Important that this is after handle/cancel.
  if (e.type === 'touchstart') e.preventDefault();

  // Set touch identifier in component state if this is a touch event. This allows us to
  // distinguish between individual touches on multitouch screens by identifying which
  // touchpoint was set to this element.
  const touchIdentifier = getTouchIdentifier(e);
  this.setState({touchIdentifier});

  // Get the current drag point from the event. This is used as the offset.
  const position = getControlPosition(e, touchIdentifier, this);
  if (position == null) return; // not possible but satisfies flow
  const {x, y} = position;

  // Create an event object with all the data parents need to make a decision here.
  const coreEvent = createCoreData(this, x, y);

  log('DraggableCore: handleDragStart: %j', coreEvent);

  // Call event handler. If it returns explicit false, cancel.
  log('calling', this.props.onStart);
  const shouldUpdate = this.props.onStart(e, coreEvent);
  if (shouldUpdate === false || this.mounted === false) return;

  // Add a style to the body to disable user-select. This prevents text from
  // being selected all over the page.
  if (this.props.enableUserSelectHack) addUserSelectStyles(ownerDocument);

  // Initiate dragging. Set the current x and y as offsets
  // so we know how much we've moved during the drag. This allows us
  // to drag elements around even if they have been moved, without issue.
  this.setState({
    dragging: true,

    lastX: x,
    lastY: y
  });

  // Add events to the document directly so we catch when the user's mouse/touch moves outside of
  // this element. We use different events depending on whether or not we have detected that this
  // is a touch-capable device.
  addEvent(ownerDocument, dragEventFor.move, this.handleDrag);
  addEvent(ownerDocument, dragEventFor.stop, this.handleDragStop);
};

handleDrag: EventHandler<MouseTouchEvent> = (e) => {

  // Get the current drag point from the event. This is used as the offset.
  const position = getControlPosition(e, this.state.touchIdentifier, this);
  if (position == null) return;
  let {x, y} = position;

  // Snap to grid if prop has been provided
  if (Array.isArray(this.props.grid)) {
    let deltaX = x - this.state.lastX, deltaY = y - this.state.lastY;
    [deltaX, deltaY] = snapToGrid(this.props.grid, deltaX, deltaY);
    if (!deltaX && !deltaY) return; // skip useless drag
    x = this.state.lastX + deltaX, y = this.state.lastY + deltaY;
  }

  const coreEvent = createCoreData(this, x, y);

  log('DraggableCore: handleDrag: %j', coreEvent);

  // Call event handler. If it returns explicit false, trigger end.
  const shouldUpdate = this.props.onDrag(e, coreEvent);
  if (shouldUpdate === false || this.mounted === false) {
    try {
      // $FlowIgnore
      this.handleDragStop(new MouseEvent('mouseup'));
    } catch (err) {
      // Old browsers
      const event = ((document.createEvent('MouseEvents'): any): MouseTouchEvent);
      // I see why this insanity was deprecated
      // $FlowIgnore
      event.initMouseEvent('mouseup', true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null);
      this.handleDragStop(event);
    }
    return;
  }

  this.setState({
    lastX: x,
    lastY: y
  });
};

handleDragStop: EventHandler<MouseTouchEvent> = (e) => {
  if (!this.state.dragging) return;

  const position = getControlPosition(e, this.state.touchIdentifier, this);
  if (position == null) return;
  const {x, y} = position;
  const coreEvent = createCoreData(this, x, y);

  // Call event handler
  const shouldContinue = this.props.onStop(e, coreEvent);
  if (shouldContinue === false || this.mounted === false) return false;

  const thisNode = this.findDOMNode();
  if (thisNode) {
    // Remove user-select hack
    if (this.props.enableUserSelectHack) removeUserSelectStyles(thisNode.ownerDocument);
  }

  log('DraggableCore: handleDragStop: %j', coreEvent);

  // Reset the el.
  this.setState({
    dragging: false,
    lastX: NaN,
    lastY: NaN
  });

  if (thisNode) {
    // Remove event handlers
    log('DraggableCore: Removing handlers');
    removeEvent(thisNode.ownerDocument, dragEventFor.move, this.handleDrag);
    removeEvent(thisNode.ownerDocument, dragEventFor.stop, this.handleDragStop);
  }
};

另外 react-draggable 还对移动端也做了相应的处理来触发这三个方法。

1.2 统一拖拽事件参数

react-draggable 封装了 handleDragStarthandleDraghandleDragStop 这三个方法,也在DraggableCoreProps 有对应的 onStartonDragonStop 三个方法。并且这三个 props 的函数参数都保持了一致,非常方便做二次封装。

  export type DraggableEvent = React.MouseEvent<HTMLElement | SVGElement>
    | React.TouchEvent<HTMLElement | SVGElement>
    | MouseEvent
    | TouchEvent

  export type DraggableEventHandler = (
    e: DraggableEvent,
    data: DraggableData
  ) => void | false;

  export interface DraggableData {
    node: HTMLElement,
    x: number, y: number,
    deltaX: number, deltaY: number,
    lastX: number, lastY: number
  }

export interface DraggableCoreProps {
    allowAnyClick: boolean,
    cancel: string,
    disabled: boolean,
    enableUserSelectHack: boolean,
    offsetParent: HTMLElement,
    grid: [number, number],
    handle: string,
    nodeRef?: React.RefObject<HTMLElement>,
    onStart: DraggableEventHandler,
    onDrag: DraggableEventHandler,
    onStop: DraggableEventHandler,
    onMouseDown: (e: MouseEvent) => void,
    scale: number
  }

1.3 受控组件和非受控组件的应用

react-draggable 提供了 <Draggable><DraggableCore> 进行封装

export interface DraggableProps extends DraggableCoreProps {
  axis: 'both' | 'x' | 'y' | 'none',
  bounds: DraggableBounds | string | false ,
  defaultClassName: string,
  defaultClassNameDragging: string,
  defaultClassNameDragged: string,
  defaultPosition: ControlPosition,
  positionOffset: PositionOffsetControlPosition,
  position: ControlPosition
}

class Draggable extends React.Component<DraggableProps, DraggableState> {
  render(): ReactElement<any> {
    const {
      axis,
      bounds,
      children,
      defaultPosition,
      defaultClassName,
      defaultClassNameDragging,
      defaultClassNameDragged,
      position,
      positionOffset,
      scale,
      ...draggableCoreProps
    } = this.props;

    let style = {};
    let svgTransform = null;

    // If this is controlled, we don't want to move it - unless it's dragging.
    const controlled = Boolean(position);
    const draggable = !controlled || this.state.dragging;

    const validPosition = position || defaultPosition;
    const transformOpts = {
      // Set left if horizontal drag is enabled
      x: canDragX(this) && draggable ?
        this.state.x :
        validPosition.x,

      // Set top if vertical drag is enabled
      y: canDragY(this) && draggable ?
        this.state.y :
        validPosition.y
    };

    // If this element was SVG, we use the `transform` attribute.
    if (this.state.isElementSVG) {
      svgTransform = createSVGTransform(transformOpts, positionOffset);
    } else {
      // Add a CSS transform to move the element around. This allows us to move the element around
      // without worrying about whether or not it is relatively or absolutely positioned.
      // If the item you are dragging already has a transform set, wrap it in a <span> so <Draggable>
      // has a clean slate.
      style = createCSSTransform(transformOpts, positionOffset);
    }

    // Mark with class while dragging
    const className = classNames((children.props.className || ''), defaultClassName, {
      [defaultClassNameDragging]: this.state.dragging,
      [defaultClassNameDragged]: this.state.dragged
    });

    // Reuse the child provided
    // This makes it flexible to use whatever element is wanted (div, ul, etc)
    return (
      <DraggableCore {...draggableCoreProps} onStart={this.onDragStart} onDrag={this.onDrag} onStop={this.onDragStop}>
        {React.cloneElement(React.Children.only(children), {
          className: className,
          style: {...children.props.style, ...style},
          transform: svgTransform
        })}
      </DraggableCore>
    );
  }
}

DraggableProps.position 如果不传, <DraggableCore> 就是一个非受控组件,相反地如果传了 xy 坐标,就会是一个受控组件了。这种封装的方式也为使用者提供了可以选择的空间。

1.4 父组件如何优雅地封装子组件

React.cloneElement(React.Children.only(children), {
  className: className,
  style: {...children.props.style, ...style},
  transform: svgTransform
})

这段代码里还用到了 React.cloneElementReact.Children.only 这两个 API,通过这两个方法可以验证需要拖拽的组件是一个单独的 DOM,并能够方便地添加用来改变位置的 style 样式。

  1. React.Children.only: 验证 children 是否只有一个子节点(一个 React 元素),如果有则返回它,否则此方法会抛出错误。
  2. React.cloneElement: 以子元素为样板克隆并返回新的 React 元素。返回元素的 props 是将新的 props 与原始元素的 props 浅层合并后的结果。新的子元素将取代现有的子元素,而来自原始元素的 keyref 将被保留

二、总结

最后总结一下 react-draggable 的我认为的几个亮点

  1. <DraggableCore> 负责拖拽封装拖拽事件, <Draggable> 对于前者封装,并对拖拽的位置使用类似装饰器模式的方式来增强功能(比如限制拖拽边界、栅格拖动等)。这种分开封装的方式扩展性很强。
  2. 抽象出 handleDragStarthandleDraghandleDragStop 三个方法,这样在不同的运行环境只要保持一致的实现就能做到很好的适配。
  3. 允许用户可以自由选择受控/非受控,自由度很高

react-draggable 项目的作者后续还继续基于它开发了下面两个仓库,也是非常的好用:

  1. github.com/react-grid-…
  2. github.com/react-grid-…