我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。。
本文作者:霁明
业务中会有一些需要实现拖拽的场景,尤其是偏视觉方向以及移动端较多。拖拽在一定程度上能让交互更加便捷,能大大提升用户体验。以业务中心子产品配置功能为例,产品模块通过拖拽来调整顺序,的确会更加方便一些。
引用官网介绍:
React DnD 是一组 React 实用程序,可帮助您构建复杂的拖放界面,同时保持组件分离。 它非常适合 Trello 和 Storify 等应用程序,在应用程序的不同部分之间拖动可以传输数据,组件会根据拖放事件更改其外观和应用程序状态。
React-DnD 特点:
安装 react-dnd, react-dnd-html5-backend
npm install react-dnd react-dnd-html5-backend
将需要拖拽的组件使用DndProvider
进行包裹
import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import Container from '../components/container'; export default function App() { return ( <DndProvider backend={HTML5Backend}> <Container /> </DndProvider> ); }
看下Container
组件,主要是管理数据,并渲染Card
列表
function Container() { // ... return ( <div style={{ width: 400 }}> {cards.map((card, index) => ( <Card key={card.id} index={index} id={card.id} text={card.text} moveCard={moveCard} /> ))} </div> ); }
接下来看下Card
组件,
import { useRef } from 'react'; import { useDrag, useDrop } from 'react-dnd'; import styles from '../styles/home.module.css'; function Card({ id, text, index, moveCard }: ICardProps) { const ref = useRef<HTMLDivElement>(null); const [{ handlerId }, drop] = useDrop({ accept: CARD, collect(monitor) { return { handlerId: monitor.getHandlerId(), }; }, hover(item: IDragItem, monitor) { if (!ref.current) { return; } const dragIndex = item.index; const hoverIndex = index; // ... // 更新元素的位置 moveCard(dragIndex, hoverIndex); // ... }, }); const [{ isDragging }, drag] = useDrag({ type: CARD, item: { id, index }, collect: (monitor: any) => ({ isDragging: monitor.isDragging(), }), }); drag(drop(ref)); const opacity = isDragging ? 0 : 1; return ( <div ref={ref} className={styles.card} style={{ opacity }} data-handler-id={handlerId} > {text} </div> ); }
至此一个简单的拖拽排序列表就实现了,实现的效果类似于React DnD官网的这个示例:https://react-dnd.github.io/react-dnd/examples/sortable/simple,接下来我们来看看实现原理。
主要代码代码目录结构
核心代码主要分三个部分:
核心实现原理:
dnd-core向backend提供数据的更新方法,backend在拖拽时更新dnd-core中的数据,dnd-core通过react-dnd更新业务组件。
先看一下源码
/** * A React component that provides the React-DnD context */ export const DndProvider: FC<DndProviderProps<unknown, unknown>> = memo( function DndProvider({ children, ...props }) { const [manager, isGlobalInstance] = getDndContextValue(props) // memoized from props // ... return <DndContext.Provider value={manager}>{children}</DndContext.Provider> }, )
从以上代码可以看出,生成了一个manager
,并将其放到DndContext.Provider
中。先看下DndContext
的代码:
import { createContext } from 'react' // ... export const DndContext = createContext<DndContextType>({ dragDropManager: undefined, })
就是使用 React 的createContext
创建的上下文容器组件。
接下来看下这个manager,主要是用来控制拖拽行为,通过Provider让子节点也可以访问。我们看下创建manager的getDndContextValue
方法:
import type { BackendFactory, DragDropManager } from 'dnd-core' import { createDragDropManager } from 'dnd-core' // ... function getDndContextValue(props: DndProviderProps<unknown, unknown>) { if ('manager' in props) { const manager = { dragDropManager: props.manager } return [manager, false] } const manager = createSingletonDndContext( props.backend, props.context, props.options, props.debugMode, ) const isGlobalInstance = !props.context return [manager, isGlobalInstance] } function createSingletonDndContext<BackendContext, BackendOptions>( backend: BackendFactory, context: BackendContext = getGlobalContext(), options: BackendOptions, debugMode?: boolean, ) { const ctx = context as any if (!ctx[INSTANCE_SYM]) { ctx[INSTANCE_SYM] = { dragDropManager: createDragDropManager( backend, context, options, debugMode, ), } } return ctx[INSTANCE_SYM] }
从以上代码可以看出,getDndContextValue
方法又调用了createSingletonDndContext
方法,并传入了backend、context、options、debugMode这几个属性,然后通过dnd-core中的createDragDropManager
来创建manager。
看下createDragDropManager.js中的主要代码
import type { Store } from 'redux' import { createStore } from 'redux' // ... import { reduce } from './reducers/index.js' export function createDragDropManager( backendFactory: BackendFactory, globalContext: unknown = undefined, backendOptions: unknown = {}, debugMode = false, ): DragDropManager { const store = makeStoreInstance(debugMode) const monitor = new DragDropMonitorImpl(store, new HandlerRegistryImpl(store)) const manager = new DragDropManagerImpl(store, monitor) const backend = backendFactory(manager, globalContext, backendOptions) manager.receiveBackend(backend) return manager } function makeStoreInstance(debugMode: boolean): Store<State> { // ... return createStore( reduce, debugMode && reduxDevTools && reduxDevTools({ name: 'dnd-core', instanceId: 'dnd-core', }), ) }
可以看到使用了redux的createStore创建了store,并创建了monitor和manager实例,通过backendFactory创建backend后端实例并安装到manager总实例。
看一下DragDropManagerImpl的主要代码
export class DragDropManagerImpl implements DragDropManager { private store: Store<State> private monitor: DragDropMonitor private backend: Backend | undefined private isSetUp = false public constructor(store: Store<State>, monitor: DragDropMonitor) { this.store = store this.monitor = monitor store.subscribe(this.handleRefCountChange) } // ... public getActions(): DragDropActions { /* eslint-disable-next-line @typescript-eslint/no-this-alias */ const manager = this const { dispatch } = this.store function bindActionCreator(actionCreator: ActionCreator<any>) { return (...args: any[]) => { const action = actionCreator.apply(manager, args as any) if (typeof action !== 'undefined') { dispatch(action) } } } const actions = createDragDropActions(this) return Object.keys(actions).reduce( (boundActions: DragDropActions, key: string) => { const action: ActionCreator<any> = (actions as any)[ key ] as ActionCreator<any> ;(boundActions as any)[key] = bindActionCreator(action) return boundActions }, {} as DragDropActions, ) } public dispatch(action: Action<any>): void { this.store.dispatch(action) } private handleRefCountChange = (): void => { const shouldSetUp = this.store.getState().refCount > 0 if (this.backend) { if (shouldSetUp && !this.isSetUp) { this.backend.setup() this.isSetUp = true } else if (!shouldSetUp && this.isSetUp) { this.backend.teardown() this.isSetUp = false } } } }
先说一下这个handleRefCountChange方法,在构造函数里通过store进行订阅,在第一次使用useDrop或useDrag时会执行setup方法初始化backend,在拖拽源和放置源都被卸载时则会执行teardown销毁backend。
接下来看一下createDragDropActions方法
export function createDragDropActions( manager: DragDropManager, ): DragDropActions { return { beginDrag: createBeginDrag(manager), publishDragSource: createPublishDragSource(manager), hover: createHover(manager), drop: createDrop(manager), endDrag: createEndDrag(manager), } }
可以看到绑定一些action:
manager包含了之前生成的 monitor、store、backend,manager 创建完成,表示此时我们有了一个 store 来管理拖拽中的数据,有了 monitor 来监听数据和控制行为,能通过 manager 进行注册,可以通过 backend 将 DOM 事件转换为 action。接下来便可以注册拖拽源和放置源了。
/** * useDragSource hook * @param sourceSpec The drag source specification (object or function, function preferred) * @param deps The memoization deps array to use when evaluating spec changes */ export function useDrag< DragObject = unknown, DropResult = unknown, CollectedProps = unknown, >( specArg: FactoryOrInstance< DragSourceHookSpec<DragObject, DropResult, CollectedProps> >, deps?: unknown[], ): [CollectedProps, ConnectDragSource, ConnectDragPreview] { const spec = useOptionalFactory(specArg, deps) invariant( !(spec as any).begin, 'useDrag::spec.begin was deprecated in v14. Replace spec.begin() with spec.item(). (see more here - https://react-dnd.github.io/react-dnd/docs/api/use-drag)', ) const monitor = useDragSourceMonitor<DragObject, DropResult>() const connector = useDragSourceConnector(spec.options, spec.previewOptions) useRegisteredDragSource(spec, monitor, connector) return [ useCollectedProps(spec.collect, monitor, connector), useConnectDragSource(connector), useConnectDragPreview(connector), ] }
可以看到useDrag
方法返回了一个包含3个元素的数组,CollectedProps(collect方法返回的对象)、ConnectDragSource(拖拽源连接器)、ConnectDragPreview(拖拽源预览)。
monitor是从前面Provider中的manager中获取的,主要看下connector
export function useDragSourceConnector( dragSourceOptions: DragSourceOptions | undefined, dragPreviewOptions: DragPreviewOptions | undefined, ): SourceConnector { const manager = useDragDropManager() const connector = useMemo( () => new SourceConnector(manager.getBackend()), [manager], ) // ... return connector }
可以看到connector获取了manager.getBackend后端的数据。
useRegisteredDragSource方法会对拖动源进行注册,会保存拖动源实例,并记录注册的数量。
看下useDrop源码
/** * useDropTarget Hook * @param spec The drop target specification (object or function, function preferred) * @param deps The memoization deps array to use when evaluating spec changes */ export function useDrop< DragObject = unknown, DropResult = unknown, CollectedProps = unknown, >( specArg: FactoryOrInstance< DropTargetHookSpec<DragObject, DropResult, CollectedProps> >, deps?: unknown[], ): [CollectedProps, ConnectDropTarget] { const spec = useOptionalFactory(specArg, deps) const monitor = useDropTargetMonitor<DragObject, DropResult>() const connector = useDropTargetConnector(spec.options) useRegisteredDropTarget(spec, monitor, connector) return [ useCollectedProps(spec.collect, monitor, connector), useConnectDropTarget(connector), ] }
useDrop返回了一个包含2个元素的数组,CollectedProps(collect方法返回的对象), ConnectDropTarget(放置源连接器),monitor和connector的获取都和useDrag类似。
HTML5Backend使用了HTML5 拖放 API,先了解下HTML拖拽事件:
一个简单拖拽操作过程,会依次触发拖拽事件:dragstart -> drag -> dragenter -> dragover (-> dragleave) -> drop -> dragend。
drag事件会在dragstar触发后持续触发,直至drop。
dragleave事件会在拖拽元素离开一个可释放目标时触发。
接下来介绍一下HTML5Backend,是React DnD 主要支持的后端,使用HTML5 拖放 API,它会截取拖动的 DOM 节点并将其用作开箱即用的“拖动预览”。React DnD 中以可插入的方式实现 HTML5 拖放支持,可以根据触摸事件、鼠标事件或其他完全不同的事件编写不同的实现,这种可插入的实现在 React DnD 中称为后端。官网提供了HTML5Backend和TouchBackend,分别用来支持web端和移动端。
后端担任与 React 的合成事件系统类似的角色:它们抽象出浏览器差异并处理原生DOM 事件。尽管有相似之处,但 React DnD 后端并不依赖于 React 或其合成事件系统。在后台,后端所做的就是将 DOM 事件转换为 React DnD 可以处理的内部 Redux 操作。
前面给DndProvider传递的HTML5backend,看一下其代码实现:
export const HTML5Backend: BackendFactory = function createBackend( manager: DragDropManager, context?: HTML5BackendContext, options?: HTML5BackendOptions, ): HTML5BackendImpl { return new HTML5BackendImpl(manager, context, options) }
可以看到其实是个返回HTML5BackendImpl
实例的函数,在创建manager实例时会执行createBackend方法创建真正的backend。
如下是 Backend 需要被实现的方法:
export interface Backend { setup(): void teardown(): void connectDragSource(sourceId: any, node?: any, options?: any): Unsubscribe connectDragPreview(sourceId: any, node?: any, options?: any): Unsubscribe connectDropTarget(targetId: any, node?: any, options?: any): Unsubscribe profile(): Record<string, number> }
setup 是 backend 的初始化方法,teardown 是 backend 销毁方法。connectDragSource方法将元素转换为可拖拽元素,并添加监听事件。connectDropTarget方法会给元素添加监听事件,connectDragPreview方法会将preview元素保存以供监听函数使用,profile方法用于返回一些简要的统计信息。
以上这几个方法都在HTML5BackendImpl中,我们先看一下setup方法:
public setup(): void { const root = this.rootElement as RootNode | undefined if (root === undefined) { return } if (root.__isReactDndBackendSetUp) { throw new Error('Cannot have two HTML5 backends at the same time.') } root.__isReactDndBackendSetUp = true this.addEventListeners(root) }
root默认是windows,通过addEventListeners方法把监听事件都绑定到windows上,这提高了性能也降低了事件销毁的难度。
看下addEventListeners方法:
private addEventListeners(target: Node) { if (!target.addEventListener) { return } target.addEventListener( 'dragstart', this.handleTopDragStart as EventListener, ) target.addEventListener('dragstart', this.handleTopDragStartCapture, true) target.addEventListener('dragend', this.handleTopDragEndCapture, true) target.addEventListener( 'dragenter', this.handleTopDragEnter as EventListener, ) target.addEventListener( 'dragenter', this.handleTopDragEnterCapture as EventListener, true, ) target.addEventListener( 'dragleave', this.handleTopDragLeaveCapture as EventListener, true, ) target.addEventListener('dragover', this.handleTopDragOver as EventListener) target.addEventListener( 'dragover', this.handleTopDragOverCapture as EventListener, true, ) target.addEventListener('drop', this.handleTopDrop as EventListener) target.addEventListener( 'drop', this.handleTopDropCapture as EventListener, true, ) }
以上代码中监听了一些拖拽事件,这些监听函数会获得拖拽事件的对象、拿到相应的参数,并执行相应的action方法。HTML5Backend 通过 manager 拿到一个 DragDropActions 的实例,执行其中的方法。DragDropActions 本质就是根据参数将其封装为一个 action,最终通过 redux 的 dispatch 将 action 分发,改变 store 中的数据。
export interface DragDropActions { beginDrag( sourceIds?: Identifier[], options?: any, ): Action<BeginDragPayload> | undefined publishDragSource(): SentinelAction | undefined hover(targetIds: Identifier[], options?: any): Action<HoverPayload> drop(options?: any): void endDrag(): SentinelAction }
最后我们再看下connectDragSource方法:
public connectDragSource( sourceId: string, node: Element, options: any, ): Unsubscribe { // ... node.setAttribute('draggable', 'true') node.addEventListener('dragstart', handleDragStart) node.addEventListener('selectstart', handleSelectStart) return (): void => { // ... node.removeEventListener('dragstart', handleDragStart) node.removeEventListener('selectstart', handleSelectStart) node.setAttribute('draggable', 'false') } }
可以看到主要是把节点的draggable属性设置为true,并添加监听事件,返回一个Unsubscribe函数用于执行销毁。
综上,HTML5Backend 在初始化的时候在 window 对象上绑定拖拽事件的监听函数,拖拽事件触发时执行对应action,更新 store 中的数据,完成由 Dom 事件到数据的转变。
HTML5 后端不支持触摸事件,因此它不适用于平板电脑和移动设备。可以使用react-dnd-touch-backend
来支持触摸设备,简单看下ToucheBackend。
ToucheBackend主要是为了支持移动端,也支持web端,在web端可以使用 mousedown、mousemove、mouseup,在移动端则使用 touchstart、touchmove、touchend,下面是ToucheBackend中对事件的定义:
const eventNames: Record<ListenerType, EventName> = { [ListenerType.mouse]: { start: 'mousedown', move: 'mousemove', end: 'mouseup', contextmenu: 'contextmenu', }, [ListenerType.touch]: { start: 'touchstart', move: 'touchmove', end: 'touchend', }, [ListenerType.keyboard]: { keydown: 'keydown', }, }
React-DnD 采用了分层设计,react-dnd充当接入层,dnd-core实现拖拽接口、定义拖拽行为、管理数据流向,backend将DOM事件通过redux action转换为数据。
使用可插入的方式引入backend,使拖拽的实现可扩展且更加灵活。
使用了单向数据流,在拖拽时不用处理中间状态,不用额外对DOM事件进行处理,只需专注于数据的变化。
React-DnD对backend的实现方式、数据的管理方式,以及整体的设计都值得借鉴。
欢迎关注【袋鼠云数栈UED团队】~
袋鼠云数栈UED团队持续为广大开发者分享技术成果,相继参与开源了欢迎star