使 React 组件/div 可拖动的推荐方法

IT技术 javascript reactjs
2021-03-08 22:20:49

我想制作一个可拖动的(即可以通过鼠标重新定位)React 组件,这似乎必然涉及全局状态和分散的事件处理程序。我可以用肮脏的方式来做,在我的 JS 文件中使用一个全局变量,甚至可以将它包装在一个很好的闭包界面中,但我想知道是否有一种方法可以更好地与 React 结合。

此外,由于我以前从未在原始 JavaScript 中做过这件事,我想看看专家是如何做的,以确保我已经处理了所有极端情况,尤其是与 React 相关的情况。

谢谢。

6个回答

我可能应该把它变成一篇博客文章,但这是一个非常可靠的例子。

评论应该很好地解释了事情,但如果您有问题,请告诉我。

这是要玩的小提琴:http : //jsfiddle.net/Af9Jt/2/

var Draggable = React.createClass({
  getDefaultProps: function () {
    return {
      // allow the initial position to be passed in as a prop
      initialPos: {x: 0, y: 0}
    }
  },
  getInitialState: function () {
    return {
      pos: this.props.initialPos,
      dragging: false,
      rel: null // position relative to the cursor
    }
  },
  // we could get away with not having this (and just having the listeners on
  // our div), but then the experience would be possibly be janky. If there's
  // anything w/ a higher z-index that gets in the way, then you're toast,
  // etc.
  componentDidUpdate: function (props, state) {
    if (this.state.dragging && !state.dragging) {
      document.addEventListener('mousemove', this.onMouseMove)
      document.addEventListener('mouseup', this.onMouseUp)
    } else if (!this.state.dragging && state.dragging) {
      document.removeEventListener('mousemove', this.onMouseMove)
      document.removeEventListener('mouseup', this.onMouseUp)
    }
  },

  // calculate relative position to the mouse and set dragging=true
  onMouseDown: function (e) {
    // only left mouse button
    if (e.button !== 0) return
    var pos = $(this.getDOMNode()).offset()
    this.setState({
      dragging: true,
      rel: {
        x: e.pageX - pos.left,
        y: e.pageY - pos.top
      }
    })
    e.stopPropagation()
    e.preventDefault()
  },
  onMouseUp: function (e) {
    this.setState({dragging: false})
    e.stopPropagation()
    e.preventDefault()
  },
  onMouseMove: function (e) {
    if (!this.state.dragging) return
    this.setState({
      pos: {
        x: e.pageX - this.state.rel.x,
        y: e.pageY - this.state.rel.y
      }
    })
    e.stopPropagation()
    e.preventDefault()
  },
  render: function () {
    // transferPropsTo will merge style & other props passed into our
    // component to also be on the child DIV.
    return this.transferPropsTo(React.DOM.div({
      onMouseDown: this.onMouseDown,
      style: {
        left: this.state.pos.x + 'px',
        top: this.state.pos.y + 'px'
      }
    }, this.props.children))
  }
})

对国家所有制等的思考。

从一开始,“谁应该拥有什么状态”就是一个需要回答的重要问题。对于“可拖动”组件,我可以看到一些不同的场景。

场景一

父级应该拥有可拖动的当前位置。在这种情况下,可拖动对象仍将拥有“我正在拖动”状态,但会this.props.onChange(x, y)在发生 mousemove 事件时调用

场景二

父级只需要拥有“非移动位置”,因此可拖动对象将拥有它的“拖动位置”,但 onmouseup 它将调用this.props.onChange(x, y)并将最终决定推迟到父级。如果父级不喜欢可拖动对象的结束位置,则它不会更新其状态,并且可拖动对象会在拖动之前“快速返回”到其初始位置。

Mixin 还是组件?

@ssorallen 指出,因为“draggable”与其说是事物本身,不如说是一个属性,所以它可能更适合用作 mixin。我对 mixin 的经验有限,所以我还没有看到它们在复杂情况下如何提供帮助或阻碍。这很可能是最好的选择。

