这是我参与更文挑战的第 6 天,活动详情查看:更文挑战
前言
react-draggable 是一个自由度非常高的 React 拖拽组件,它在 Github 上收到了 6.8k star。下面就来介绍下我在这个项目的源码里学到了什么。
一、源码分析
1.1 拖拽的实现原理
拖拽功能都在 <DraggableCore>
里实现, 源码点击这里
拖拽可以分为拖拽开始、拖拽中、拖拽结束过程,在这三个拖拽的过程中做如下的处理
- 拖拽开始:记录拖拽的初始位置
- 拖拽中:监听拖拽的距离和方向,并移动真实 dom
- 拖拽结束:取消拖拽中的事件监听
这个三个拖拽过程被封装为: handleDragStart
、 handleDrag
和 handleDragStop
三个方法,源码如下:
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 封装了 handleDragStart
、handleDrag
和 handleDragStop
这三个方法,也在DraggableCoreProps
有对应的 onStart
、onDrag
和 onStop
三个方法。并且这三个 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.cloneElement
和 React.Children.only
这两个 API,通过这两个方法可以验证需要拖拽的组件是一个单独的 DOM,并能够方便地添加用来改变位置的 style 样式。
- React.Children.only: 验证
children
是否只有一个子节点(一个 React 元素),如果有则返回它,否则此方法会抛出错误。- React.cloneElement: 以子元素为样板克隆并返回新的 React 元素。返回元素的 props 是将新的 props 与原始元素的 props 浅层合并后的结果。新的子元素将取代现有的子元素,而来自原始元素的
key
和ref
将被保留
二、总结
最后总结一下 react-draggable 的我认为的几个亮点
<DraggableCore>
负责拖拽封装拖拽事件,<Draggable>
对于前者封装,并对拖拽的位置使用类似装饰器模式的方式来增强功能(比如限制拖拽边界、栅格拖动等)。这种分开封装的方式扩展性很强。- 抽象出
handleDragStart
、handleDrag
和handleDragStop
三个方法,这样在不同的运行环境只要保持一致的实现就能做到很好的适配。 - 允许用户可以自由选择受控/非受控,自由度很高
react-draggable 项目的作者后续还继续基于它开发了下面两个仓库,也是非常的好用: