可插拔式架构---实现拖拽组件
作为一个前端程序员,要想实现一个拖拽元素的效果,通常我们可以将其简单的分为以下几步:
- 监听触发 start 事件,记录下元素起始位置。
- 监听触发 move 事件,通过计算当前位置和初始位置之间的二维距离,将其应用于transform属性,实现元素的拖拽效果。
- 监听触发 end 事件,将最终获取的元素位置,设置为元素的当前位置,实现元素放置。
思路很简单,实现起来也不难。但是,考虑到可拓展性,实现更加可定制的功能,如支持多元素拖拽、绘制放置区域、拖拽中动画、拖拽排序等等,我们必须将各个核心功能解耦,构建出更灵活的代码架构。
下面,我将用 react 先来实现一个拖拽功能的简单架构。
Context 全局管理上下文
首先我想要通过Context
来对所有可拖拽元素进行管理,并且记录下激活元素以及其状态。
export function DndContext({
children,
}: DndContextProps) {
// Avoid multiple contexts using the same state
const [state, dispatch] = useReducer(reducer, undefined, initialState);
const { manager, transform, activeId } = state;
// activeNoede origin information on Start
const activeRectRef = useRef<{ initOffset: Coordinate; clientRect: DOMRect } | null>(null);
//...
const initialContextValue: DndContextDescriptor = {
...state,
dispatch,
activator,
};
return (
<Context.Provider value={initialContextValue}>
{children}
</Context.Provider>
);
}
如上所述,一个全局管理 Context 可能包括一个管理中心 manager,当前移动的距离 transform,当前拖拽元素唯一标识 activeId。
其中,你的 manager 的类型结构可能是这样:
// 元素类型
export type DraggableNode = {
id: UniqueIdentifier;
// node position information
clientRect?: MutableRefObject<DOMRect | undefined>;
node?: React.MutableRefObject<HTMLElement>;
// custom data:
[x: string]: any;
};
// 全局管理中心
export default class Manager {
private nodes: {
draggables: DraggableNode[];
};
constructor() {
this.nodes = {
draggables: []
};
}
/**注册拖拽元素 */
push(node: DraggableNode) {
this.nodes['draggables'].push(node);
}
/**获取拖拽元素 */
getNode(id: UniqueIdentifier) {
const nodes = this.nodes['draggables'];
for (let index = 0; index < nodes.length; index++) {
const node = nodes[index];
if (node?.id === id) return node;
}
return null;
}
//......
}
其次最核心的功能就是事件监听器。这里为了考虑可插拔式的插件结构,我将单独的 Event 抽离出来一个类:
const EVENTS = {
start: ['mousedown'],
move: ['mousemove'],
end: ['mouseup']
};
export class MouseSensor {
static eventName = 'onMouseDown';
constructor(private props: MouseSensorProps) {
const { manager, listener } = props;
this.manager = manager;
// 这里等同于document.addEventListener(...)
this.windowListeners = listener;
this.reset();
this.handleStart = this.handleStart.bind(this);
this.handleMove = this.handleMove.bind(this);
this.handleEnd = this.handleEnd.bind(this);
}
handleStart(event: Event, id: UniqueIdentifier) {
// start hooks钩子函数
const { onStart } = this.props;
event.stopPropagation();
// 记录当前的处于拖拽状态的元素id
this.activeId = id;
// Remove any text selection from the document
this.removeTextSelection();
// Prevent further text selection while dragging
this.windowListeners.add('selectionchange', this.removeTextSelection);
// Resolved cursor error when mouse moving over Safari
this.windowListeners.add('selectstart', (e) => e.preventDefault());
// 绑定接下来的move、end事件
EVENTS.move.forEach((eventName) => {
this.windowListeners.add(eventName, this.handleMove);
});
EVENTS.end.forEach((eventName) => {
this.windowListeners.add(eventName, this.handleEnd);
});
const activeNodeDescriptor = this.manager.getNode(id);
if (activeNodeDescriptor && activeNodeDescriptor.node.current) {
// 每次开始的时候都要先获取一下,避免由于页面滚动造成的偏移
this.clientRect = activeNodeDescriptor.node.current.getBoundingClientRect();
// 通过mouse event获取元素起始位置
this.initOffset = getEventCoordinates(event) as Coordinate;
onStart();
}
}
private handleMove(event: MouseEvent) {
// 如果没有拖拽元素,则return
if (!this.activeId) return;
const { onMove } = this.props;
// 获取并推算出移动的距离
const currentCoordinates = getEventCoordinates(event)!;
const transform = {
x: currentCoordinates.x - this.initOffset.x,
y: currentCoordinates.y - this.initOffset.y
};
this.transform = transform;
onMove(transform, this.activeId, event);
}
private handleEnd(event: Event) {
if (!this.activeId) return;
const { onEnd } = this.props;
// reset本次拖拽的数据状态
this.windowListeners.remove('selectstart');
this.windowListeners.removeAll();
onEnd({ nativeEvent: event, delta: this.transform, id: this.activeId });
this.reset();
}
private reset() {
this.activeId = '';
this.initOffset = undefined;
this.clientRect = undefined;
this.marginRect = undefined;
this.transform = {
x: 0,
y: 0
};
}
private removeTextSelection() {
this.document.getSelection()?.removeAllRanges();
}
}
-
handleStart
方法用于处理鼠标按下事件,它接受一个事件对象和拖拽元素的唯一标识符id作为参数。在该方法内部,它执行了一系列操作,如阻止事件冒泡、清除文本选择、绑定移动和松开事件等。还通过manager获取了拖拽元素的初始位置,并记录下来。最后,调用了onStart函数,表示拖拽开始。 -
handleMove
方法用于处理鼠标移动事件,它接受一个鼠标事件作为参数。在该方法内部,它计算当前位置与初始位置之间的距离,并通过调用onMove函数将移动距离和拖拽元素的唯一标识符传递出去。 -
handleEnd
方法用于处理鼠标松开事件,它接受一个事件对象作为参数。在该方法内部,它执行一些清理操作,如移除事件监听、重置状态等。最后,调用onEnd函数,表示拖拽结束。
最重要的,在onMove
钩子中,我们进行dispatch
将transform
状态更新至 Context 中。
// DndContext
useEffect(() => {
sensorRef.current = new Sensor({
manager,
listener: listenerRef.current,
onMove: useEvent((transform, id, event) => {
// something to do ...
if (id && activeRectRef.current) {
dispatch({
type: DragActionEnum.TRANSFORM,
payload: {
transform
}
});
}
})
});
// set activate event to binding with clicked element
setActivator({
eventName: Sensor.eventName,
handler: sensorRef.current.handleStart
});
}, []);
这里需要注意的是,事件插件的初始化只需要初始化一次,而钩子函数中的逻辑可能需要当前 Context 中的某些 state。考虑到useEffect
的特性,这里需要使用useEvent
Hooks 来包装一下。
/**
* 用于定义一个函数,该函数具有两个特性
* 1. 在组件多次render的时候也会保持一致
* 2. 能够获取到当前组件最新的props和state
* @param fn
* @returns memorizeFn
*/
export const useEvent = (fn: (...args: any[]) => void) => {
const eventRef = useRef<((...args: any[]) => void) | null>(null);
// 组件重新渲染时更新函数本身
useLayoutEffect(() => {
eventRef.current = fn;
});
// 保证函数引用地址
return useCallback((...args: any[]) => {
return eventRef.current && eventRef.current(...args);
}, []);
};
利用 Hooks 绑定元素
在实现了 Context 之后,我们需要消费 Context 中的状态,去实现具体的拖拽效果。
为了最大程度的减少使用者的代码开支,我编写了一个 hooks 去自动将用户的元素变成可拖拽的元素。
/**接受元素唯一id */
export const useDraggable = ({ id }) => {
const { activeId, transform, dispatch, activator, manager } = useDndContext();
const rect = useRef<DOMRect>();
// 当前元素是否处于拖拽状态
const isActive = activeId == id;
const dragNode = useRef<HTMLElement>();
// 处理Context中下发的activator,将其变成以下形式:
// ```js
// {
// onMouseDown:()=>{},
// }
//
const listener = useSyntheticListeners(activator, id);
const setDragNodeRef = useCallback((currentNode: HTMLElement) => {
dragNode.current = currentNode;
if (currentNode) {
rect.current = currentNode.getBoundingClientRect(); // initialize draggables position
}
}, []);
// 将transform处理成CSS Transform的形式
const attributes = {
...(isActive ? setTransform(transform) : {})
};
useEffect(() => {
dispatch({
type: DragActionEnum.PUSH_NODE,
payload: {
node: {
id,
node: dragNode,
clientRect: rect
}
}
});
return () => {
dispatch({
type: DragActionEnum.REMOVE_NODE,
payload: { id }
});
};
}, [dispatch, id, manager, dragNode]);
return {
isActive,
dragNode,
attributes,
listener,
setDragNode: setDragNodeRef
};
};
可以发现,useDraggable
主要所作的工作:
- 去注册元素到 Context Manager 中
- 获取事件以绑定在元素上
- 获取 transform 并转化为 CSS Style 的形式
- 返回最关键的,处理后的事件对象和 CSS Style 对象、是否处于拖拽状态的布尔值等等。
最终,利用useDraggable
就可以将任意元素转化为可拖拽元素啦~
function DraggableElement({ id, className }) {
const { isActive, setDragNode, listener, attributes } = useDraggable({
id
});
return (
<>
<div
ref={setDragNode}
className={classnames(className, {
[`__${prefix}_dragging`]: isActive
})}
style={{ ...attributes }}
{...listener}
>
{children}
</div>
</>
);
}
结语
上述代码也许不能完全运行起来,因为我省略了很多细节,以便读者可以通过思路启发来自行实现一个拖拽组件。
通过使用 React 的 Context 和自定义 Hooks,我们成功地实现了一个可定制的拖拽功能架构。这个架构允许我们管理和操作拖拽元素的状态,并通过拆分事件监听器和管理中心的方式,实现了更好的代码组织和可拓展性。无论是简单的元素拖拽,还是更复杂的功能,我们都可以通过这个架构轻松地构建出符合需求的拖拽功能。
该架构也只是抛砖引玉,未来希望能构建出更灵活更丰富的组件出来。
我是「盐焗乳鸽还要香锅」,喜欢我的文章欢迎关注噢
- github 链接github.com/1360151219
- 掘金账号、知乎账号、简书《盐焗乳鸽还要香锅》
- 思否账号《天天摸鱼真的爽》