很好的例子。这似乎Mixin比完整的类更合适,因为“Draggable”实际上不是一个对象,它是对象的一种能力。
2021-04-19 22:20:49
您可以通过执行以下操作来删除 jquery 依赖项: var computedStyle = window.getComputedStyle(this.getDOMNode()); pos = { top: parseInt(computedStyle.top), left: parseInt(computedStyle.left) }; 如果您将 jquery 与 react 一起使用,您可能做错了;) 如果您需要一些 jquery 插件,我发现在纯 react 中重写它通常更容易且代码更少。
2021-04-25 22:20:49
并跟进 re this.getDOMNode(),这已被弃用。使用 ref 获取 dom 节点。 facebook.github.io/react/docs/...
2021-04-26 22:20:49
只是想跟进@MattCrinklaw-Vogt 上面的评论,说一个更防弹的解决方案是使用this.getDOMNode().getBoundingClientRect()- getComputedStyle 可以输出任何有效的 CSS 属性,包括auto在这种情况下,上面的代码将导致NaN. 请参阅 MDN 文章:developer.mozilla.org/en-US/docs/Web/API/Element/...
2021-05-01 22:20:49
我玩了一下,似乎拖到它的父级之外并没有做任何事情,但是当它拖到另一个反应组件中时会发生各种奇怪的事情
2021-05-05 22:20:49

我实现了react-dnd,这是一个灵活的 HTML5 拖放混合,用于具有完整 DOM 控制的 React。

现有的拖放库不适合我的用例,所以我自己写了。它类似于我们在 Stampsy.com 上运行了大约一年的代码,但经过重写以利用 React 和 Flux。

我的主要要求:

  • 发出自己的零 DOM 或 CSS,将其留给消费组件;
  • 对消耗组件施加尽可能少的结构;
  • 使用 HTML5 拖放作为主要后端,但可以在未来添加不同的后端;
  • 像原始的 HTML5 API 一样,强调拖动数据而不仅仅是“可拖动的视图”;
  • 从消费代码中隐藏 HTML5 API 怪癖;
  • 对于不同类型的数据,不同的组件可能是“拖动源”或“放置目标”;
  • 允许一个组件在需要时包含多个拖放源和放置目标;
  • 如果拖动或悬停兼容的数据,则使放置目标可以轻松更改其外观;
  • 使拖动缩略图而不是元素屏幕截图的图像更容易使用,从而避免浏览器的怪癖。

如果这些听起来很熟悉,请继续阅读。

用法

简单的拖动源

首先,声明可以拖动的数据类型。

这些用于检查拖放源和放置目标的“兼容性”:

// ItemTypes.js
module.exports = {
  BLOCK: 'block',
  IMAGE: 'image'
};

(如果您没有多种数据类型,则此库可能不适合您。)

然后,让我们制作一个非常简单的可拖动组件,拖动时表示IMAGE

var { DragDropMixin } = require('react-dnd'),
    ItemTypes = require('./ItemTypes');

var Image = React.createClass({
  mixins: [DragDropMixin],

  configureDragDrop(registerType) {

    // Specify all supported types by calling registerType(type, { dragSource?, dropTarget? })
    registerType(ItemTypes.IMAGE, {

      // dragSource, when specified, is { beginDrag(), canDrag()?, endDrag(didDrop)? }
      dragSource: {

        // beginDrag should return { item, dragOrigin?, dragPreview?, dragEffect? }
        beginDrag() {
          return {
            item: this.props.image
          };
        }
      }
    });
  },

  render() {

    // {...this.dragSourceFor(ItemTypes.IMAGE)} will expand into
    // { draggable: true, onDragStart: (handled by mixin), onDragEnd: (handled by mixin) }.

    return (
      <img src={this.props.image.url}
           {...this.dragSourceFor(ItemTypes.IMAGE)} />
    );
  }
);

通过指定configureDragDrop,我们告诉DragDropMixin这个组件的拖放行为。可拖动和可放置组件都使用相同的 mixin。

在内部configureDragDrop,我们需要调用组件支持的registerType每个自定义ItemTypes例如,您的应用程序中可能有多种图像表示形式,每个表示形式都会提供一个dragSourcefor ItemTypes.IMAGE

AdragSource只是一个指定拖动源如何工作的对象。您必须实现beginDrag以返回表示您正在拖动的数据的项目,以及一些调整拖动 UI 的选项(可选)。您可以选择实现canDrag禁止拖动,或endDrag(didDrop)在发生(或未发生)放置时执行某些逻辑。你可以通过让一个共享的 mixindragSource为它们生成在组件之间共享这个逻辑

最后,您必须使用{...this.dragSourceFor(itemType)}一些(一个或多个)元素render来附加拖动处理程序。这意味着您可以在一个元素中拥有多个“拖动手柄”,它们甚至可能对应于不同的项目类型。(如果您不熟悉JSX 扩展属性语法,请查看)。

简单放置目标

假设我们想ImageBlock成为IMAGEs的放置目标几乎是一样的,只是我们需要给出registerType一个dropTarget实现:

var { DragDropMixin } = require('react-dnd'),
    ItemTypes = require('./ItemTypes');

var ImageBlock = React.createClass({
  mixins: [DragDropMixin],

  configureDragDrop(registerType) {

    registerType(ItemTypes.IMAGE, {

      // dropTarget, when specified, is { acceptDrop(item)?, enter(item)?, over(item)?, leave(item)? }
      dropTarget: {
        acceptDrop(image) {
          // Do something with image! for example,
          DocumentActionCreators.setImage(this.props.blockId, image);
        }
      }
    });
  },

  render() {

    // {...this.dropTargetFor(ItemTypes.IMAGE)} will expand into
    // { onDragEnter: (handled by mixin), onDragOver: (handled by mixin), onDragLeave: (handled by mixin), onDrop: (handled by mixin) }.

    return (
      <div {...this.dropTargetFor(ItemTypes.IMAGE)}>
        {this.props.image &&
          <img src={this.props.image.url} />
        }
      </div>
    );
  }
);

在一个组件中拖动源 + 放置目标

假设我们现在希望用户能够从ImageBlock. 我们只需要为dragSource添加适当的和一些处理程序:

var { DragDropMixin } = require('react-dnd'),
    ItemTypes = require('./ItemTypes');

var ImageBlock = React.createClass({
  mixins: [DragDropMixin],

  configureDragDrop(registerType) {

    registerType(ItemTypes.IMAGE, {

      // Add a drag source that only works when ImageBlock has an image:
      dragSource: {
        canDrag() {
          return !!this.props.image;
        },

        beginDrag() {
          return {
            item: this.props.image
          };
        }
      }

      dropTarget: {
        acceptDrop(image) {
          DocumentActionCreators.setImage(this.props.blockId, image);
        }
      }
    });
  },

  render() {

    return (
      <div {...this.dropTargetFor(ItemTypes.IMAGE)}>

        {/* Add {...this.dragSourceFor} handlers to a nested node */}
        {this.props.image &&
          <img src={this.props.image.url}
               {...this.dragSourceFor(ItemTypes.IMAGE)} />
        }
      </div>
    );
  }
);

还有什么可能?

我没有涵盖所有内容,但可以通过更多方式使用此 API:

  • 使用getDragState(type)getDropState(type)了解拖动是否处于活动状态,并使用它来切换 CSS 类或属性;
  • 指定dragPreviewImage使用的图像作为拖动占位符(使用ImagePreloaderMixin加载它们);
  • 说,我们想要重新ImageBlocks排序。我们只需要它们来实现dropTargetdragSourceItemTypes.BLOCK.
  • 假设我们添加其他类型的块。我们可以通过将其放入 mixin 来重用它们的重新排序逻辑。
  • dropTargetFor(...types) 允许一次指定多种类型,因此一个拖放区可以捕获多种不同的类型。
  • 当您需要更细粒度的控制时,大多数方法都将导致它们的拖动事件作为最后一个参数传递。

有关最新的文档和安装说明,请前往Github 上的 react-dnd 存储库

@DanAbramov 我很感谢你为这篇文章所做的努力,没有它我可能不会如此迅速地转向react-dnd像许多你的产品,似乎不必要的复杂,在第一,但在primely专业的方式放在一起(要通过你的文档我平时学习软件工程的两件事...)我读过“谁不知道 unix 注定要重新实现它......很糟糕,”我觉得类似的东西可能适用于你的许多创作(包括这个)。
2021-04-27 22:20:49
使用 react-dnd 更新了可自由拖动内容的官方示例的链接:基本高级
2021-05-09 22:20:49
除了使用鼠标之外,拖放和鼠标拖动还有什么共同点?您的回答根本与问题无关,显然是图书馆广告。
2021-05-11 22:20:49

Jared Forsyth 的答案是非常错误和过时的。它遵循一整套反模式,例如 of 的使用stopPropagation从 props 初始化状态、jQuery 的使用、状态中的嵌套对象,并具有一些奇怪的dragging状态字段。如果被重写,解决方案如下,但它仍然在每个鼠标移动刻度上强制虚拟 DOM 协调,并且性能不是很好。

更新。我的回答是非常错误和过时的。现在,代码通过使用本机事件处理程序和样式更新来缓解 React 组件生命周期缓慢的问题,使用transform它不会导致回流,并通过requestAnimationFrame. 现在,在我尝试过的每个浏览器中,它始终保持 60 FPS。

const throttle = (f) => {
    let token = null, lastArgs = null;
    const invoke = () => {
        f(...lastArgs);
        token = null;
    };
    const result = (...args) => {
        lastArgs = args;
        if (!token) {
            token = requestAnimationFrame(invoke);
        }
    };
    result.cancel = () => token && cancelAnimationFrame(token);
    return result;
};

class Draggable extends React.PureComponent {
    _relX = 0;
    _relY = 0;
    _ref = React.createRef();

    _onMouseDown = (event) => {
        if (event.button !== 0) {
            return;
        }
        const {scrollLeft, scrollTop, clientLeft, clientTop} = document.body;
        // Try to avoid calling `getBoundingClientRect` if you know the size
        // of the moving element from the beginning. It forces reflow and is
        // the laggiest part of the code right now. Luckily it's called only
        // once per click.
        const {left, top} = this._ref.current.getBoundingClientRect();
        this._relX = event.pageX - (left + scrollLeft - clientLeft);
        this._relY = event.pageY - (top + scrollTop - clientTop);
        document.addEventListener('mousemove', this._onMouseMove);
        document.addEventListener('mouseup', this._onMouseUp);
        event.preventDefault();
    };

    _onMouseUp = (event) => {
        document.removeEventListener('mousemove', this._onMouseMove);
        document.removeEventListener('mouseup', this._onMouseUp);
        event.preventDefault();
    };

    _onMouseMove = (event) => {
        this.props.onMove(
            event.pageX - this._relX,
            event.pageY - this._relY,
        );
        event.preventDefault();
    };

    _update = throttle(() => {
        const {x, y} = this.props;
        this._ref.current.style.transform = `translate(${x}px, ${y}px)`;
    });

    componentDidMount() {
        this._ref.current.addEventListener('mousedown', this._onMouseDown);
        this._update();
    }

    componentDidUpdate() {
        this._update();
    }

    componentWillUnmount() {
        this._ref.current.removeEventListener('mousedown', this._onMouseDown);
        this._update.cancel();
    }

    render() {
        return (
            <div className="draggable" ref={this._ref}>
                {this.props.children}
            </div>
        );
    }
}

class Test extends React.PureComponent {
    state = {
        x: 100,
        y: 200,
    };

    _move = (x, y) => this.setState({x, y});

    // you can implement grid snapping logic or whatever here
    /*
    _move = (x, y) => this.setState({
        x: ~~((x - 5) / 10) * 10 + 5,
        y: ~~((y - 5) / 10) * 10 + 5,
    });
    */

    render() {
        const {x, y} = this.state;
        return (
            <Draggable x={x} y={y} onMove={this._move}>
                Drag me
            </Draggable>
        );
    }
}

ReactDOM.render(
    <Test />,
    document.getElementById('container'),
);

和一点 CSS

.draggable {
    /* just to size it to content */
    display: inline-block;
    /* opaque background is important for performance */
    background: white;
    /* avoid selecting text while dragging */
    user-select: none;
}

JSFiddle 上的示例。

@ryanj 不,默认值是邪恶的,这就是问题所在。当道具改变时,正确的动作是什么?我们应该将状态重置为新的默认值吗?我们是否应该将新的默认值与旧的默认值进行比较,以便仅在默认值发生变化时才将状态重置为默认值?没有办法限制用户只能使用一个常量值,而没有其他方法。这就是为什么它是一个反模式。默认值应该通过高阶组件显式创建(即针对整个类,而不是针对一个对象),并且永远不应该通过 props 设置。
2021-04-16 22:20:49
我不同意 - 组件状态是存储特定于组件 UI 的数据的绝佳位置,例如,与整个应用程序无关。在某些情况下无法潜在地将默认值作为 props 传递,用于在挂载后检索此数据的选项是有限的,并且在许多(大多数?)情况下,与组件周围的变幻莫测相比可能会在某个时间传递不同的 someDefaultValue 道具更不理想稍后日期。我不提倡它作为最佳实践或任何类型的东西,它根本没有你建议的那么有害 imo
2021-04-17 22:20:49
无论如何,我们现在有钩子,我必须很快再次更新答案。
2021-04-20 22:20:49
确实非常简单和优雅的解决方案。我很高兴看到我对它的看法有点相似。我确实有一个问题:您提到性能不佳,您打算如何在考虑性能的情况下实现类似的功能?
2021-04-26 22:20:49
感谢这一点,这绝对不是性能最高的解决方案,但它遵循当今构建应用程序的最佳实践。
2021-05-04 22:20:49

这是一个简单的现代方法,在useState,useEffectuseRefES6 中。

import React, { useRef, useState, useEffect } from 'react'

const quickAndDirtyStyle = {
  width: "200px",
  height: "200px",
  background: "#FF9900",
  color: "#FFFFFF",
  display: "flex",
  justifyContent: "center",
  alignItems: "center"
}

const DraggableComponent = () => {
  const [pressed, setPressed] = useState(false)
  const [position, setPosition] = useState({x: 0, y: 0})
  const ref = useRef()

  // Monitor changes to position state and update DOM
  useEffect(() => {
    if (ref.current) {
      ref.current.style.transform = `translate(${position.x}px, ${position.y}px)`
    }
  }, [position])

  // Update the current position if mouse is down
  const onMouseMove = (event) => {
    if (pressed) {
      setPosition({
        x: position.x + event.movementX,
        y: position.y + event.movementY
      })
    }
  }

  return (
    <div
      ref={ ref }
      style={ quickAndDirtyStyle }
      onMouseMove={ onMouseMove }
      onMouseDown={ () => setPressed(true) }
      onMouseUp={ () => setPressed(false) }>
      <p>{ pressed ? "Dragging..." : "Press to drag" }</p>
    </div>
  )
}

export default DraggableComponent
此外,是否onMouseMove应该包含在useCallback中以及为什么module具有export default而不是命名导出也是有争议的
2021-04-20 22:20:49
现在我又读了一遍,onMouseMove仅在 after订阅onMouseDown可能对性能很重要,因为 React 必须在每个 mousemove 滴答时将浏览器的事件对象转换为合成事件对象,即使它因if (pressed)检查失败而立即被丢弃
2021-04-22 22:20:49
最新的,但不正确。position存储为状态,这使得组件在mousemove. 更糟糕的是,它没有任何节流。
2021-04-28 22:20:49
这似乎是这里最新的答案。
2021-04-29 22:20:49

我已经将 polkovnikov.ph 解决方案更新为 React 16 / ES6,其中包含诸如触摸处理和对齐网格之类的增强功能,这正是我游戏所需要的。捕捉到网格可以缓解性能问题。

import React from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';

class Draggable extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            relX: 0,
            relY: 0,
            x: props.x,
            y: props.y
        };
        this.gridX = props.gridX || 1;
        this.gridY = props.gridY || 1;
        this.onMouseDown = this.onMouseDown.bind(this);
        this.onMouseMove = this.onMouseMove.bind(this);
        this.onMouseUp = this.onMouseUp.bind(this);
        this.onTouchStart = this.onTouchStart.bind(this);
        this.onTouchMove = this.onTouchMove.bind(this);
        this.onTouchEnd = this.onTouchEnd.bind(this);
    }

    static propTypes = {
        onMove: PropTypes.func,
        onStop: PropTypes.func,
        x: PropTypes.number.isRequired,
        y: PropTypes.number.isRequired,
        gridX: PropTypes.number,
        gridY: PropTypes.number
    }; 

    onStart(e) {
        const ref = ReactDOM.findDOMNode(this.handle);
        const body = document.body;
        const box = ref.getBoundingClientRect();
        this.setState({
            relX: e.pageX - (box.left + body.scrollLeft - body.clientLeft),
            relY: e.pageY - (box.top + body.scrollTop - body.clientTop)
        });
    }

    onMove(e) {
        const x = Math.trunc((e.pageX - this.state.relX) / this.gridX) * this.gridX;
        const y = Math.trunc((e.pageY - this.state.relY) / this.gridY) * this.gridY;
        if (x !== this.state.x || y !== this.state.y) {
            this.setState({
                x,
                y
            });
            this.props.onMove && this.props.onMove(this.state.x, this.state.y);
        }        
    }

    onMouseDown(e) {
        if (e.button !== 0) return;
        this.onStart(e);
        document.addEventListener('mousemove', this.onMouseMove);
        document.addEventListener('mouseup', this.onMouseUp);
        e.preventDefault();
    }

    onMouseUp(e) {
        document.removeEventListener('mousemove', this.onMouseMove);
        document.removeEventListener('mouseup', this.onMouseUp);
        this.props.onStop && this.props.onStop(this.state.x, this.state.y);
        e.preventDefault();
    }

    onMouseMove(e) {
        this.onMove(e);
        e.preventDefault();
    }

    onTouchStart(e) {
        this.onStart(e.touches[0]);
        document.addEventListener('touchmove', this.onTouchMove, {passive: false});
        document.addEventListener('touchend', this.onTouchEnd, {passive: false});
        e.preventDefault();
    }

    onTouchMove(e) {
        this.onMove(e.touches[0]);
        e.preventDefault();
    }

    onTouchEnd(e) {
        document.removeEventListener('touchmove', this.onTouchMove);
        document.removeEventListener('touchend', this.onTouchEnd);
        this.props.onStop && this.props.onStop(this.state.x, this.state.y);
        e.preventDefault();
    }

    render() {
        return <div
            onMouseDown={this.onMouseDown}
            onTouchStart={this.onTouchStart}
            style={{
                position: 'absolute',
                left: this.state.x,
                top: this.state.y,
                touchAction: 'none'
            }}
            ref={(div) => { this.handle = div; }}
        >
            {this.props.children}
        </div>;
    }
}

export default Draggable;
嗨@anyhotcountry 你用gridX系数做什么?
2021-04-16 22:20:49
这对我来说非常有效。我在 onStart() 函数中进行了更改:计算 relX 和 relY 我使用了 e.clienX-this.props.x。这允许我将可拖动组件放置在父容器内,而不是依赖于整个页面作为拖动区域。我知道这是一个迟到的评论,但只是想说声谢谢。
2021-04-26 22:20:49
@AlexNikonov 它是(对齐)网格在 x 方向上的大小。建议使用 gridX 和 gridY > 1 以提高性能。
2021-05-12 22:20:49