我想制作一个可拖动的(即可以通过鼠标重新定位)React 组件,这似乎必然涉及全局状态和分散的事件处理程序。我可以用肮脏的方式来做,在我的 JS 文件中使用一个全局变量,甚至可以将它包装在一个很好的闭包界面中,但我想知道是否有一种方法可以更好地与 React 结合。
此外,由于我以前从未在原始 JavaScript 中做过这件事,我想看看专家是如何做的,以确保我已经处理了所有极端情况,尤其是与 React 相关的情况。
谢谢。
我想制作一个可拖动的(即可以通过鼠标重新定位)React 组件,这似乎必然涉及全局状态和分散的事件处理程序。我可以用肮脏的方式来做,在我的 JS 文件中使用一个全局变量,甚至可以将它包装在一个很好的闭包界面中,但我想知道是否有一种方法可以更好地与 React 结合。
此外,由于我以前从未在原始 JavaScript 中做过这件事,我想看看专家是如何做的,以确保我已经处理了所有极端情况,尤其是与 React 相关的情况。
谢谢。
我可能应该把它变成一篇博客文章,但这是一个非常可靠的例子。
评论应该很好地解释了事情,但如果您有问题,请告诉我。
这是要玩的小提琴: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)
并将最终决定推迟到父级。如果父级不喜欢可拖动对象的结束位置,则它不会更新其状态,并且可拖动对象会在拖动之前“快速返回”到其初始位置。
@ssorallen 指出,因为“draggable”与其说是事物本身,不如说是一个属性,所以它可能更适合用作 mixin。我对 mixin 的经验有限,所以我还没有看到它们在复杂情况下如何提供帮助或阻碍。这很可能是最好的选择。
我实现了react-dnd,这是一个灵活的 HTML5 拖放混合,用于具有完整 DOM 控制的 React。
现有的拖放库不适合我的用例,所以我自己写了。它类似于我们在 Stampsy.com 上运行了大约一年的代码,但经过重写以利用 React 和 Flux。
我的主要要求:
如果这些听起来很熟悉,请继续阅读。
首先,声明可以拖动的数据类型。
这些用于检查拖放源和放置目标的“兼容性”:
// 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
。例如,您的应用程序中可能有多种图像表示形式,每个表示形式都会提供一个dragSource
for ItemTypes.IMAGE
。
AdragSource
只是一个指定拖动源如何工作的对象。您必须实现beginDrag
以返回表示您正在拖动的数据的项目,以及一些调整拖动 UI 的选项(可选)。您可以选择实现canDrag
禁止拖动,或endDrag(didDrop)
在发生(或未发生)放置时执行某些逻辑。你可以通过让一个共享的 mixindragSource
为它们生成来在组件之间共享这个逻辑。
最后,您必须使用{...this.dragSourceFor(itemType)}
一些(一个或多个)元素render
来附加拖动处理程序。这意味着您可以在一个元素中拥有多个“拖动手柄”,它们甚至可能对应于不同的项目类型。(如果您不熟悉JSX 扩展属性语法,请查看)。
假设我们想ImageBlock
成为IMAGE
s的放置目标。几乎是一样的,只是我们需要给出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 类或属性;dragPreview
要Image
使用的图像作为拖动占位符(使用ImagePreloaderMixin
加载它们);ImageBlocks
排序。我们只需要它们来实现dropTarget
和dragSource
为ItemTypes.BLOCK
.dropTargetFor(...types)
允许一次指定多种类型,因此一个拖放区可以捕获多种不同的类型。有关最新的文档和安装说明,请前往Github 上的 react-dnd 存储库。
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;
}
这是一个简单的现代方法,在useState
,useEffect
和useRef
ES6 中。
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
我已经将 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;